webhook-db.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { test } from 'node:test';
  6. import {
  7. claimWebhookDeliveries,
  8. completeWebhookDeliveryFailure,
  9. completeWebhookDeliverySuccess,
  10. createDomain,
  11. createUser,
  12. createWebhook,
  13. deleteWebhook,
  14. enqueueWebhookDeliveries,
  15. enqueueWebhookTestDelivery,
  16. getWebhook,
  17. initDatabase,
  18. listWebhookDeliveries,
  19. listWebhooks,
  20. logSendEvent,
  21. replayWebhookDelivery,
  22. rotateWebhookSecret,
  23. updateSendEventDelivery,
  24. updateWebhook
  25. } from '../src/db.js';
  26. import { MAX_WEBHOOK_ATTEMPTS } from '../src/webhook-model.js';
  27. test('isolates webhooks by user and supports domain scope filter', () => {
  28. initDatabase(tempDataDir(), 'test-secret');
  29. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  30. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  31. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  32. createWebhook(alice.id, {
  33. name: 'Alice account',
  34. url: 'https://hooks.alice.example/account',
  35. events: ['sent']
  36. });
  37. createWebhook(alice.id, {
  38. name: 'Alice domain',
  39. url: 'https://hooks.alice.example/domain',
  40. events: ['failed'],
  41. domainId: aliceDomain.id
  42. });
  43. createWebhook(bob.id, {
  44. name: 'Bob account',
  45. url: 'https://hooks.bob.example/account',
  46. events: ['sent', 'bounced']
  47. });
  48. assert.equal(listWebhooks(alice.id).length, 2);
  49. assert.equal(listWebhooks(bob.id).length, 1);
  50. assert.equal(listWebhooks(alice.id, { domainId: null }).length, 1);
  51. assert.equal(listWebhooks(alice.id, { domainId: aliceDomain.id }).length, 1);
  52. assert.equal(listWebhooks(alice.id, { domainId: aliceDomain.id })[0].name, 'Alice domain');
  53. assert.equal(listWebhooks(bob.id, { domainId: aliceDomain.id }).length, 0);
  54. });
  55. test('create returns secret once; list and get omit secret', () => {
  56. initDatabase(tempDataDir(), 'test-secret');
  57. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  58. const created = createWebhook(alice.id, {
  59. name: 'Primary',
  60. url: 'https://hooks.example.com/mail',
  61. events: ['sent', 'failed']
  62. });
  63. assert.ok(created.secret);
  64. assert.match(created.secret, /^whsec_/);
  65. assert.equal(created.secretPrefix, created.secret.slice(0, 8));
  66. assert.deepEqual(created.events, ['sent', 'failed']);
  67. assert.equal(created.enabled, true);
  68. assert.equal('secret' in listWebhooks(alice.id)[0], false);
  69. assert.equal('secret' in getWebhook(created.id, alice.id), false);
  70. assert.equal(listWebhooks(alice.id)[0].secretPrefix, created.secretPrefix);
  71. const rotated = rotateWebhookSecret(alice.id, created.id);
  72. assert.ok(rotated.secret);
  73. assert.notEqual(rotated.secret, created.secret);
  74. assert.equal(rotated.secretPrefix, rotated.secret.slice(0, 8));
  75. assert.equal('secret' in getWebhook(created.id, alice.id), false);
  76. const updated = updateWebhook(alice.id, created.id, {
  77. name: 'Renamed',
  78. enabled: false,
  79. events: ['bounced']
  80. });
  81. assert.equal(updated.name, 'Renamed');
  82. assert.equal(updated.enabled, false);
  83. assert.deepEqual(updated.events, ['bounced']);
  84. assert.equal(deleteWebhook(alice.id, created.id), true);
  85. assert.equal(getWebhook(created.id, alice.id), null);
  86. });
  87. test('enqueueWebhookDeliveries is idempotent per webhook+event+send_event', () => {
  88. initDatabase(tempDataDir(), 'test-secret');
  89. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  90. const domain = createDomain(alice.id, domainFixture('alice.example'));
  91. const webhook = createWebhook(alice.id, {
  92. name: 'Account',
  93. url: 'https://hooks.example.com/a',
  94. events: ['sent', 'failed']
  95. });
  96. const sendEvent = {
  97. id: 42,
  98. userId: alice.id,
  99. domainId: domain.id,
  100. status: 'sent',
  101. sender: 'noreply@alice.example',
  102. recipients: ['user@example.com'],
  103. subject: 'Hello',
  104. detail: 'ok',
  105. queueId: 'QUEUE42',
  106. deliveredAt: '2026-07-09T12:00:01.000Z'
  107. };
  108. const first = enqueueWebhookDeliveries(sendEvent);
  109. const second = enqueueWebhookDeliveries(sendEvent);
  110. assert.equal(first.length, 1);
  111. assert.equal(second.length, 0);
  112. const deliveries = listWebhookDeliveries(alice.id);
  113. assert.equal(deliveries.length, 1);
  114. assert.equal(deliveries[0].webhookId, webhook.id);
  115. assert.equal(deliveries[0].sendEventId, 42);
  116. assert.equal(deliveries[0].eventType, 'sent');
  117. assert.equal(deliveries[0].status, 'pending');
  118. assert.equal(deliveries[0].attemptCount, 0);
  119. const payload = JSON.parse(deliveries[0].payloadJson);
  120. assert.equal(payload.id, `whd_${deliveries[0].id}`);
  121. assert.equal(payload.type, 'email.sent');
  122. assert.equal(payload.data.message_id, 'mh-42');
  123. assert.equal(payload.data.domain, 'alice.example');
  124. assert.equal(payload.data.queue_id, 'QUEUE42');
  125. });
  126. test('domain override skips account webhooks for that event', () => {
  127. initDatabase(tempDataDir(), 'test-secret');
  128. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  129. const domain = createDomain(alice.id, domainFixture('override.example'));
  130. const account = createWebhook(alice.id, {
  131. name: 'Account',
  132. url: 'https://hooks.example.com/account',
  133. events: ['sent', 'failed']
  134. });
  135. const domainHook = createWebhook(alice.id, {
  136. name: 'Domain',
  137. url: 'https://hooks.example.com/domain',
  138. events: ['sent'],
  139. domainId: domain.id
  140. });
  141. enqueueWebhookDeliveries({
  142. id: 7,
  143. userId: alice.id,
  144. domainId: domain.id,
  145. domain: 'override.example',
  146. status: 'sent',
  147. sender: 'noreply@override.example',
  148. recipients: ['a@example.com'],
  149. subject: 'Override',
  150. detail: '',
  151. queueId: 'Q7'
  152. });
  153. const sent = listWebhookDeliveries(alice.id);
  154. assert.equal(sent.length, 1);
  155. assert.equal(sent[0].webhookId, domainHook.id);
  156. enqueueWebhookDeliveries({
  157. id: 8,
  158. userId: alice.id,
  159. domainId: domain.id,
  160. domain: 'override.example',
  161. status: 'failed',
  162. sender: 'noreply@override.example',
  163. recipients: ['a@example.com'],
  164. subject: 'Fallback',
  165. detail: 'error',
  166. queueId: 'Q8'
  167. });
  168. const failed = listWebhookDeliveries(alice.id, { eventType: 'failed' });
  169. assert.equal(failed.length, 1);
  170. assert.equal(failed[0].webhookId, account.id);
  171. });
  172. test('logSendEvent with failed status creates webhook delivery', () => {
  173. initDatabase(tempDataDir(), 'test-secret');
  174. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  175. const domain = createDomain(alice.id, domainFixture('fail.example'));
  176. createWebhook(alice.id, {
  177. name: 'Failures',
  178. url: 'https://hooks.example.com/failed',
  179. events: ['failed']
  180. });
  181. const eventId = logSendEvent({
  182. userId: alice.id,
  183. domainId: domain.id,
  184. sender: 'noreply@fail.example',
  185. recipients: ['user@example.com'],
  186. subject: 'Boom',
  187. status: 'failed',
  188. detail: 'SMTP rejected'
  189. });
  190. const deliveries = listWebhookDeliveries(alice.id);
  191. assert.equal(deliveries.length, 1);
  192. assert.equal(deliveries[0].sendEventId, eventId);
  193. assert.equal(deliveries[0].eventType, 'failed');
  194. const payload = JSON.parse(deliveries[0].payloadJson);
  195. assert.equal(payload.type, 'email.failed');
  196. assert.equal(payload.data.domain, 'fail.example');
  197. assert.equal(payload.data.detail, 'SMTP rejected');
  198. });
  199. test('updateSendEventDelivery terminal status change enqueues delivery', () => {
  200. initDatabase(tempDataDir(), 'test-secret');
  201. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  202. const domain = createDomain(alice.id, domainFixture('track.example'));
  203. createWebhook(alice.id, {
  204. name: 'Sent',
  205. url: 'https://hooks.example.com/sent',
  206. events: ['sent']
  207. });
  208. const eventId = logSendEvent({
  209. userId: alice.id,
  210. domainId: domain.id,
  211. sender: 'noreply@track.example',
  212. recipients: ['recipient@example.net'],
  213. subject: 'Tracked',
  214. status: 'queued',
  215. detail: '250 2.0.0 Ok: queued as 1DAEBC3EC8'
  216. });
  217. assert.equal(listWebhookDeliveries(alice.id).length, 0);
  218. assert.equal(
  219. updateSendEventDelivery('1DAEBC3EC8', {
  220. at: '2026-07-08T04:15:21.000Z',
  221. queueId: '1DAEBC3EC8',
  222. recipient: 'recipient@example.net',
  223. relay: 'mx.example.net[203.0.113.25]:25',
  224. dsn: '2.0.0',
  225. status: 'sent',
  226. response: '250 OK',
  227. raw: 'raw postfix line'
  228. }),
  229. true
  230. );
  231. const deliveries = listWebhookDeliveries(alice.id);
  232. assert.equal(deliveries.length, 1);
  233. assert.equal(deliveries[0].sendEventId, eventId);
  234. assert.equal(deliveries[0].eventType, 'sent');
  235. const payload = JSON.parse(deliveries[0].payloadJson);
  236. assert.equal(payload.type, 'email.sent');
  237. assert.equal(payload.data.domain, 'track.example');
  238. assert.equal(payload.data.queue_id, '1DAEBC3EC8');
  239. assert.equal(payload.data.delivered_at, '2026-07-08T04:15:21.000Z');
  240. // Same terminal status again (duplicate attempt ignored) must not create another delivery.
  241. updateSendEventDelivery('1DAEBC3EC8', {
  242. at: '2026-07-08T04:15:21.000Z',
  243. queueId: '1DAEBC3EC8',
  244. recipient: 'recipient@example.net',
  245. relay: 'mx.example.net[203.0.113.25]:25',
  246. dsn: '2.0.0',
  247. status: 'sent',
  248. response: '250 OK',
  249. raw: 'raw postfix line'
  250. });
  251. assert.equal(listWebhookDeliveries(alice.id).length, 1);
  252. });
  253. test('claim, complete success/failure, replay, test delivery, and dead path', () => {
  254. initDatabase(tempDataDir(), 'test-secret');
  255. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  256. createDomain(alice.id, domainFixture('worker.example'));
  257. const webhook = createWebhook(alice.id, {
  258. name: 'Worker',
  259. url: 'https://hooks.example.com/worker',
  260. events: ['sent', 'failed']
  261. });
  262. enqueueWebhookDeliveries({
  263. id: 99,
  264. userId: alice.id,
  265. domainId: null,
  266. status: 'sent',
  267. sender: 'noreply@worker.example',
  268. recipients: ['a@example.com'],
  269. subject: 'Work',
  270. detail: '',
  271. queueId: 'W99'
  272. });
  273. const claimed = claimWebhookDeliveries(5);
  274. assert.equal(claimed.length, 1);
  275. assert.equal(claimed[0].webhook.id, webhook.id);
  276. assert.equal(claimed[0].webhook.url, 'https://hooks.example.com/worker');
  277. assert.ok(claimed[0].webhook.secret);
  278. assert.equal(claimed[0].delivery.status, 'processing');
  279. assert.match(claimed[0].webhook.secret, /^whsec_/);
  280. const success = completeWebhookDeliverySuccess(claimed[0].delivery.id, {
  281. responseStatus: 200,
  282. bodyPreview: 'ok'
  283. });
  284. assert.equal(success.status, 'success');
  285. assert.equal(success.attemptCount, 1);
  286. assert.equal(success.responseStatus, 200);
  287. const testDelivery = enqueueWebhookTestDelivery(alice.id, webhook.id);
  288. assert.equal(testDelivery.sendEventId, 0);
  289. assert.equal(testDelivery.status, 'pending');
  290. const testPayload = JSON.parse(testDelivery.payloadJson);
  291. assert.equal(testPayload.data.test, true);
  292. assert.equal(testPayload.data.message_id, 'mh-test');
  293. assert.equal(testPayload.id, `whd_${testDelivery.id}`);
  294. const reused = enqueueWebhookTestDelivery(alice.id, webhook.id);
  295. assert.equal(reused.id, testDelivery.id);
  296. assert.equal(reused.status, 'pending');
  297. assert.equal(reused.attemptCount, 0);
  298. const claimedTest = claimWebhookDeliveries(5);
  299. assert.equal(claimedTest.length, 1);
  300. const failed = completeWebhookDeliveryFailure(claimedTest[0].delivery.id, {
  301. responseStatus: 500,
  302. bodyPreview: 'err',
  303. error: 'server error'
  304. });
  305. assert.equal(failed.status, 'pending');
  306. assert.equal(failed.attemptCount, 1);
  307. assert.ok(failed.nextAttemptAt > failed.lastAttemptAt);
  308. const replayed = replayWebhookDelivery(alice.id, failed.id);
  309. assert.equal(replayed.status, 'pending');
  310. assert.equal(replayed.attemptCount, 0);
  311. assert.equal(replayed.error, '');
  312. assert.equal(replayed.responseBodyPreview, '');
  313. const processing = claimWebhookDeliveries(1)[0];
  314. assert.throws(() => replayWebhookDelivery(alice.id, processing.delivery.id), /投递中|租约/);
  315. enqueueWebhookDeliveries({
  316. id: 100,
  317. userId: alice.id,
  318. status: 'failed',
  319. sender: 'noreply@worker.example',
  320. recipients: ['b@example.com'],
  321. subject: 'Dead path',
  322. detail: 'x'
  323. });
  324. let row = listWebhookDeliveries(alice.id, { eventType: 'failed' }).find((d) => d.sendEventId === 100);
  325. assert.ok(row);
  326. while (row.status !== 'dead') {
  327. row = completeWebhookDeliveryFailure(row.id, { responseStatus: 502, error: 'down' });
  328. }
  329. assert.equal(row.status, 'dead');
  330. assert.equal(row.attemptCount, MAX_WEBHOOK_ATTEMPTS);
  331. const reset = replayWebhookDelivery(alice.id, row.id);
  332. assert.equal(reset.status, 'pending');
  333. assert.equal(reset.attemptCount, 0);
  334. });
  335. function domainFixture(domain) {
  336. return {
  337. domain,
  338. selector: 'mh202607',
  339. verificationToken: 'token',
  340. dkimPublic: 'public',
  341. dkimPrivate: 'private',
  342. senderHost: `mail.${domain}`,
  343. sendingIp: '127.0.0.1',
  344. spfExtra: '',
  345. dmarcPolicy: 'none',
  346. dmarcRua: ''
  347. };
  348. }
  349. function tempDataDir() {
  350. return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-db-'));
  351. }