server-webhooks-api.test.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import process from 'node:process';
  7. import { test } from 'node:test';
  8. import net from 'node:net';
  9. test('webhook API requires authentication', async () => {
  10. const { child, baseUrl } = await startTestServer();
  11. try {
  12. const list = await fetch(`${baseUrl}/api/webhooks`);
  13. assert.equal(list.status, 401);
  14. assert.equal((await list.json()).error, 'Authentication required.');
  15. const create = await fetch(`${baseUrl}/api/webhooks`, {
  16. method: 'POST',
  17. headers: { 'Content-Type': 'application/json' },
  18. body: JSON.stringify({
  19. name: 'No auth',
  20. url: 'http://127.0.0.1:9/hook',
  21. events: ['sent']
  22. })
  23. });
  24. assert.equal(create.status, 401);
  25. const deliveries = await fetch(`${baseUrl}/api/webhook-deliveries`);
  26. assert.equal(deliveries.status, 401);
  27. } finally {
  28. child.kill('SIGTERM');
  29. await waitForExit(child, 1000);
  30. }
  31. });
  32. test('webhook API isolates users and returns secret only on create/rotate', async () => {
  33. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  34. try {
  35. seedUsers(dataDir, sessionSecret, [
  36. { username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' },
  37. { username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' }
  38. ]);
  39. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  40. const bobCookie = await login(baseUrl, 'bob', 'password123');
  41. const aliceDomain = await createSendingDomain(baseUrl, aliceCookie, { domain: 'alice-hooks.example' });
  42. const bobDomain = await createSendingDomain(baseUrl, bobCookie, { domain: 'bob-hooks.example' });
  43. const create = await fetch(`${baseUrl}/api/webhooks`, {
  44. method: 'POST',
  45. headers: {
  46. 'Content-Type': 'application/json',
  47. Cookie: aliceCookie
  48. },
  49. body: JSON.stringify({
  50. name: 'Alice primary',
  51. url: 'http://127.0.0.1:9/alice',
  52. events: ['sent', 'failed'],
  53. enabled: true
  54. })
  55. });
  56. assert.equal(create.status, 201);
  57. const created = await create.json();
  58. assert.equal(created.webhook.name, 'Alice primary');
  59. assert.ok(created.webhook.secret);
  60. assert.match(created.webhook.secret, /^whsec_/);
  61. assert.equal(created.webhook.secretPrefix, created.webhook.secret.slice(0, 8));
  62. assert.deepEqual(created.webhook.events, ['sent', 'failed']);
  63. assert.equal(created.webhook.domainId, null);
  64. assert.equal(created.webhook.enabled, true);
  65. const aliceWebhookId = created.webhook.id;
  66. const firstSecret = created.webhook.secret;
  67. const bobCreate = await fetch(`${baseUrl}/api/webhooks`, {
  68. method: 'POST',
  69. headers: {
  70. 'Content-Type': 'application/json',
  71. Cookie: bobCookie
  72. },
  73. body: JSON.stringify({
  74. name: 'Bob primary',
  75. url: 'http://127.0.0.1:9/bob',
  76. events: ['bounced'],
  77. domainId: bobDomain.id
  78. })
  79. });
  80. assert.equal(bobCreate.status, 201);
  81. const bobWebhook = (await bobCreate.json()).webhook;
  82. assert.equal(bobWebhook.domainId, bobDomain.id);
  83. assert.ok(bobWebhook.secret);
  84. const aliceList = await fetch(`${baseUrl}/api/webhooks`, {
  85. headers: { Cookie: aliceCookie }
  86. });
  87. assert.equal(aliceList.status, 200);
  88. const aliceListBody = await aliceList.json();
  89. assert.equal(aliceListBody.webhooks.length, 1);
  90. assert.equal(aliceListBody.webhooks[0].id, aliceWebhookId);
  91. assert.equal('secret' in aliceListBody.webhooks[0], false);
  92. assert.equal(aliceListBody.webhooks[0].secretPrefix, firstSecret.slice(0, 8));
  93. const bobSeesAlice = await fetch(`${baseUrl}/api/webhooks`, {
  94. headers: { Cookie: bobCookie }
  95. });
  96. assert.equal(bobSeesAlice.status, 200);
  97. const bobList = await bobSeesAlice.json();
  98. assert.equal(bobList.webhooks.length, 1);
  99. assert.equal(bobList.webhooks[0].id, bobWebhook.id);
  100. assert.equal(bobList.webhooks[0].name, 'Bob primary');
  101. assert.equal('secret' in bobList.webhooks[0], false);
  102. const bobPatchAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  103. method: 'PATCH',
  104. headers: {
  105. 'Content-Type': 'application/json',
  106. Cookie: bobCookie
  107. },
  108. body: JSON.stringify({ name: 'Hijacked' })
  109. });
  110. assert.equal(bobPatchAlice.status, 404);
  111. const bobDeleteAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  112. method: 'DELETE',
  113. headers: { Cookie: bobCookie }
  114. });
  115. assert.equal(bobDeleteAlice.status, 404);
  116. const bobRotateAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
  117. method: 'POST',
  118. headers: { Cookie: bobCookie }
  119. });
  120. assert.equal(bobRotateAlice.status, 404);
  121. const bobTestAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/test`, {
  122. method: 'POST',
  123. headers: { Cookie: bobCookie }
  124. });
  125. assert.equal(bobTestAlice.status, 404);
  126. const stealDomain = await fetch(`${baseUrl}/api/webhooks`, {
  127. method: 'POST',
  128. headers: {
  129. 'Content-Type': 'application/json',
  130. Cookie: aliceCookie
  131. },
  132. body: JSON.stringify({
  133. name: 'Steal bob domain',
  134. url: 'http://127.0.0.1:9/steal',
  135. events: ['sent'],
  136. domainId: bobDomain.id
  137. })
  138. });
  139. assert.equal(stealDomain.status, 400);
  140. assert.match((await stealDomain.json()).error, /域名/);
  141. const domainScoped = await fetch(`${baseUrl}/api/webhooks`, {
  142. method: 'POST',
  143. headers: {
  144. 'Content-Type': 'application/json',
  145. Cookie: aliceCookie
  146. },
  147. body: JSON.stringify({
  148. name: 'Alice domain',
  149. url: 'http://127.0.0.1:9/alice-domain',
  150. events: ['failed'],
  151. domainId: aliceDomain.id
  152. })
  153. });
  154. assert.equal(domainScoped.status, 201);
  155. assert.equal((await domainScoped.json()).webhook.domainId, aliceDomain.id);
  156. const filtered = await fetch(`${baseUrl}/api/webhooks?domainId=${aliceDomain.id}`, {
  157. headers: { Cookie: aliceCookie }
  158. });
  159. assert.equal(filtered.status, 200);
  160. const filteredBody = await filtered.json();
  161. assert.equal(filteredBody.webhooks.length, 1);
  162. assert.equal(filteredBody.webhooks[0].name, 'Alice domain');
  163. const accountOnly = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
  164. headers: { Cookie: aliceCookie }
  165. });
  166. assert.equal(accountOnly.status, 200);
  167. const accountOnlyBody = await accountOnly.json();
  168. assert.equal(accountOnlyBody.webhooks.length, 1);
  169. assert.equal(accountOnlyBody.webhooks[0].name, 'Alice primary');
  170. const patch = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  171. method: 'PATCH',
  172. headers: {
  173. 'Content-Type': 'application/json',
  174. Cookie: aliceCookie
  175. },
  176. body: JSON.stringify({
  177. name: 'Alice renamed',
  178. events: ['sent'],
  179. enabled: false
  180. })
  181. });
  182. assert.equal(patch.status, 200);
  183. const patched = await patch.json();
  184. assert.equal(patched.webhook.name, 'Alice renamed');
  185. assert.deepEqual(patched.webhook.events, ['sent']);
  186. assert.equal(patched.webhook.enabled, false);
  187. assert.equal('secret' in patched.webhook, false);
  188. const rotate = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
  189. method: 'POST',
  190. headers: { Cookie: aliceCookie }
  191. });
  192. assert.equal(rotate.status, 200);
  193. const rotated = await rotate.json();
  194. assert.ok(rotated.webhook.secret);
  195. assert.notEqual(rotated.webhook.secret, firstSecret);
  196. assert.equal(rotated.webhook.secretPrefix, rotated.webhook.secret.slice(0, 8));
  197. const afterRotateList = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
  198. headers: { Cookie: aliceCookie }
  199. });
  200. assert.equal('secret' in (await afterRotateList.json()).webhooks[0], false);
  201. const invalidEvents = await fetch(`${baseUrl}/api/webhooks`, {
  202. method: 'POST',
  203. headers: {
  204. 'Content-Type': 'application/json',
  205. Cookie: aliceCookie
  206. },
  207. body: JSON.stringify({
  208. name: 'Bad events',
  209. url: 'http://127.0.0.1:9/bad',
  210. events: ['queued']
  211. })
  212. });
  213. assert.equal(invalidEvents.status, 400);
  214. const insecureUrl = await fetch(`${baseUrl}/api/webhooks`, {
  215. method: 'POST',
  216. headers: {
  217. 'Content-Type': 'application/json',
  218. Cookie: aliceCookie
  219. },
  220. body: JSON.stringify({
  221. name: 'Bad url',
  222. url: 'http://example.com/hook',
  223. events: ['sent']
  224. })
  225. });
  226. assert.equal(insecureUrl.status, 400);
  227. } finally {
  228. child.kill('SIGTERM');
  229. await waitForExit(child, 1000);
  230. }
  231. });
  232. test('webhook test and replay endpoints work', async () => {
  233. const { child, baseUrl } = await startTestServer();
  234. try {
  235. const cookie = await login(baseUrl, 'admin', 'password123');
  236. await createSendingDomain(baseUrl, cookie, { domain: 'webhook-test.example' });
  237. const create = await fetch(`${baseUrl}/api/webhooks`, {
  238. method: 'POST',
  239. headers: {
  240. 'Content-Type': 'application/json',
  241. Cookie: cookie
  242. },
  243. body: JSON.stringify({
  244. name: 'Test endpoint',
  245. url: 'http://127.0.0.1:9/test',
  246. events: ['sent', 'bounced']
  247. })
  248. });
  249. assert.equal(create.status, 201);
  250. const webhook = (await create.json()).webhook;
  251. const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  252. method: 'POST',
  253. headers: { Cookie: cookie }
  254. });
  255. assert.equal(testDelivery.status, 202);
  256. const testBody = await testDelivery.json();
  257. assert.ok(testBody.delivery);
  258. assert.equal(testBody.delivery.webhookId, webhook.id);
  259. assert.equal(testBody.delivery.sendEventId, 0);
  260. assert.equal(testBody.delivery.eventType, 'sent');
  261. assert.equal(testBody.delivery.status, 'pending');
  262. assert.equal(testBody.delivery.attemptCount, 0);
  263. const payload = JSON.parse(testBody.delivery.payloadJson);
  264. assert.equal(payload.data.test, true);
  265. assert.equal(payload.data.message_id, 'mh-test');
  266. assert.equal(payload.type, 'email.sent');
  267. const deliveryId = testBody.delivery.id;
  268. const listDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries?webhookId=${webhook.id}`, {
  269. headers: { Cookie: cookie }
  270. });
  271. assert.equal(listDeliveries.status, 200);
  272. const listed = await listDeliveries.json();
  273. assert.equal(listed.deliveries.length, 1);
  274. assert.equal(listed.deliveries[0].id, deliveryId);
  275. const replay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
  276. method: 'POST',
  277. headers: { Cookie: cookie }
  278. });
  279. assert.equal(replay.status, 200);
  280. const replayed = await replay.json();
  281. assert.equal(replayed.delivery.id, deliveryId);
  282. assert.equal(replayed.delivery.status, 'pending');
  283. assert.equal(replayed.delivery.attemptCount, 0);
  284. assert.equal(JSON.parse(replayed.delivery.payloadJson).id, `whd_${deliveryId}`);
  285. const retest = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  286. method: 'POST',
  287. headers: { Cookie: cookie }
  288. });
  289. assert.equal(retest.status, 202);
  290. const retested = await retest.json();
  291. assert.equal(retested.delivery.id, deliveryId);
  292. assert.equal(retested.delivery.status, 'pending');
  293. const allDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries`, {
  294. headers: { Cookie: cookie }
  295. });
  296. assert.equal(allDeliveries.status, 200);
  297. assert.ok((await allDeliveries.json()).deliveries.length >= 1);
  298. } finally {
  299. child.kill('SIGTERM');
  300. await waitForExit(child, 1000);
  301. }
  302. });
  303. test('webhook delivery replay is isolated by user', async () => {
  304. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  305. try {
  306. seedUsers(dataDir, sessionSecret, [
  307. { username: 'carol', email: 'carol@example.com', password: 'password123', status: 'active' },
  308. { username: 'dave', email: 'dave@example.com', password: 'password123', status: 'active' }
  309. ]);
  310. const carolCookie = await login(baseUrl, 'carol', 'password123');
  311. const daveCookie = await login(baseUrl, 'dave', 'password123');
  312. const create = await fetch(`${baseUrl}/api/webhooks`, {
  313. method: 'POST',
  314. headers: {
  315. 'Content-Type': 'application/json',
  316. Cookie: carolCookie
  317. },
  318. body: JSON.stringify({
  319. name: 'Carol hook',
  320. url: 'http://127.0.0.1:9/carol',
  321. events: ['failed']
  322. })
  323. });
  324. assert.equal(create.status, 201);
  325. const webhook = (await create.json()).webhook;
  326. const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  327. method: 'POST',
  328. headers: { Cookie: carolCookie }
  329. });
  330. assert.equal(testDelivery.status, 202);
  331. const deliveryId = (await testDelivery.json()).delivery.id;
  332. const daveList = await fetch(`${baseUrl}/api/webhook-deliveries`, {
  333. headers: { Cookie: daveCookie }
  334. });
  335. assert.equal(daveList.status, 200);
  336. assert.equal((await daveList.json()).deliveries.length, 0);
  337. const daveReplay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
  338. method: 'POST',
  339. headers: { Cookie: daveCookie }
  340. });
  341. assert.equal(daveReplay.status, 404);
  342. const deleted = await fetch(`${baseUrl}/api/webhooks/${webhook.id}`, {
  343. method: 'DELETE',
  344. headers: { Cookie: carolCookie }
  345. });
  346. assert.equal(deleted.status, 200);
  347. assert.equal((await deleted.json()).deleted, true);
  348. const missing = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  349. method: 'POST',
  350. headers: { Cookie: carolCookie }
  351. });
  352. assert.equal(missing.status, 404);
  353. } finally {
  354. child.kill('SIGTERM');
  355. await waitForExit(child, 1000);
  356. }
  357. });
  358. async function startTestServer() {
  359. const port = await freePort();
  360. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webhooks-api-'));
  361. const sessionSecret = 'test-session-secret-webhooks';
  362. const child = spawn(process.execPath, ['src/server.js'], {
  363. cwd: process.cwd(),
  364. env: {
  365. ...process.env,
  366. PORT: String(port),
  367. DATA_DIR: dataDir,
  368. ADMIN_PASSWORD: 'password123',
  369. SESSION_SECRET: sessionSecret,
  370. DNS_AUTO_CHECK_ENABLED: 'false',
  371. SUBMISSION_ENABLED: 'false',
  372. IMAP_ENABLED: 'false',
  373. POP3_ENABLED: 'false',
  374. WEBHOOK_WORKER_ENABLED: '0',
  375. WEBHOOK_ALLOW_HTTP_LOCAL: '1',
  376. DELIVERY_TRACKING_ENABLED: 'false'
  377. },
  378. stdio: ['ignore', 'pipe', 'pipe']
  379. });
  380. await waitForOutput(child, 'MailHub listening');
  381. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  382. }
  383. async function login(baseUrl, username, password) {
  384. const response = await fetch(`${baseUrl}/api/login`, {
  385. method: 'POST',
  386. headers: { 'Content-Type': 'application/json' },
  387. body: JSON.stringify({ username, password })
  388. });
  389. assert.equal(response.status, 200);
  390. const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
  391. assert.ok(cookie);
  392. return cookie;
  393. }
  394. function seedUsers(dataDir, sessionSecret, users) {
  395. const script = `
  396. import { initDatabase, createUser } from './src/db.js';
  397. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  398. for (const user of JSON.parse(process.env.SEED_USERS)) {
  399. createUser(user);
  400. }
  401. `;
  402. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  403. cwd: process.cwd(),
  404. env: {
  405. ...process.env,
  406. DATA_DIR: dataDir,
  407. SESSION_SECRET: sessionSecret,
  408. SEED_USERS: JSON.stringify(users)
  409. },
  410. encoding: 'utf8'
  411. });
  412. assert.equal(result.status, 0, result.stderr || result.stdout);
  413. }
  414. async function createSendingDomain(baseUrl, cookie, data = {}) {
  415. const domain = data.domain || 'send.example';
  416. const response = await fetch(`${baseUrl}/api/domains`, {
  417. method: 'POST',
  418. headers: {
  419. 'Content-Type': 'application/json',
  420. Cookie: cookie
  421. },
  422. body: JSON.stringify({
  423. domain,
  424. selector: data.selector || 'mh',
  425. senderHost: data.senderHost || `mail.${domain}`,
  426. sendingIp: data.sendingIp || '127.0.0.1'
  427. })
  428. });
  429. assert.equal(response.status, 201);
  430. return (await response.json()).domain;
  431. }
  432. function freePort() {
  433. return new Promise((resolve, reject) => {
  434. const server = net.createServer();
  435. server.listen(0, '127.0.0.1', () => {
  436. const address = server.address();
  437. server.close(() => {
  438. if (address && typeof address === 'object') resolve(address.port);
  439. else reject(new Error('Unable to allocate a test port.'));
  440. });
  441. });
  442. });
  443. }
  444. function waitForOutput(child, text) {
  445. return new Promise((resolve, reject) => {
  446. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  447. const chunks = [];
  448. const onData = (chunk) => {
  449. chunks.push(String(chunk));
  450. if (chunks.join('').includes(text)) {
  451. clearTimeout(timeout);
  452. child.stdout.off('data', onData);
  453. child.stderr.off('data', onData);
  454. resolve();
  455. }
  456. };
  457. child.stdout.on('data', onData);
  458. child.stderr.on('data', onData);
  459. child.once('exit', (code) => {
  460. clearTimeout(timeout);
  461. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  462. });
  463. });
  464. }
  465. function waitForExit(child, timeoutMs) {
  466. if (child.exitCode !== null) return Promise.resolve(true);
  467. return new Promise((resolve) => {
  468. const timeout = setTimeout(() => {
  469. child.off('exit', onExit);
  470. resolve(false);
  471. }, timeoutMs);
  472. const onExit = () => {
  473. clearTimeout(timeout);
  474. resolve(true);
  475. };
  476. child.once('exit', onExit);
  477. });
  478. }