dns-providers.test.js 13 KB

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