dns-providers.test.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. import assert from 'node:assert/strict';
  2. import { afterEach, test } from 'node:test';
  3. import { applyDnsSetup, testDnsCredential } from '../src/dns-providers.js';
  4. const originalFetch = globalThis.fetch;
  5. afterEach(() => {
  6. globalThis.fetch = originalFetch;
  7. });
  8. test('cloudflare provider tests credentials and replaces duplicate SPF records', async () => {
  9. const calls = [];
  10. globalThis.fetch = async (url, options = {}) => {
  11. calls.push({ url: String(url), method: options.method || 'GET', body: options.body });
  12. if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  13. if (String(url).includes('/zones/zone-1') && !String(url).includes('/dns_records')) {
  14. return json({ success: true, result: { id: 'zone-1', name: 'example.com' } });
  15. }
  16. if (String(url).includes('/dns_records?')) {
  17. return json({
  18. success: true,
  19. result: [
  20. { id: 'spf-1', type: 'TXT', name: 'example.com', content: 'v=spf1 include:old ~all' },
  21. { id: 'spf-2', type: 'TXT', name: 'example.com', content: 'v=spf1 include:duplicate ~all' }
  22. ]
  23. });
  24. }
  25. return json({ success: true, result: { id: 'ok' } });
  26. };
  27. const credential = cloudflareCredential();
  28. assert.equal((await testDnsCredential(credential)).ok, true);
  29. const result = await applyDnsSetup(domainFixture(), credential, {
  30. records: [{ key: 'spf', host: 'example.com', type: 'TXT', value: 'v=spf1 ip4:127.0.0.1 ~all' }]
  31. });
  32. assert.equal(result.ok, true);
  33. assert.ok(calls.some((call) => call.method === 'PUT' && call.url.includes('/dns_records/spf-1')));
  34. assert.ok(calls.some((call) => call.method === 'DELETE' && call.url.includes('/dns_records/spf-2')));
  35. });
  36. test('cloudflare provider paginates exact record lookups before creating SPF records', async () => {
  37. const calls = [];
  38. globalThis.fetch = async (url, options = {}) => {
  39. const urlText = String(url);
  40. calls.push({ url: urlText, method: options.method || 'GET', body: options.body });
  41. if (urlText.includes('/zones?name=example.com')) {
  42. return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  43. }
  44. if (urlText.includes('/dns_records?')) {
  45. const params = new URL(urlText).searchParams;
  46. const page = Number(params.get('page') || 1);
  47. if (page === 1) {
  48. return json({
  49. success: true,
  50. result: [{ id: 'txt-1', type: 'TXT', name: 'example.com', content: 'google-site-verification=abc' }],
  51. result_info: { page: 1, total_pages: 2 }
  52. });
  53. }
  54. return json({
  55. success: true,
  56. result: [
  57. { id: 'spf-1', type: 'TXT', name: 'example.com', content: 'v=spf1 include:spf.mailjet.com +include:spf.97admin.com -all' },
  58. { id: 'spf-2', type: 'TXT', name: 'example.com', content: 'v=spf1 include:spf.mailjet.com include:spf.97admin.com ip4:192.0.2.10 a:in.example.com -all' }
  59. ],
  60. result_info: { page: 2, total_pages: 2 }
  61. });
  62. }
  63. return json({ success: true, result: { id: 'ok' } });
  64. };
  65. const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
  66. records: [
  67. {
  68. key: 'spf',
  69. host: 'example.com',
  70. type: 'TXT',
  71. value: 'v=spf1 include:spf.mailjet.com include:spf.97admin.com ip4:192.0.2.10 a:in.example.com -all'
  72. }
  73. ]
  74. });
  75. assert.equal(result.ok, true);
  76. assert.ok(calls.some((call) => call.url.includes('name.exact=example.com')));
  77. assert.ok(calls.some((call) => call.method === 'PUT' && call.url.includes('/dns_records/spf-1')));
  78. assert.ok(calls.some((call) => call.method === 'DELETE' && call.url.includes('/dns_records/spf-2')));
  79. assert.equal(calls.some((call) => call.method === 'POST'), false);
  80. });
  81. test('cloudflare provider matches quoted TXT SPF content returned by the API', async () => {
  82. const calls = [];
  83. globalThis.fetch = async (url, options = {}) => {
  84. const urlText = String(url);
  85. calls.push({ url: urlText, method: options.method || 'GET', body: options.body });
  86. if (urlText.includes('/zones?name=example.com')) {
  87. return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  88. }
  89. if (urlText.includes('/dns_records?')) {
  90. return json({
  91. success: true,
  92. result: [
  93. { id: 'spf-1', type: 'TXT', name: 'example.com', content: '"v=spf1 include:spf.mailjet.com +include:spf.97admin.com -all"' },
  94. { id: 'spf-2', type: 'TXT', name: 'example.com', content: '"v=spf1 include:spf.mailjet.com include:spf.97admin.com ip4:192.0.2.10 a:in.example.com -all"' }
  95. ],
  96. result_info: { page: 1, total_pages: 1 }
  97. });
  98. }
  99. return json({ success: true, result: { id: 'ok' } });
  100. };
  101. const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
  102. records: [
  103. {
  104. key: 'spf',
  105. host: 'example.com',
  106. type: 'TXT',
  107. value: 'v=spf1 include:spf.mailjet.com include:spf.97admin.com ip4:192.0.2.10 a:in.example.com -all'
  108. }
  109. ]
  110. });
  111. assert.equal(result.ok, true);
  112. assert.ok(calls.some((call) => call.method === 'PUT' && call.url.includes('/dns_records/spf-1')));
  113. assert.ok(calls.some((call) => call.method === 'DELETE' && call.url.includes('/dns_records/spf-2')));
  114. assert.equal(calls.some((call) => call.method === 'POST'), false);
  115. });
  116. test('aliyun provider signs and sends create/update record actions', async () => {
  117. const actions = [];
  118. globalThis.fetch = async (url) => {
  119. const params = new URL(String(url)).searchParams;
  120. const action = params.get('Action');
  121. actions.push(action);
  122. if (action === 'DescribeDomainRecords') {
  123. return json({
  124. DomainRecords: {
  125. Record: [{ RecordId: '1', RR: '_dmarc', Type: 'TXT', Value: 'v=DMARC1; p=none' }]
  126. }
  127. });
  128. }
  129. return json({});
  130. };
  131. const credential = aliyunCredential();
  132. assert.equal((await testDnsCredential(credential)).ok, true);
  133. const result = await applyDnsSetup(domainFixture(), credential, {
  134. records: [{ key: 'dmarc', host: '_dmarc.example.com', type: 'TXT', value: 'v=DMARC1; p=reject' }]
  135. });
  136. assert.equal(result.ok, true);
  137. assert.ok(actions.includes('UpdateDomainRecord'));
  138. });
  139. test('aliyun one-click dns falls back to parent zone for subdomain sending domains', async () => {
  140. const calls = [];
  141. globalThis.fetch = async (url) => {
  142. const params = new URL(String(url)).searchParams;
  143. calls.push({
  144. action: params.get('Action'),
  145. domainName: params.get('DomainName'),
  146. rr: params.get('RR')
  147. });
  148. if (params.get('DomainName') === 'notify.example.com') {
  149. return json({
  150. Code: 'InvalidDomainName.NoExist',
  151. Message: 'domain not found'
  152. });
  153. }
  154. if (params.get('Action') === 'DescribeDomainRecords') {
  155. return json({ DomainRecords: { Record: [] } });
  156. }
  157. return json({});
  158. };
  159. const result = await applyDnsSetup(
  160. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  161. { ...aliyunCredential(), zoneName: 'notify.example.com' },
  162. {
  163. records: [
  164. {
  165. key: 'verification',
  166. host: '_mailhub.notify.example.com',
  167. type: 'TXT',
  168. value: 'mailhub-verification=token',
  169. status: 'missing'
  170. }
  171. ]
  172. }
  173. );
  174. assert.equal(result.ok, true);
  175. assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'notify.example.com'));
  176. assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'example.com'));
  177. assert.ok(calls.some((call) => (
  178. call.action === 'AddDomainRecord'
  179. && call.domainName === 'example.com'
  180. && call.rr === '_mailhub.notify'
  181. )));
  182. });
  183. test('aliyun one-click dns can use another managed root zone with the same credentials', async () => {
  184. const calls = [];
  185. globalThis.fetch = async (url) => {
  186. const params = new URL(String(url)).searchParams;
  187. calls.push({
  188. action: params.get('Action'),
  189. domainName: params.get('DomainName'),
  190. rr: params.get('RR')
  191. });
  192. if (params.get('Action') === 'DescribeDomainRecords') {
  193. return json({ DomainRecords: { Record: [] } });
  194. }
  195. return json({});
  196. };
  197. const result = await applyDnsSetup(
  198. { domain: 'phplife.net', senderHost: 'mail.phplife.net', sendingIp: '127.0.0.1' },
  199. { ...aliyunCredential(), zoneName: 'ss5.xyz' },
  200. {
  201. records: [
  202. {
  203. key: 'verification',
  204. host: '_mailhub.phplife.net',
  205. type: 'TXT',
  206. value: 'mailhub-verification=token',
  207. status: 'missing'
  208. }
  209. ]
  210. }
  211. );
  212. assert.equal(result.ok, true);
  213. assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'phplife.net'));
  214. assert.ok(calls.some((call) => (
  215. call.action === 'AddDomainRecord'
  216. && call.domainName === 'phplife.net'
  217. && call.rr === '_mailhub'
  218. )));
  219. });
  220. test('dnspod provider signs and sends create record actions', async () => {
  221. const actions = [];
  222. globalThis.fetch = async (url, options = {}) => {
  223. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  224. actions.push(options.headers['X-TC-Action']);
  225. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  226. return json({ Response: { RecordList: [] } });
  227. }
  228. return json({ Response: { RecordId: 123 } });
  229. };
  230. const credential = dnspodCredential();
  231. assert.equal((await testDnsCredential(credential)).ok, true);
  232. const result = await applyDnsSetup(domainFixture(), credential, {
  233. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  234. });
  235. assert.equal(result.ok, true);
  236. assert.ok(actions.includes('CreateRecord'));
  237. });
  238. test('dnspod provider treats empty record list responses as no existing records', async () => {
  239. const actions = [];
  240. globalThis.fetch = async (url, options = {}) => {
  241. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  242. actions.push(options.headers['X-TC-Action']);
  243. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  244. return json({
  245. Response: {
  246. Error: {
  247. Code: 'FailedOperation.RecordListEmpty',
  248. Message: '记录列表为空。'
  249. }
  250. }
  251. });
  252. }
  253. return json({ Response: { RecordId: 123 } });
  254. };
  255. const result = await applyDnsSetup(domainFixture(), dnspodCredential(), {
  256. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  257. });
  258. assert.equal(result.ok, true);
  259. assert.equal(result.results[0].detail, 'created');
  260. assert.ok(actions.includes('CreateRecord'));
  261. });
  262. test('dnspod one-click dns falls back to parent zone for subdomain sending domains', async () => {
  263. const calls = [];
  264. globalThis.fetch = async (url, options = {}) => {
  265. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  266. const payload = JSON.parse(options.body);
  267. calls.push({ action: options.headers['X-TC-Action'], payload });
  268. if (payload.Domain === 'notify.example.com') {
  269. return json({
  270. Response: {
  271. Error: {
  272. Code: 'ResourceNotFound.NoDataOfRecord',
  273. Message: 'domain not found'
  274. }
  275. }
  276. });
  277. }
  278. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  279. return json({ Response: { RecordList: [] } });
  280. }
  281. return json({ Response: { RecordId: 123 } });
  282. };
  283. const result = await applyDnsSetup(
  284. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  285. { ...dnspodCredential(), zoneName: 'notify.example.com' },
  286. {
  287. records: [
  288. {
  289. key: 'verification',
  290. host: '_mailhub.notify.example.com',
  291. type: 'TXT',
  292. value: 'mailhub-verification=token',
  293. status: 'missing'
  294. }
  295. ]
  296. }
  297. );
  298. assert.equal(result.ok, true);
  299. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'notify.example.com'));
  300. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'example.com'));
  301. assert.ok(calls.some((call) => (
  302. call.action === 'CreateRecord'
  303. && call.payload.Domain === 'example.com'
  304. && call.payload.SubDomain === '_mailhub.notify'
  305. )));
  306. });
  307. test('dnspod one-click dns can use another managed root zone with the same credentials', async () => {
  308. const calls = [];
  309. globalThis.fetch = async (url, options = {}) => {
  310. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  311. const payload = JSON.parse(options.body);
  312. calls.push({ action: options.headers['X-TC-Action'], payload });
  313. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  314. return json({ Response: { RecordList: [] } });
  315. }
  316. return json({ Response: { RecordId: 123 } });
  317. };
  318. const result = await applyDnsSetup(
  319. { domain: 'phplife.net', senderHost: 'mail.phplife.net', sendingIp: '127.0.0.1' },
  320. { ...dnspodCredential(), zoneName: 'ss5.xyz' },
  321. {
  322. records: [
  323. {
  324. key: 'verification',
  325. host: '_mailhub.phplife.net',
  326. type: 'TXT',
  327. value: 'mailhub-verification=token',
  328. status: 'missing'
  329. }
  330. ]
  331. }
  332. );
  333. assert.equal(result.ok, true);
  334. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'phplife.net'));
  335. assert.ok(calls.some((call) => (
  336. call.action === 'CreateRecord'
  337. && call.payload.Domain === 'phplife.net'
  338. && call.payload.SubDomain === '_mailhub'
  339. )));
  340. });
  341. test('one-click dns setup only applies records under the user domain zone', async () => {
  342. const calls = [];
  343. globalThis.fetch = async (url, options = {}) => {
  344. calls.push({ url: String(url), method: options.method || 'GET' });
  345. if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  346. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  347. return json({ success: true, result: { id: 'ok' } });
  348. };
  349. const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
  350. records: [
  351. {
  352. key: 'dkim',
  353. host: 'mh._domainkey.example.com',
  354. type: 'TXT',
  355. value: 'v=DKIM1; k=rsa; p=abc',
  356. status: 'missing'
  357. },
  358. {
  359. key: 'sender-a',
  360. host: 'smtp.example.com',
  361. type: 'A',
  362. value: '127.0.0.1',
  363. status: 'ok'
  364. }
  365. ]
  366. });
  367. assert.equal(result.ok, true);
  368. assert.equal(result.results.length, 1);
  369. assert.equal(result.results[0].key, 'dkim');
  370. assert.equal(calls.filter((call) => call.method === 'POST').length, 1);
  371. });
  372. test('cloudflare one-click dns can use the current domain zone with a multi-zone token', async () => {
  373. const calls = [];
  374. globalThis.fetch = async (url, options = {}) => {
  375. calls.push({ url: String(url), method: options.method || 'GET' });
  376. if (String(url).includes('/zones?name=other.com')) {
  377. return json({ success: true, result: [{ id: 'zone-other', name: 'other.com' }] });
  378. }
  379. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  380. return json({ success: true, result: { id: 'ok' } });
  381. };
  382. const result = await applyDnsSetup(
  383. { ...domainFixture(), domain: 'other.com', senderHost: 'mail.other.com' },
  384. cloudflareCredential(),
  385. {
  386. records: [
  387. {
  388. key: 'verification',
  389. host: '_mailhub.other.com',
  390. type: 'TXT',
  391. value: 'mailhub-verification=token',
  392. status: 'missing'
  393. }
  394. ]
  395. }
  396. );
  397. assert.equal(result.ok, true);
  398. assert.equal(result.results[0].ok, true);
  399. assert.ok(calls.some((call) => call.url.includes('/zones?name=other.com')));
  400. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-other/dns_records')));
  401. });
  402. test('cloudflare one-click dns discovers the parent zone for subdomain sending domains', async () => {
  403. const calls = [];
  404. globalThis.fetch = async (url, options = {}) => {
  405. calls.push({ url: String(url), method: options.method || 'GET' });
  406. if (String(url).includes('/zones?name=sender.example.com')) {
  407. return json({ success: true, result: [] });
  408. }
  409. if (String(url).includes('/zones?name=example.com')) {
  410. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  411. }
  412. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  413. return json({ success: true, result: { id: 'ok' } });
  414. };
  415. const result = await applyDnsSetup(
  416. { ...domainFixture(), domain: 'sender.example.com', senderHost: 'smtp.example.com' },
  417. { ...cloudflareCredential(), zoneName: 'example.org' },
  418. {
  419. records: [
  420. {
  421. key: 'verification',
  422. host: '_mailhub.sender.example.com',
  423. type: 'TXT',
  424. value: 'mailhub-verification=token',
  425. status: 'missing'
  426. }
  427. ]
  428. }
  429. );
  430. assert.equal(result.ok, true);
  431. assert.equal(result.results[0].ok, true);
  432. assert.ok(calls.some((call) => call.url.includes('/zones?name=sender.example.com')));
  433. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  434. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  435. });
  436. test('cloudflare one-click dns falls back from configured child zone to parent zone', async () => {
  437. const calls = [];
  438. globalThis.fetch = async (url, options = {}) => {
  439. calls.push({ url: String(url), method: options.method || 'GET' });
  440. if (String(url).includes('/zones?name=notify.example.com')) {
  441. return json({ success: true, result: [] });
  442. }
  443. if (String(url).includes('/zones?name=example.com')) {
  444. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  445. }
  446. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  447. return json({ success: true, result: { id: 'ok' } });
  448. };
  449. const result = await applyDnsSetup(
  450. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  451. { ...cloudflareCredential(), zoneName: 'notify.example.com' },
  452. {
  453. records: [
  454. {
  455. key: 'verification',
  456. host: '_mailhub.notify.example.com',
  457. type: 'TXT',
  458. value: 'mailhub-verification=token',
  459. status: 'missing'
  460. }
  461. ]
  462. }
  463. );
  464. assert.equal(result.ok, true);
  465. assert.equal(result.results[0].ok, true);
  466. assert.ok(calls.some((call) => call.url.includes('/zones?name=notify.example.com')));
  467. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  468. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  469. });
  470. function cloudflareCredential() {
  471. return {
  472. provider: 'cloudflare',
  473. zoneName: 'example.com',
  474. defaultTtl: 600,
  475. credentials: { apiToken: 'token' }
  476. };
  477. }
  478. function aliyunCredential() {
  479. return {
  480. provider: 'aliyun',
  481. zoneName: 'example.com',
  482. defaultTtl: 600,
  483. credentials: { accessKeyId: 'id', accessKeySecret: 'secret' }
  484. };
  485. }
  486. function dnspodCredential() {
  487. return {
  488. provider: 'dnspod',
  489. zoneName: 'example.com',
  490. defaultTtl: 600,
  491. credentials: { secretId: 'id', secretKey: 'secret' }
  492. };
  493. }
  494. function domainFixture() {
  495. return {
  496. domain: 'example.com',
  497. senderHost: 'mail.example.com',
  498. sendingIp: '127.0.0.1'
  499. };
  500. }
  501. function json(payload) {
  502. return {
  503. ok: true,
  504. status: 200,
  505. async json() {
  506. return payload;
  507. }
  508. };
  509. }