dns-providers.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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('dnspod provider signs and sends create record actions', async () => {
  184. const actions = [];
  185. globalThis.fetch = async (url, options = {}) => {
  186. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  187. actions.push(options.headers['X-TC-Action']);
  188. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  189. return json({ Response: { RecordList: [] } });
  190. }
  191. return json({ Response: { RecordId: 123 } });
  192. };
  193. const credential = dnspodCredential();
  194. assert.equal((await testDnsCredential(credential)).ok, true);
  195. const result = await applyDnsSetup(domainFixture(), credential, {
  196. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  197. });
  198. assert.equal(result.ok, true);
  199. assert.ok(actions.includes('CreateRecord'));
  200. });
  201. test('dnspod provider treats empty record list responses as no existing records', async () => {
  202. const actions = [];
  203. globalThis.fetch = async (url, options = {}) => {
  204. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  205. actions.push(options.headers['X-TC-Action']);
  206. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  207. return json({
  208. Response: {
  209. Error: {
  210. Code: 'FailedOperation.RecordListEmpty',
  211. Message: '记录列表为空。'
  212. }
  213. }
  214. });
  215. }
  216. return json({ Response: { RecordId: 123 } });
  217. };
  218. const result = await applyDnsSetup(domainFixture(), dnspodCredential(), {
  219. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  220. });
  221. assert.equal(result.ok, true);
  222. assert.equal(result.results[0].detail, 'created');
  223. assert.ok(actions.includes('CreateRecord'));
  224. });
  225. test('dnspod one-click dns falls back to parent zone for subdomain sending domains', async () => {
  226. const calls = [];
  227. globalThis.fetch = async (url, options = {}) => {
  228. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  229. const payload = JSON.parse(options.body);
  230. calls.push({ action: options.headers['X-TC-Action'], payload });
  231. if (payload.Domain === 'notify.example.com') {
  232. return json({
  233. Response: {
  234. Error: {
  235. Code: 'ResourceNotFound.NoDataOfRecord',
  236. Message: 'domain not found'
  237. }
  238. }
  239. });
  240. }
  241. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  242. return json({ Response: { RecordList: [] } });
  243. }
  244. return json({ Response: { RecordId: 123 } });
  245. };
  246. const result = await applyDnsSetup(
  247. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  248. { ...dnspodCredential(), zoneName: 'notify.example.com' },
  249. {
  250. records: [
  251. {
  252. key: 'verification',
  253. host: '_mailhub.notify.example.com',
  254. type: 'TXT',
  255. value: 'mailhub-verification=token',
  256. status: 'missing'
  257. }
  258. ]
  259. }
  260. );
  261. assert.equal(result.ok, true);
  262. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'notify.example.com'));
  263. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'example.com'));
  264. assert.ok(calls.some((call) => (
  265. call.action === 'CreateRecord'
  266. && call.payload.Domain === 'example.com'
  267. && call.payload.SubDomain === '_mailhub.notify'
  268. )));
  269. });
  270. test('one-click dns setup only applies records under the user domain zone', async () => {
  271. const calls = [];
  272. globalThis.fetch = async (url, options = {}) => {
  273. calls.push({ url: String(url), method: options.method || 'GET' });
  274. if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  275. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  276. return json({ success: true, result: { id: 'ok' } });
  277. };
  278. const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
  279. records: [
  280. {
  281. key: 'dkim',
  282. host: 'mh._domainkey.example.com',
  283. type: 'TXT',
  284. value: 'v=DKIM1; k=rsa; p=abc',
  285. status: 'missing'
  286. },
  287. {
  288. key: 'sender-a',
  289. host: 'smtp.example.com',
  290. type: 'A',
  291. value: '127.0.0.1',
  292. status: 'ok'
  293. }
  294. ]
  295. });
  296. assert.equal(result.ok, true);
  297. assert.equal(result.results.length, 1);
  298. assert.equal(result.results[0].key, 'dkim');
  299. assert.equal(calls.filter((call) => call.method === 'POST').length, 1);
  300. });
  301. test('cloudflare one-click dns can use the current domain zone with a multi-zone token', async () => {
  302. const calls = [];
  303. globalThis.fetch = async (url, options = {}) => {
  304. calls.push({ url: String(url), method: options.method || 'GET' });
  305. if (String(url).includes('/zones?name=other.com')) {
  306. return json({ success: true, result: [{ id: 'zone-other', name: 'other.com' }] });
  307. }
  308. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  309. return json({ success: true, result: { id: 'ok' } });
  310. };
  311. const result = await applyDnsSetup(
  312. { ...domainFixture(), domain: 'other.com', senderHost: 'mail.other.com' },
  313. cloudflareCredential(),
  314. {
  315. records: [
  316. {
  317. key: 'verification',
  318. host: '_mailhub.other.com',
  319. type: 'TXT',
  320. value: 'mailhub-verification=token',
  321. status: 'missing'
  322. }
  323. ]
  324. }
  325. );
  326. assert.equal(result.ok, true);
  327. assert.equal(result.results[0].ok, true);
  328. assert.ok(calls.some((call) => call.url.includes('/zones?name=other.com')));
  329. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-other/dns_records')));
  330. });
  331. test('cloudflare one-click dns discovers the parent zone for subdomain sending domains', async () => {
  332. const calls = [];
  333. globalThis.fetch = async (url, options = {}) => {
  334. calls.push({ url: String(url), method: options.method || 'GET' });
  335. if (String(url).includes('/zones?name=sender.example.com')) {
  336. return json({ success: true, result: [] });
  337. }
  338. if (String(url).includes('/zones?name=example.com')) {
  339. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  340. }
  341. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  342. return json({ success: true, result: { id: 'ok' } });
  343. };
  344. const result = await applyDnsSetup(
  345. { ...domainFixture(), domain: 'sender.example.com', senderHost: 'smtp.example.com' },
  346. { ...cloudflareCredential(), zoneName: 'example.org' },
  347. {
  348. records: [
  349. {
  350. key: 'verification',
  351. host: '_mailhub.sender.example.com',
  352. type: 'TXT',
  353. value: 'mailhub-verification=token',
  354. status: 'missing'
  355. }
  356. ]
  357. }
  358. );
  359. assert.equal(result.ok, true);
  360. assert.equal(result.results[0].ok, true);
  361. assert.ok(calls.some((call) => call.url.includes('/zones?name=sender.example.com')));
  362. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  363. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  364. });
  365. test('cloudflare one-click dns falls back from configured child zone to parent zone', async () => {
  366. const calls = [];
  367. globalThis.fetch = async (url, options = {}) => {
  368. calls.push({ url: String(url), method: options.method || 'GET' });
  369. if (String(url).includes('/zones?name=notify.example.com')) {
  370. return json({ success: true, result: [] });
  371. }
  372. if (String(url).includes('/zones?name=example.com')) {
  373. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  374. }
  375. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  376. return json({ success: true, result: { id: 'ok' } });
  377. };
  378. const result = await applyDnsSetup(
  379. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  380. { ...cloudflareCredential(), zoneName: 'notify.example.com' },
  381. {
  382. records: [
  383. {
  384. key: 'verification',
  385. host: '_mailhub.notify.example.com',
  386. type: 'TXT',
  387. value: 'mailhub-verification=token',
  388. status: 'missing'
  389. }
  390. ]
  391. }
  392. );
  393. assert.equal(result.ok, true);
  394. assert.equal(result.results[0].ok, true);
  395. assert.ok(calls.some((call) => call.url.includes('/zones?name=notify.example.com')));
  396. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  397. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  398. });
  399. function cloudflareCredential() {
  400. return {
  401. provider: 'cloudflare',
  402. zoneName: 'example.com',
  403. defaultTtl: 600,
  404. credentials: { apiToken: 'token' }
  405. };
  406. }
  407. function aliyunCredential() {
  408. return {
  409. provider: 'aliyun',
  410. zoneName: 'example.com',
  411. defaultTtl: 600,
  412. credentials: { accessKeyId: 'id', accessKeySecret: 'secret' }
  413. };
  414. }
  415. function dnspodCredential() {
  416. return {
  417. provider: 'dnspod',
  418. zoneName: 'example.com',
  419. defaultTtl: 600,
  420. credentials: { secretId: 'id', secretKey: 'secret' }
  421. };
  422. }
  423. function domainFixture() {
  424. return {
  425. domain: 'example.com',
  426. senderHost: 'mail.example.com',
  427. sendingIp: '127.0.0.1'
  428. };
  429. }
  430. function json(payload) {
  431. return {
  432. ok: true,
  433. status: 200,
  434. async json() {
  435. return payload;
  436. }
  437. };
  438. }