dns-providers.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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('aliyun provider signs and sends create/update record actions', async () => {
  82. const actions = [];
  83. globalThis.fetch = async (url) => {
  84. const params = new URL(String(url)).searchParams;
  85. const action = params.get('Action');
  86. actions.push(action);
  87. if (action === 'DescribeDomainRecords') {
  88. return json({
  89. DomainRecords: {
  90. Record: [{ RecordId: '1', RR: '_dmarc', Type: 'TXT', Value: 'v=DMARC1; p=none' }]
  91. }
  92. });
  93. }
  94. return json({});
  95. };
  96. const credential = aliyunCredential();
  97. assert.equal((await testDnsCredential(credential)).ok, true);
  98. const result = await applyDnsSetup(domainFixture(), credential, {
  99. records: [{ key: 'dmarc', host: '_dmarc.example.com', type: 'TXT', value: 'v=DMARC1; p=reject' }]
  100. });
  101. assert.equal(result.ok, true);
  102. assert.ok(actions.includes('UpdateDomainRecord'));
  103. });
  104. test('aliyun one-click dns falls back to parent zone for subdomain sending domains', async () => {
  105. const calls = [];
  106. globalThis.fetch = async (url) => {
  107. const params = new URL(String(url)).searchParams;
  108. calls.push({
  109. action: params.get('Action'),
  110. domainName: params.get('DomainName'),
  111. rr: params.get('RR')
  112. });
  113. if (params.get('DomainName') === 'notify.example.com') {
  114. return json({
  115. Code: 'InvalidDomainName.NoExist',
  116. Message: 'domain not found'
  117. });
  118. }
  119. if (params.get('Action') === 'DescribeDomainRecords') {
  120. return json({ DomainRecords: { Record: [] } });
  121. }
  122. return json({});
  123. };
  124. const result = await applyDnsSetup(
  125. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  126. { ...aliyunCredential(), zoneName: 'notify.example.com' },
  127. {
  128. records: [
  129. {
  130. key: 'verification',
  131. host: '_mailhub.notify.example.com',
  132. type: 'TXT',
  133. value: 'mailhub-verification=token',
  134. status: 'missing'
  135. }
  136. ]
  137. }
  138. );
  139. assert.equal(result.ok, true);
  140. assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'notify.example.com'));
  141. assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'example.com'));
  142. assert.ok(calls.some((call) => (
  143. call.action === 'AddDomainRecord'
  144. && call.domainName === 'example.com'
  145. && call.rr === '_mailhub.notify'
  146. )));
  147. });
  148. test('dnspod provider signs and sends create record actions', async () => {
  149. const actions = [];
  150. globalThis.fetch = async (url, options = {}) => {
  151. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  152. actions.push(options.headers['X-TC-Action']);
  153. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  154. return json({ Response: { RecordList: [] } });
  155. }
  156. return json({ Response: { RecordId: 123 } });
  157. };
  158. const credential = dnspodCredential();
  159. assert.equal((await testDnsCredential(credential)).ok, true);
  160. const result = await applyDnsSetup(domainFixture(), credential, {
  161. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  162. });
  163. assert.equal(result.ok, true);
  164. assert.ok(actions.includes('CreateRecord'));
  165. });
  166. test('dnspod provider treats empty record list responses as no existing records', async () => {
  167. const actions = [];
  168. globalThis.fetch = async (url, options = {}) => {
  169. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  170. actions.push(options.headers['X-TC-Action']);
  171. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  172. return json({
  173. Response: {
  174. Error: {
  175. Code: 'FailedOperation.RecordListEmpty',
  176. Message: '记录列表为空。'
  177. }
  178. }
  179. });
  180. }
  181. return json({ Response: { RecordId: 123 } });
  182. };
  183. const result = await applyDnsSetup(domainFixture(), dnspodCredential(), {
  184. records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
  185. });
  186. assert.equal(result.ok, true);
  187. assert.equal(result.results[0].detail, 'created');
  188. assert.ok(actions.includes('CreateRecord'));
  189. });
  190. test('dnspod one-click dns falls back to parent zone for subdomain sending domains', async () => {
  191. const calls = [];
  192. globalThis.fetch = async (url, options = {}) => {
  193. assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
  194. const payload = JSON.parse(options.body);
  195. calls.push({ action: options.headers['X-TC-Action'], payload });
  196. if (payload.Domain === 'notify.example.com') {
  197. return json({
  198. Response: {
  199. Error: {
  200. Code: 'ResourceNotFound.NoDataOfRecord',
  201. Message: 'domain not found'
  202. }
  203. }
  204. });
  205. }
  206. if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
  207. return json({ Response: { RecordList: [] } });
  208. }
  209. return json({ Response: { RecordId: 123 } });
  210. };
  211. const result = await applyDnsSetup(
  212. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  213. { ...dnspodCredential(), zoneName: 'notify.example.com' },
  214. {
  215. records: [
  216. {
  217. key: 'verification',
  218. host: '_mailhub.notify.example.com',
  219. type: 'TXT',
  220. value: 'mailhub-verification=token',
  221. status: 'missing'
  222. }
  223. ]
  224. }
  225. );
  226. assert.equal(result.ok, true);
  227. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'notify.example.com'));
  228. assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'example.com'));
  229. assert.ok(calls.some((call) => (
  230. call.action === 'CreateRecord'
  231. && call.payload.Domain === 'example.com'
  232. && call.payload.SubDomain === '_mailhub.notify'
  233. )));
  234. });
  235. test('one-click dns setup only applies records under the user domain zone', async () => {
  236. const calls = [];
  237. globalThis.fetch = async (url, options = {}) => {
  238. calls.push({ url: String(url), method: options.method || 'GET' });
  239. if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
  240. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  241. return json({ success: true, result: { id: 'ok' } });
  242. };
  243. const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
  244. records: [
  245. {
  246. key: 'dkim',
  247. host: 'mh._domainkey.example.com',
  248. type: 'TXT',
  249. value: 'v=DKIM1; k=rsa; p=abc',
  250. status: 'missing'
  251. },
  252. {
  253. key: 'sender-a',
  254. host: 'smtp.example.com',
  255. type: 'A',
  256. value: '127.0.0.1',
  257. status: 'ok'
  258. }
  259. ]
  260. });
  261. assert.equal(result.ok, true);
  262. assert.equal(result.results.length, 1);
  263. assert.equal(result.results[0].key, 'dkim');
  264. assert.equal(calls.filter((call) => call.method === 'POST').length, 1);
  265. });
  266. test('cloudflare one-click dns can use the current domain zone with a multi-zone token', async () => {
  267. const calls = [];
  268. globalThis.fetch = async (url, options = {}) => {
  269. calls.push({ url: String(url), method: options.method || 'GET' });
  270. if (String(url).includes('/zones?name=other.com')) {
  271. return json({ success: true, result: [{ id: 'zone-other', name: 'other.com' }] });
  272. }
  273. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  274. return json({ success: true, result: { id: 'ok' } });
  275. };
  276. const result = await applyDnsSetup(
  277. { ...domainFixture(), domain: 'other.com', senderHost: 'mail.other.com' },
  278. cloudflareCredential(),
  279. {
  280. records: [
  281. {
  282. key: 'verification',
  283. host: '_mailhub.other.com',
  284. type: 'TXT',
  285. value: 'mailhub-verification=token',
  286. status: 'missing'
  287. }
  288. ]
  289. }
  290. );
  291. assert.equal(result.ok, true);
  292. assert.equal(result.results[0].ok, true);
  293. assert.ok(calls.some((call) => call.url.includes('/zones?name=other.com')));
  294. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-other/dns_records')));
  295. });
  296. test('cloudflare one-click dns discovers the parent zone for subdomain sending domains', async () => {
  297. const calls = [];
  298. globalThis.fetch = async (url, options = {}) => {
  299. calls.push({ url: String(url), method: options.method || 'GET' });
  300. if (String(url).includes('/zones?name=sender.example.com')) {
  301. return json({ success: true, result: [] });
  302. }
  303. if (String(url).includes('/zones?name=example.com')) {
  304. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  305. }
  306. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  307. return json({ success: true, result: { id: 'ok' } });
  308. };
  309. const result = await applyDnsSetup(
  310. { ...domainFixture(), domain: 'sender.example.com', senderHost: 'smtp.example.com' },
  311. { ...cloudflareCredential(), zoneName: 'example.org' },
  312. {
  313. records: [
  314. {
  315. key: 'verification',
  316. host: '_mailhub.sender.example.com',
  317. type: 'TXT',
  318. value: 'mailhub-verification=token',
  319. status: 'missing'
  320. }
  321. ]
  322. }
  323. );
  324. assert.equal(result.ok, true);
  325. assert.equal(result.results[0].ok, true);
  326. assert.ok(calls.some((call) => call.url.includes('/zones?name=sender.example.com')));
  327. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  328. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  329. });
  330. test('cloudflare one-click dns falls back from configured child zone to parent zone', async () => {
  331. const calls = [];
  332. globalThis.fetch = async (url, options = {}) => {
  333. calls.push({ url: String(url), method: options.method || 'GET' });
  334. if (String(url).includes('/zones?name=notify.example.com')) {
  335. return json({ success: true, result: [] });
  336. }
  337. if (String(url).includes('/zones?name=example.com')) {
  338. return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
  339. }
  340. if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
  341. return json({ success: true, result: { id: 'ok' } });
  342. };
  343. const result = await applyDnsSetup(
  344. { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
  345. { ...cloudflareCredential(), zoneName: 'notify.example.com' },
  346. {
  347. records: [
  348. {
  349. key: 'verification',
  350. host: '_mailhub.notify.example.com',
  351. type: 'TXT',
  352. value: 'mailhub-verification=token',
  353. status: 'missing'
  354. }
  355. ]
  356. }
  357. );
  358. assert.equal(result.ok, true);
  359. assert.equal(result.results[0].ok, true);
  360. assert.ok(calls.some((call) => call.url.includes('/zones?name=notify.example.com')));
  361. assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
  362. assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
  363. });
  364. function cloudflareCredential() {
  365. return {
  366. provider: 'cloudflare',
  367. zoneName: 'example.com',
  368. defaultTtl: 600,
  369. credentials: { apiToken: 'token' }
  370. };
  371. }
  372. function aliyunCredential() {
  373. return {
  374. provider: 'aliyun',
  375. zoneName: 'example.com',
  376. defaultTtl: 600,
  377. credentials: { accessKeyId: 'id', accessKeySecret: 'secret' }
  378. };
  379. }
  380. function dnspodCredential() {
  381. return {
  382. provider: 'dnspod',
  383. zoneName: 'example.com',
  384. defaultTtl: 600,
  385. credentials: { secretId: 'id', secretKey: 'secret' }
  386. };
  387. }
  388. function domainFixture() {
  389. return {
  390. domain: 'example.com',
  391. senderHost: 'mail.example.com',
  392. sendingIp: '127.0.0.1'
  393. };
  394. }
  395. function json(payload) {
  396. return {
  397. ok: true,
  398. status: 200,
  399. async json() {
  400. return payload;
  401. }
  402. };
  403. }