db.test.js 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680
  1. import assert from 'node:assert/strict';
  2. import { createHash } from 'node:crypto';
  3. import { mkdtempSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import { DatabaseSync } from 'node:sqlite';
  8. import {
  9. authenticateUser,
  10. claimLegacyData,
  11. consumeAccountToken,
  12. createApiToken,
  13. createAccountToken,
  14. createSendEvent,
  15. createTrackingLink,
  16. createDomain,
  17. createUser,
  18. createUserWithAccountToken,
  19. createWebhook,
  20. deleteSmtpCredential,
  21. deleteSmtpRelay,
  22. getDnsCredential,
  23. getDomain,
  24. getSendEvent,
  25. getSendAnalytics,
  26. getSmtpRelay,
  27. getSmtpCredential,
  28. getAdminResourceInventory,
  29. getSystemEmailSettings,
  30. getUser,
  31. initDatabase,
  32. listAuditLogs,
  33. listDomains,
  34. listSendEvents,
  35. listSmtpCredentials,
  36. listSmtpRelays,
  37. listUsersWithResourceCounts,
  38. invalidateAccountTokens,
  39. logAudit,
  40. logSendEvent,
  41. approveUser,
  42. markUserEmailVerified,
  43. previewUserMerge,
  44. pruneTrackingEvents,
  45. recordTrackingEvent,
  46. saveDnsCredential,
  47. saveSmtpRelay,
  48. saveSmtpCredential,
  49. saveSystemEmailSettings,
  50. seedAdminUser,
  51. transferApiTokens,
  52. transferDnsCredential,
  53. transferDomain,
  54. updateUser,
  55. updateUserStatus,
  56. executeUserMerge,
  57. finalizeSendEvent,
  58. findSendEventByTrackingToken,
  59. findTrackingLinkByToken,
  60. verifyApiToken,
  61. verifySmtpCredential
  62. } from '../src/db.js';
  63. import {
  64. createTrackingToken,
  65. encryptTrackingTarget,
  66. hashTrackingToken,
  67. trackingTargetFingerprint
  68. } from '../src/tracking.js';
  69. test('migrates legacy data to the seeded admin user', () => {
  70. const dataDir = tempDataDir();
  71. const dbPath = path.join(dataDir, 'mailhub.sqlite');
  72. const legacy = new DatabaseSync(dbPath);
  73. legacy.exec(`
  74. CREATE TABLE domains (
  75. id INTEGER PRIMARY KEY AUTOINCREMENT,
  76. domain TEXT NOT NULL UNIQUE,
  77. selector TEXT NOT NULL,
  78. verification_token TEXT NOT NULL,
  79. dkim_public TEXT NOT NULL,
  80. dkim_private TEXT NOT NULL,
  81. sender_host TEXT NOT NULL,
  82. sending_ip TEXT NOT NULL,
  83. spf_extra TEXT NOT NULL DEFAULT '',
  84. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  85. dmarc_rua TEXT NOT NULL DEFAULT '',
  86. status_json TEXT NOT NULL DEFAULT '{}',
  87. created_at TEXT NOT NULL,
  88. updated_at TEXT NOT NULL
  89. );
  90. CREATE TABLE send_events (
  91. id INTEGER PRIMARY KEY AUTOINCREMENT,
  92. domain_id INTEGER,
  93. sender TEXT NOT NULL,
  94. recipients TEXT NOT NULL,
  95. subject TEXT NOT NULL,
  96. status TEXT NOT NULL,
  97. detail TEXT NOT NULL DEFAULT '',
  98. created_at TEXT NOT NULL
  99. );
  100. CREATE TABLE smtp_credentials (
  101. id INTEGER PRIMARY KEY CHECK (id = 1),
  102. username TEXT NOT NULL,
  103. password_hash TEXT NOT NULL,
  104. password_secret TEXT NOT NULL DEFAULT '',
  105. created_at TEXT NOT NULL,
  106. updated_at TEXT NOT NULL
  107. );
  108. `);
  109. legacy
  110. .prepare(`
  111. INSERT INTO domains (
  112. domain, selector, verification_token, dkim_public, dkim_private,
  113. sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua, created_at, updated_at
  114. ) VALUES ('legacy.example', 'mh', 'tok', 'pub', 'priv', 'mail.legacy.example', '127.0.0.1', '', 'none', '', 'now', 'now')
  115. `)
  116. .run();
  117. legacy
  118. .prepare(`
  119. INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
  120. VALUES (1, 'noreply@legacy.example', '["user@example.com"]', 'hi', 'queued', '', 'now')
  121. `)
  122. .run();
  123. legacy
  124. .prepare(`
  125. INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
  126. VALUES (1, 'legacy-smtp', 'scrypt$salt$hash', '', 'now', 'now')
  127. `)
  128. .run();
  129. legacy.close();
  130. initDatabase(dataDir, 'test-secret');
  131. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  132. claimLegacyData(admin.id);
  133. assert.equal(listDomains(admin.id).length, 1);
  134. assert.equal(listSendEvents(admin.id).length, 1);
  135. assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
  136. });
  137. test('migrates legacy send events with engagement tracking disabled', () => {
  138. const dataDir = tempDataDir();
  139. const database = initDatabase(dataDir, 'test-secret');
  140. const columns = database.prepare('PRAGMA table_info(send_events)').all().map((column) => column.name);
  141. assert.equal(columns.includes('tracking_token_hash'), true);
  142. assert.equal(columns.includes('tracking_opens'), true);
  143. assert.equal(columns.includes('tracking_clicks'), true);
  144. const tables = database
  145. .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('tracking_links', 'tracking_events') ORDER BY name")
  146. .all()
  147. .map((row) => row.name);
  148. assert.deepEqual(tables, ['tracking_events', 'tracking_links']);
  149. });
  150. test('creates and finalizes one tracked send event with a unique open token', () => {
  151. initDatabase(tempDataDir(), 'test-secret');
  152. const user = createUser({ username: 'tracked', email: 'tracked@example.com', password: 'password123' });
  153. const domain = createDomain(user.id, domainFixture('tracked.example'));
  154. const token = createTrackingToken();
  155. const id = createSendEvent({
  156. userId: user.id,
  157. domainId: domain.id,
  158. sender: 'sender@tracked.example',
  159. recipients: ['reader@example.net'],
  160. subject: 'Tracked',
  161. trackingToken: token,
  162. trackingOpens: true,
  163. trackingClicks: true
  164. });
  165. const submitting = getSendEvent(user.id, id);
  166. assert.equal(submitting.status, 'submitting');
  167. assert.equal(submitting.tracking.enabled, true);
  168. assert.equal(submitting.tracking.opens, true);
  169. assert.equal(submitting.tracking.clicks, true);
  170. assert.equal(findSendEventByTrackingToken(token).id, id);
  171. const finalized = finalizeSendEvent(id, user.id, {
  172. status: 'queued',
  173. detail: 'queued as TRACK123',
  174. queueId: 'TRACK123',
  175. deliveryLog: [{ phase: 'queue', ok: true }]
  176. });
  177. assert.equal(finalized.status, 'queued');
  178. assert.equal(finalized.queueId, 'TRACK123');
  179. assert.throws(() => createSendEvent({
  180. userId: user.id,
  181. domainId: domain.id,
  182. sender: 'sender@tracked.example',
  183. recipients: ['second@example.net'],
  184. subject: 'Duplicate token',
  185. trackingToken: token,
  186. trackingOpens: true
  187. }), /unique/i);
  188. });
  189. test('stores encrypted opaque tracking links and isolates creation by owner', () => {
  190. initDatabase(tempDataDir(), 'test-secret');
  191. const alice = createUser({ username: 'track-alice', email: 'track-alice@example.com', password: 'password123' });
  192. const bob = createUser({ username: 'track-bob', email: 'track-bob@example.com', password: 'password123' });
  193. const domain = createDomain(alice.id, domainFixture('links.example'));
  194. const eventId = createSendEvent({
  195. userId: alice.id,
  196. domainId: domain.id,
  197. sender: 'sender@links.example',
  198. recipients: ['reader@example.net'],
  199. subject: 'Links',
  200. trackingClicks: true
  201. });
  202. const token = createTrackingToken();
  203. const target = 'https://example.net/reset?token=private';
  204. const linkId = createTrackingLink(alice.id, eventId, {
  205. token,
  206. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  207. targetFingerprint: hashTrackingToken(`destination:${target}`),
  208. targetOrigin: 'https://example.net'
  209. });
  210. const link = findTrackingLinkByToken(token);
  211. assert.equal(link.id, linkId);
  212. assert.equal(link.sendEventId, eventId);
  213. assert.equal(link.userId, alice.id);
  214. assert.equal(link.targetCiphertext.includes('private'), false);
  215. assert.equal(link.targetOrigin, 'https://example.net');
  216. const detail = getSendEvent(alice.id, eventId, { trackingSecret: 'tracking-secret' });
  217. assert.equal(detail.tracking.links.length, 1);
  218. assert.equal(detail.tracking.links[0].target, target);
  219. assert.throws(() => createTrackingLink(bob.id, eventId, {
  220. token: createTrackingToken(),
  221. targetCiphertext: encryptTrackingTarget('https://example.net/other', 'tracking-secret'),
  222. targetFingerprint: 'other',
  223. targetOrigin: 'https://example.net'
  224. }), /not found/i);
  225. });
  226. test('records deduplicated engagement events and summarizes message detail', () => {
  227. initDatabase(tempDataDir(), 'test-secret');
  228. const user = createUser({ username: 'events', email: 'events@example.com', password: 'password123' });
  229. const domain = createDomain(user.id, domainFixture('events.example'));
  230. const eventId = createSendEvent({
  231. userId: user.id,
  232. domainId: domain.id,
  233. sender: 'sender@events.example',
  234. recipients: ['reader@example.net'],
  235. subject: 'Engagement',
  236. trackingOpens: true,
  237. trackingClicks: true
  238. });
  239. const trackingLinkId = createTrackingLink(user.id, eventId, {
  240. token: createTrackingToken(),
  241. targetCiphertext: encryptTrackingTarget('https://example.net/click', 'tracking-secret'),
  242. targetFingerprint: trackingTargetFingerprint('https://example.net/click', 'tracking-secret'),
  243. targetOrigin: 'https://example.net'
  244. });
  245. const scanner = recordTrackingEvent({
  246. sendEventId: eventId,
  247. eventType: 'open',
  248. source: 'scanner',
  249. occurredAt: '2026-07-09T12:00:00.000Z',
  250. userAgent: 'Scanner',
  251. ipHash: 'scanner-ip',
  252. replayKey: 'scanner-open'
  253. });
  254. assert.equal(scanner.recorded, true);
  255. assert.equal(scanner.firstQualifying, false);
  256. const direct = recordTrackingEvent({
  257. sendEventId: eventId,
  258. eventType: 'open',
  259. source: 'direct',
  260. occurredAt: '2026-07-09T12:01:00.000Z',
  261. userAgent: 'Mozilla/5.0',
  262. ipHash: 'direct-ip',
  263. replayKey: 'direct-open'
  264. });
  265. assert.equal(direct.recorded, true);
  266. assert.equal(direct.firstQualifying, true);
  267. recordTrackingEvent({
  268. sendEventId: eventId,
  269. trackingLinkId,
  270. eventType: 'click',
  271. source: 'direct',
  272. occurredAt: '2026-07-09T11:59:00.000Z',
  273. userAgent: 'Mozilla/5.0',
  274. ipHash: 'click-ip',
  275. replayKey: 'direct-click'
  276. });
  277. const duplicate = recordTrackingEvent({
  278. sendEventId: eventId,
  279. eventType: 'open',
  280. source: 'direct',
  281. occurredAt: '2026-07-09T12:01:30.000Z',
  282. userAgent: 'Mozilla/5.0',
  283. ipHash: 'direct-ip',
  284. replayKey: 'direct-open'
  285. });
  286. assert.equal(duplicate.recorded, false);
  287. assert.equal(duplicate.duplicate, true);
  288. const detail = getSendEvent(user.id, eventId);
  289. assert.equal(detail.tracking.summary.totalOpens, 1);
  290. assert.equal(detail.tracking.summary.totalClicks, 1);
  291. assert.equal(detail.tracking.summary.uniqueOpen, true);
  292. assert.equal(detail.tracking.summary.scannerEvents, 1);
  293. assert.equal(detail.tracking.summary.firstOpenedAt, '2026-07-09T11:59:00.000Z');
  294. assert.equal(detail.tracking.summary.lastOpenedAt, '2026-07-09T12:01:00.000Z');
  295. assert.equal(detail.tracking.events.length, 3);
  296. assert.equal(detail.tracking.eventCount, 3);
  297. assert.equal(detail.tracking.eventsTruncated, false);
  298. assert.equal(detail.tracking.events.some((event) => Object.hasOwn(event, 'ipHash')), false);
  299. const listed = listSendEvents(user.id)[0];
  300. assert.equal(listed.tracking.summary.totalOpens, 1);
  301. assert.equal(listed.tracking.summary.uniqueOpen, true);
  302. });
  303. test('bounds tracking detail events while preserving the complete SQL summary', () => {
  304. const database = initDatabase(tempDataDir(), 'test-secret');
  305. const user = createUser({ username: 'bounded-events', email: 'bounded-events@example.com', password: 'password123' });
  306. const domain = createDomain(user.id, domainFixture('bounded-events.example'));
  307. const eventId = createSendEvent({
  308. userId: user.id,
  309. domainId: domain.id,
  310. sender: 'sender@bounded-events.example',
  311. recipients: ['reader@example.net'],
  312. subject: 'Bounded events',
  313. trackingOpens: true
  314. });
  315. const start = Date.parse('2026-07-09T00:00:00.000Z');
  316. for (let index = 0; index < 505; index += 1) {
  317. recordTrackingEvent({
  318. sendEventId: eventId,
  319. eventType: 'open',
  320. source: 'direct',
  321. occurredAt: new Date(start + index * 1000).toISOString(),
  322. userAgent: 'Mozilla/5.0',
  323. ipHash: `bounded-${index}`,
  324. replayKey: `bounded-${index}`
  325. });
  326. }
  327. for (let index = 0; index < 205; index += 1) {
  328. const target = `https://example.net/link/${index}`;
  329. createTrackingLink(user.id, eventId, {
  330. token: createTrackingToken(),
  331. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  332. targetFingerprint: trackingTargetFingerprint(target, 'tracking-secret'),
  333. targetOrigin: 'https://example.net'
  334. });
  335. }
  336. const detail = getSendEvent(user.id, eventId, { trackingSecret: 'tracking-secret' });
  337. assert.equal(detail.tracking.summary.totalOpens, 505);
  338. assert.equal(detail.tracking.eventCount, 505);
  339. assert.equal(detail.tracking.events.length, 500);
  340. assert.equal(detail.tracking.eventsTruncated, true);
  341. assert.equal(detail.tracking.linkCount, 205);
  342. assert.equal(detail.tracking.links.length, 200);
  343. assert.equal(detail.tracking.linksTruncated, true);
  344. assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:05.000Z');
  345. assert.equal(detail.tracking.events.at(-1).occurredAt, '2026-07-09T00:08:24.000Z');
  346. const listed = listSendEvents(user.id)[0];
  347. assert.equal(listed.tracking.summary.totalOpens, 505);
  348. assert.equal(Object.hasOwn(listed.tracking, 'events'), false);
  349. const eventIndexes = database.prepare("PRAGMA index_list('tracking_events')").all().map((row) => row.name);
  350. const sendEventIndexes = database.prepare("PRAGMA index_list('send_events')").all().map((row) => row.name);
  351. assert.equal(eventIndexes.includes('idx_tracking_events_link_time'), true);
  352. assert.equal(sendEventIndexes.includes('idx_events_user_created'), true);
  353. });
  354. test('enqueues first qualifying engagement webhook and ignores scanner-first events', () => {
  355. initDatabase(tempDataDir(), 'test-secret');
  356. const user = createUser({ username: 'engagement-hook', email: 'engagement-hook@example.com', password: 'password123' });
  357. const domain = createDomain(user.id, domainFixture('engagement-hook.example'));
  358. createWebhook(user.id, {
  359. name: 'Engagement events',
  360. url: 'https://hooks.example.com/engagement',
  361. events: ['opened', 'clicked']
  362. });
  363. const eventId = createSendEvent({
  364. userId: user.id,
  365. domainId: domain.id,
  366. sender: 'sender@engagement-hook.example',
  367. recipients: ['reader@example.net'],
  368. subject: 'Webhook engagement',
  369. status: 'sent',
  370. trackingOpens: true,
  371. trackingClicks: true
  372. });
  373. recordTrackingEvent({
  374. sendEventId: eventId,
  375. eventType: 'open',
  376. source: 'scanner',
  377. occurredAt: '2026-07-09T12:00:00.000Z',
  378. userAgent: 'Scanner',
  379. ipHash: 'scanner-ip',
  380. replayKey: 'hook-scanner'
  381. });
  382. assert.equal(getSendEvent(user.id, eventId).webhookDeliveries.length, 0);
  383. recordTrackingEvent({
  384. sendEventId: eventId,
  385. eventType: 'open',
  386. source: 'direct',
  387. occurredAt: '2026-07-09T12:01:00.000Z',
  388. userAgent: 'Mozilla/5.0',
  389. ipHash: 'direct-ip',
  390. replayKey: 'hook-direct-1'
  391. });
  392. recordTrackingEvent({
  393. sendEventId: eventId,
  394. eventType: 'open',
  395. source: 'direct',
  396. occurredAt: '2026-07-09T12:02:00.000Z',
  397. userAgent: 'Mozilla/5.0',
  398. ipHash: 'direct-ip-2',
  399. replayKey: 'hook-direct-2'
  400. });
  401. const deliveries = getSendEvent(user.id, eventId).webhookDeliveries;
  402. assert.equal(deliveries.length, 1);
  403. assert.equal(deliveries[0].eventType, 'opened');
  404. });
  405. test('caps tracking writes per message and UTC day', () => {
  406. initDatabase(tempDataDir(), 'test-secret');
  407. const user = createUser({ username: 'cap-user', email: 'cap-user@example.com', password: 'password123' });
  408. const domain = createDomain(user.id, domainFixture('cap.example'));
  409. const eventId = createSendEvent({
  410. userId: user.id,
  411. domainId: domain.id,
  412. sender: 'sender@cap.example',
  413. recipients: ['reader@example.net'],
  414. subject: 'Cap',
  415. trackingOpens: true
  416. });
  417. for (let index = 0; index < 2; index += 1) {
  418. const result = recordTrackingEvent({
  419. sendEventId: eventId,
  420. eventType: 'open',
  421. source: 'direct',
  422. occurredAt: `2026-07-09T12:0${index}:00.000Z`,
  423. userAgent: 'Mozilla/5.0',
  424. ipHash: `ip-${index}`,
  425. replayKey: `cap-${index}`,
  426. maxPerDay: 2
  427. });
  428. assert.equal(result.recorded, true);
  429. }
  430. const capped = recordTrackingEvent({
  431. sendEventId: eventId,
  432. eventType: 'open',
  433. source: 'direct',
  434. occurredAt: '2026-07-09T12:03:00.000Z',
  435. userAgent: 'Mozilla/5.0',
  436. ipHash: 'ip-3',
  437. replayKey: 'cap-3',
  438. maxPerDay: 2
  439. });
  440. assert.equal(capped.recorded, false);
  441. assert.equal(capped.capped, true);
  442. const nextDay = recordTrackingEvent({
  443. sendEventId: eventId,
  444. eventType: 'open',
  445. source: 'direct',
  446. occurredAt: '2026-07-10T00:00:00.000Z',
  447. userAgent: 'Mozilla/5.0',
  448. ipHash: 'ip-4',
  449. replayKey: 'cap-4',
  450. maxPerDay: 2
  451. });
  452. assert.equal(nextDay.recorded, true);
  453. });
  454. test('prunes raw tracking events before the retention cutoff', () => {
  455. initDatabase(tempDataDir(), 'test-secret');
  456. const user = createUser({ username: 'retention', email: 'retention@example.com', password: 'password123' });
  457. const domain = createDomain(user.id, domainFixture('retention.example'));
  458. const eventId = createSendEvent({
  459. userId: user.id,
  460. domainId: domain.id,
  461. sender: 'sender@retention.example',
  462. recipients: ['reader@example.net'],
  463. subject: 'Retention',
  464. trackingOpens: true
  465. });
  466. for (const [occurredAt, replayKey] of [
  467. ['2026-01-01T00:00:00.000Z', 'old-event'],
  468. ['2026-07-09T00:00:00.000Z', 'new-event']
  469. ]) {
  470. recordTrackingEvent({
  471. sendEventId: eventId,
  472. eventType: 'open',
  473. source: 'direct',
  474. occurredAt,
  475. userAgent: 'Mozilla/5.0',
  476. ipHash: replayKey,
  477. replayKey
  478. });
  479. }
  480. assert.equal(pruneTrackingEvents({ days: 30, now: '2026-07-10T00:00:00.000Z' }), 1);
  481. const detail = getSendEvent(user.id, eventId);
  482. assert.equal(detail.tracking.events.length, 1);
  483. assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:00.000Z');
  484. });
  485. test('isolates domains, smtp credentials, and api tokens by user', () => {
  486. initDatabase(tempDataDir(), 'test-secret');
  487. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  488. claimLegacyData(admin.id);
  489. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  490. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  491. assert.equal(authenticateUser('alice', 'password123').id, alice.id);
  492. assert.equal(authenticateUser('alice', 'wrong'), null);
  493. createDomain(alice.id, domainFixture('alice.example'));
  494. assert.equal(listDomains(alice.id).length, 1);
  495. assert.equal(listDomains(bob.id).length, 0);
  496. logSendEvent({
  497. userId: alice.id,
  498. domainId: listDomains(alice.id)[0].id,
  499. sender: 'noreply@alice.example',
  500. recipients: ['user@example.com'],
  501. subject: 'Hello',
  502. status: 'queued'
  503. });
  504. assert.equal(listSendEvents(alice.id).length, 1);
  505. assert.equal(listSendEvents(bob.id).length, 0);
  506. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'copy-me-123' });
  507. assert.equal(getSmtpCredential(alice.id, { includePassword: true }).password, 'copy-me-123');
  508. assert.equal(verifySmtpCredential('smtp-alice', 'copy-me-123').user.id, alice.id);
  509. assert.equal(verifySmtpCredential('smtp-alice', 'wrong'), null);
  510. const token = createApiToken(alice.id, 'send');
  511. assert.equal(verifyApiToken(token.token).id, alice.id);
  512. });
  513. test('stores multiple smtp login credentials per user', () => {
  514. initDatabase(tempDataDir(), 'test-secret');
  515. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  516. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  517. const first = saveSmtpCredential(alice.id, { username: 'alice-main', password: 'first-secret' });
  518. const second = saveSmtpCredential(alice.id, { username: 'alice-app', password: 'second-secret' });
  519. saveSmtpCredential(bob.id, { username: 'bob-main', password: 'bob-secret' });
  520. assert.equal(first.passwordSet, true);
  521. assert.equal(second.passwordSet, true);
  522. assert.deepEqual(listSmtpCredentials(alice.id).map((credential) => credential.username), ['alice-app', 'alice-main']);
  523. assert.equal(getSmtpCredential(first.id, alice.id, { includePassword: true }).password, 'first-secret');
  524. assert.equal(getSmtpCredential(first.id, bob.id), null);
  525. assert.equal(verifySmtpCredential('alice-main', 'first-secret').user.id, alice.id);
  526. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  527. assert.equal(verifySmtpCredential('alice-app', 'wrong'), null);
  528. assert.equal(deleteSmtpCredential(first.id, alice.id), true);
  529. assert.equal(verifySmtpCredential('alice-main', 'first-secret'), null);
  530. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  531. });
  532. test('stores multiple outbound smtp relays with encrypted recoverable passwords', () => {
  533. const database = initDatabase(tempDataDir(), 'test-secret');
  534. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  535. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  536. const primary = saveSmtpRelay(alice.id, {
  537. name: 'Primary relay',
  538. host: 'smtp.primary.example',
  539. port: 587,
  540. secure: false,
  541. username: 'primary-user',
  542. password: 'primary-secret',
  543. helo: 'mail.alice.example',
  544. isDefault: true
  545. });
  546. const backup = saveSmtpRelay(alice.id, {
  547. name: 'Backup relay',
  548. host: 'smtp.backup.example',
  549. port: 465,
  550. secure: true,
  551. username: 'backup-user',
  552. password: 'backup-secret',
  553. isDefault: true
  554. });
  555. saveSmtpRelay(bob.id, {
  556. name: 'Bob relay',
  557. host: 'smtp.bob.example',
  558. port: 25,
  559. secure: false,
  560. username: '',
  561. password: '',
  562. isDefault: true
  563. });
  564. assert.equal(primary.passwordSet, true);
  565. assert.equal('password' in primary, false);
  566. assert.deepEqual(
  567. listSmtpRelays(alice.id).map((relay) => [relay.name, relay.isDefault, 'password' in relay]),
  568. [
  569. ['Backup relay', true, false],
  570. ['Primary relay', false, false]
  571. ]
  572. );
  573. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  574. assert.equal(getSmtpRelay(backup.id, bob.id), null);
  575. assert.equal(JSON.stringify(listSmtpRelays(alice.id)).includes('backup-secret'), false);
  576. const stored = database.prepare('SELECT password_secret FROM smtp_relays WHERE id = ?').get(backup.id);
  577. assert.ok(stored.password_secret);
  578. assert.equal(stored.password_secret.includes('backup-secret'), false);
  579. const updated = saveSmtpRelay(alice.id, {
  580. id: backup.id,
  581. name: 'Backup relay updated',
  582. host: 'smtp.backup.example',
  583. port: 2525,
  584. secure: false,
  585. username: 'backup-user',
  586. helo: 'helo.backup.example',
  587. isDefault: false
  588. });
  589. assert.equal(updated.port, 2525);
  590. assert.equal(updated.isDefault, false);
  591. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  592. const cleared = saveSmtpRelay(alice.id, {
  593. id: backup.id,
  594. name: 'Backup relay without auth',
  595. host: 'smtp.backup.example',
  596. username: '',
  597. password: ''
  598. });
  599. assert.equal(cleared.passwordSet, false);
  600. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, '');
  601. assert.equal(saveSmtpRelay(bob.id, {
  602. id: backup.id,
  603. name: 'Should not create',
  604. host: 'smtp.invalid.example'
  605. }), null);
  606. assert.equal(deleteSmtpRelay(backup.id, bob.id), false);
  607. assert.equal(deleteSmtpRelay(backup.id, alice.id), true);
  608. assert.equal(getSmtpRelay(backup.id, alice.id), null);
  609. });
  610. test('returns scoped send event detail with webhook deliveries', () => {
  611. initDatabase(tempDataDir(), 'test-secret');
  612. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  613. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  614. const domain = createDomain(alice.id, domainFixture('detail.example'));
  615. createWebhook(alice.id, {
  616. name: 'Delivery audit',
  617. url: 'https://hooks.example.com/mailhub',
  618. events: ['sent']
  619. });
  620. const eventId = logSendEvent({
  621. userId: alice.id,
  622. domainId: domain.id,
  623. sender: 'noreply@detail.example',
  624. recipients: ['user@example.com'],
  625. subject: 'Tracked message',
  626. status: 'sent',
  627. detail: '250 2.0.0 queued as QDETAIL',
  628. queueId: 'QDETAIL',
  629. deliveryAttempts: [{
  630. at: '2026-07-09T12:00:00.000Z',
  631. queueId: 'QDETAIL',
  632. recipient: 'user@example.com',
  633. relay: 'mx.example.net',
  634. dsn: '2.0.0',
  635. status: 'sent',
  636. response: '250 2.0.0 ok'
  637. }],
  638. deliveryLog: [{
  639. at: '2026-07-09T11:59:58.000Z',
  640. phase: 'queue',
  641. direction: 'server',
  642. response: '250 2.0.0 queued as QDETAIL',
  643. ok: true
  644. }],
  645. deliveredAt: '2026-07-09T12:00:00.000Z'
  646. });
  647. const detail = getSendEvent(alice.id, eventId);
  648. assert.equal(detail.id, eventId);
  649. assert.equal(detail.domain, 'detail.example');
  650. assert.equal(detail.queueId, 'QDETAIL');
  651. assert.equal(detail.deliveryAttempts[0].relay, 'mx.example.net');
  652. assert.equal(detail.deliveryLog[0].phase, 'queue');
  653. assert.equal(detail.webhookDeliveries.length, 1);
  654. assert.equal(detail.webhookDeliveries[0].sendEventId, eventId);
  655. assert.equal(detail.webhookDeliveries[0].eventType, 'sent');
  656. assert.equal(getSendEvent(bob.id, eventId), null);
  657. });
  658. test('send analytics includes delivery funnel and top failure reasons', () => {
  659. initDatabase(tempDataDir(), 'test-secret');
  660. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  661. const domain = createDomain(alice.id, domainFixture('analytics.example'));
  662. for (const [index, status] of ['sent', 'sent', 'queued'].entries()) {
  663. logSendEvent({
  664. userId: alice.id,
  665. domainId: domain.id,
  666. sender: 'noreply@analytics.example',
  667. recipients: ['user@example.com'],
  668. subject: `Message ${status}`,
  669. status,
  670. detail: status === 'queued' ? 'queued as QPENDING' : '250 2.0.0 ok',
  671. queueId: status === 'queued' ? 'QPENDING' : `QSENT${index + 1}`
  672. });
  673. }
  674. logSendEvent({
  675. userId: alice.id,
  676. domainId: domain.id,
  677. sender: 'noreply@analytics.example',
  678. recipients: ['bad@example.com'],
  679. subject: 'Bounce',
  680. status: 'bounced',
  681. detail: 'Mailbox unavailable',
  682. queueId: 'QBOUNCE'
  683. });
  684. logSendEvent({
  685. userId: alice.id,
  686. domainId: domain.id,
  687. sender: 'noreply@analytics.example',
  688. recipients: ['bad2@example.com'],
  689. subject: 'Failure',
  690. status: 'failed',
  691. detail: 'Mailbox\t unavailable'
  692. });
  693. const analytics = getSendAnalytics(alice.id, { days: 7 });
  694. assert.equal(analytics.summary.submitted, 5);
  695. assert.equal(analytics.summary.accepted, 4);
  696. assert.equal(analytics.summary.delivered, 2);
  697. assert.equal(analytics.summary.pending, 1);
  698. assert.equal(analytics.summary.failed, 2);
  699. assert.equal(analytics.summary.acceptanceRate, 80);
  700. assert.equal(analytics.summary.deliveryRate, 40);
  701. assert.equal(analytics.summary.failureRate, 40);
  702. assert.deepEqual(analytics.deliveryFunnel.map((item) => [item.stage, item.total, item.rate]), [
  703. ['submitted', 5, 100],
  704. ['accepted', 4, 80],
  705. ['delivered', 2, 40],
  706. ['pending', 1, 20],
  707. ['failed', 2, 40]
  708. ]);
  709. assert.equal(analytics.failureReasons[0].reason, 'Mailbox unavailable');
  710. assert.equal(analytics.failureReasons[0].total, 2);
  711. });
  712. test('send analytics reports delivered engagement rates trends and grouped links', () => {
  713. initDatabase(tempDataDir(), 'test-secret');
  714. const user = createUser({ username: 'analytics-track', email: 'analytics-track@example.com', password: 'password123' });
  715. const domain = createDomain(user.id, domainFixture('analytics-track.example'));
  716. const now = new Date().toISOString();
  717. const target = 'https://example.net/reset?token=private';
  718. const fingerprint = trackingTargetFingerprint(target, 'tracking-secret');
  719. const events = [];
  720. for (let index = 0; index < 3; index += 1) {
  721. events.push(createSendEvent({
  722. userId: user.id,
  723. domainId: domain.id,
  724. sender: 'sender@analytics-track.example',
  725. recipients: [`reader-${index}@example.net`],
  726. subject: `Tracked ${index}`,
  727. status: 'sent',
  728. trackingOpens: true,
  729. trackingClicks: true
  730. }));
  731. }
  732. const links = events.slice(0, 2).map((eventId, index) => {
  733. const token = createTrackingToken();
  734. const id = createTrackingLink(user.id, eventId, {
  735. token,
  736. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  737. targetFingerprint: fingerprint,
  738. targetOrigin: 'https://example.net'
  739. });
  740. return { id, token, index };
  741. });
  742. const engagement = [
  743. { sendEventId: events[0], eventType: 'open', source: 'direct', key: 'open-direct' },
  744. { sendEventId: events[0], eventType: 'open', source: 'proxy', key: 'open-proxy' },
  745. { sendEventId: events[0], trackingLinkId: links[0].id, eventType: 'click', source: 'direct', key: 'click-one' },
  746. { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'scanner', key: 'click-scanner' },
  747. { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'direct', key: 'click-two' }
  748. ];
  749. for (const item of engagement) {
  750. recordTrackingEvent({
  751. ...item,
  752. occurredAt: now,
  753. userAgent: item.source === 'scanner' ? 'Scanner' : 'Mozilla/5.0',
  754. ipHash: item.key,
  755. replayKey: item.key
  756. });
  757. }
  758. const analytics = getSendAnalytics(user.id, { days: 7, trackingSecret: 'tracking-secret' });
  759. assert.equal(analytics.engagement.trackedDelivered, 3);
  760. assert.equal(analytics.engagement.totalOpens, 2);
  761. assert.equal(analytics.engagement.uniqueOpens, 2);
  762. assert.equal(analytics.engagement.proxyOpens, 1);
  763. assert.equal(analytics.engagement.totalClicks, 2);
  764. assert.equal(analytics.engagement.uniqueClicks, 2);
  765. assert.equal(analytics.engagement.scannerEvents, 1);
  766. assert.equal(analytics.engagement.openRate, 66.7);
  767. assert.equal(analytics.engagement.clickRate, 66.7);
  768. assert.equal(analytics.engagement.clickToOpenRate, 100);
  769. assert.equal(analytics.engagementByDay.reduce((sum, day) => sum + day.clicks, 0), 2);
  770. assert.equal(analytics.topLinks.length, 1);
  771. assert.equal(analytics.topLinks[0].clicks, 2);
  772. assert.equal(analytics.topLinks[0].uniqueClicks, 2);
  773. assert.equal(analytics.topLinks[0].target, target);
  774. });
  775. test('send analytics does not truncate windows above 5000 messages', () => {
  776. const database = initDatabase(tempDataDir(), 'test-secret');
  777. const user = createUser({ username: 'large-analytics', email: 'large-analytics@example.com', password: 'password123' });
  778. const createdAt = new Date().toISOString();
  779. const insert = database.prepare(`
  780. INSERT INTO send_events (
  781. user_id, domain_id, sender, recipients, subject, status, detail, created_at
  782. ) VALUES (?, NULL, ?, '[]', ?, 'queued', '', ?)
  783. `);
  784. database.exec('BEGIN');
  785. for (let index = 0; index < 5001; index += 1) {
  786. insert.run(user.id, 'sender@example.com', `Message ${index}`, createdAt);
  787. }
  788. database.exec('COMMIT');
  789. assert.equal(getSendAnalytics(user.id, { days: 1 }).summary.total, 5001);
  790. });
  791. test('stores account tokens as hashes and enforces token lifecycle', () => {
  792. const database = initDatabase(tempDataDir(), 'test-secret');
  793. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  794. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  795. const purpose = 'email_verification';
  796. const created = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  797. const expectedHash = createHash('sha256').update(created.token).digest('hex');
  798. assert.equal(created.userId, alice.id);
  799. assert.equal(created.purpose, purpose);
  800. assert.equal(typeof created.token, 'string');
  801. assert.equal(created.token.length > 32, true);
  802. assert.equal(created.usedAt, null);
  803. assert.equal('tokenHash' in created, false);
  804. const stored = database.prepare('SELECT * FROM account_tokens WHERE id = ?').get(created.id);
  805. assert.equal(stored.user_id, alice.id);
  806. assert.equal(stored.purpose, purpose);
  807. assert.equal(stored.token_hash, expectedHash);
  808. assert.notEqual(stored.token_hash, created.token);
  809. assert.equal(Object.values(stored).includes(created.token), false);
  810. assert.equal(consumeAccountToken(created.token, 'password_reset'), null);
  811. const consumed = consumeAccountToken(created.token, purpose);
  812. assert.equal(consumed.id, created.id);
  813. assert.equal(consumed.userId, alice.id);
  814. assert.equal(consumed.purpose, purpose);
  815. assert.equal(typeof consumed.usedAt, 'string');
  816. assert.equal('token' in consumed, false);
  817. assert.equal(consumeAccountToken(created.token, purpose), null);
  818. const expired = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  819. database
  820. .prepare('UPDATE account_tokens SET expires_at = ? WHERE id = ?')
  821. .run('2000-01-01T00:00:00.000Z', expired.id);
  822. assert.equal(consumeAccountToken(expired.token, purpose), null);
  823. const alreadyUsedReset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  824. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset').id, alreadyUsedReset.id);
  825. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  826. const otherPurpose = createAccountToken(alice.id, 'email_change', { ttlMinutes: 30 });
  827. const otherUserReset = createAccountToken(bob.id, 'password_reset', { ttlMinutes: 30 });
  828. assert.equal(invalidateAccountTokens(alice.id, 'password_reset'), 1);
  829. assert.equal(typeof database.prepare('SELECT used_at FROM account_tokens WHERE id = ?').get(reset.id).used_at, 'string');
  830. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  831. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset'), null);
  832. assert.equal(consumeAccountToken(otherPurpose.token, 'email_change').id, otherPurpose.id);
  833. assert.equal(consumeAccountToken(otherUserReset.token, 'password_reset').id, otherUserReset.id);
  834. });
  835. test('password updates invalidate unused password reset tokens only', () => {
  836. initDatabase(tempDataDir(), 'test-secret');
  837. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  838. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  839. const verification = createAccountToken(alice.id, 'email_verification', { ttlMinutes: 30 });
  840. updateUser(alice.id, { password: 'new-password-123' });
  841. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  842. assert.equal(consumeAccountToken(verification.token, 'email_verification').id, verification.id);
  843. assert.equal(authenticateUser('alice', 'password123'), null);
  844. assert.equal(authenticateUser('alice', 'new-password-123').id, alice.id);
  845. });
  846. test('validates account token ttl minute boundaries', () => {
  847. initDatabase(tempDataDir(), 'test-secret');
  848. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  849. const maxTtlMinutes = 7 * 24 * 60;
  850. for (const ttlMinutes of [0, -1, 1.5, maxTtlMinutes + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
  851. assert.throws(
  852. () => createAccountToken(alice.id, 'email_verification', { ttlMinutes }),
  853. /令牌有效期不正确。/
  854. );
  855. }
  856. assert.equal(createAccountToken(alice.id, 'email_verification', { ttlMinutes: 1 }).userId, alice.id);
  857. assert.equal(createAccountToken(alice.id, 'password_reset', { ttlMinutes: maxTtlMinutes }).userId, alice.id);
  858. });
  859. test('creates users with account tokens atomically', () => {
  860. const database = initDatabase(tempDataDir(), 'test-secret');
  861. assert.throws(
  862. () => createUserWithAccountToken({
  863. username: 'rollback',
  864. email: 'rollback@example.com',
  865. password: 'password123',
  866. status: 'pending_email'
  867. }, 'email_verification', { ttlMinutes: 0 }),
  868. /令牌有效期不正确。/
  869. );
  870. assert.equal(
  871. database
  872. .prepare("SELECT COUNT(*) AS count FROM users WHERE username = 'rollback' OR email = 'rollback@example.com'")
  873. .get()
  874. .count,
  875. 0
  876. );
  877. assert.equal(database.prepare('SELECT COUNT(*) AS count FROM account_tokens').get().count, 0);
  878. const { user, accountToken } = createUserWithAccountToken({
  879. username: 'atomic',
  880. email: 'atomic@example.com',
  881. password: 'password123',
  882. status: 'pending_email'
  883. }, 'email_verification', { ttlMinutes: 30 });
  884. assert.equal(user.status, 'pending_email');
  885. assert.equal(accountToken.userId, user.id);
  886. assert.equal(accountToken.purpose, 'email_verification');
  887. assert.equal(typeof accountToken.token, 'string');
  888. assert.equal('tokenHash' in accountToken, false);
  889. assert.equal(
  890. database
  891. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?')
  892. .get(user.id, 'email_verification')
  893. .count,
  894. 1
  895. );
  896. });
  897. test('moves users through the extended status lifecycle', () => {
  898. initDatabase(tempDataDir(), 'test-secret');
  899. const pendingEmail = createUser({
  900. username: 'pending',
  901. email: 'pending@example.com',
  902. password: 'password123',
  903. status: 'pending_email'
  904. });
  905. const seededAdmin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  906. assert.equal(pendingEmail.status, 'pending_email');
  907. assert.equal(seededAdmin.status, 'active');
  908. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  909. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  910. assert.equal(approveUser(pendingEmail.id).status, 'active');
  911. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'active');
  912. assert.equal(updateUserStatus(pendingEmail.id, 'disabled').status, 'disabled');
  913. assert.equal(updateUser(pendingEmail.id, { status: 'pending_review' }).status, 'pending_review');
  914. assert.equal(markUserEmailVerified(999999), null);
  915. assert.throws(() => updateUserStatus(pendingEmail.id, 'archived'), /用户状态不正确/);
  916. assert.throws(
  917. () => createUser({
  918. username: 'invalidstatus',
  919. email: 'invalidstatus@example.com',
  920. password: 'password123',
  921. status: 'archived'
  922. }),
  923. /用户状态不正确/
  924. );
  925. assert.equal(getUser(pendingEmail.id).status, 'pending_review');
  926. });
  927. test('lists users with owned resource counts', () => {
  928. initDatabase(tempDataDir(), 'test-secret');
  929. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  930. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  931. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  932. createDomain(alice.id, domainFixture('news.alice.example'));
  933. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  934. saveDnsCredential(alice.id, {
  935. name: 'Alice Cloudflare',
  936. provider: 'cloudflare',
  937. zoneName: 'alice.example',
  938. credentials: { apiToken: 'alice-secret-dns-token', zoneId: 'alice-zone' }
  939. });
  940. saveDnsCredential(alice.id, {
  941. name: 'Alice News Cloudflare',
  942. provider: 'cloudflare',
  943. zoneName: 'news.alice.example',
  944. credentials: { apiToken: 'alice-news-secret-dns-token', zoneId: 'alice-news-zone' }
  945. });
  946. createApiToken(alice.id, 'primary');
  947. createApiToken(alice.id, 'secondary');
  948. createApiToken(bob.id, 'primary');
  949. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  950. saveSmtpCredential(alice.id, { username: 'smtp-alice-app', password: 'smtp-secret-456' });
  951. logSendEvent({
  952. userId: alice.id,
  953. domainId: aliceDomain.id,
  954. sender: 'noreply@alice.example',
  955. recipients: ['one@example.com'],
  956. subject: 'First',
  957. status: 'queued'
  958. });
  959. logSendEvent({
  960. userId: alice.id,
  961. domainId: aliceDomain.id,
  962. sender: 'noreply@alice.example',
  963. recipients: ['two@example.com'],
  964. subject: 'Second',
  965. status: 'sent'
  966. });
  967. logSendEvent({
  968. userId: bob.id,
  969. domainId: bobDomain.id,
  970. sender: 'noreply@bob.example',
  971. recipients: ['bob@example.com'],
  972. subject: 'Bob',
  973. status: 'queued'
  974. });
  975. const users = listUsersWithResourceCounts();
  976. const aliceWithCounts = users.find((user) => user.id === alice.id);
  977. const bobWithCounts = users.find((user) => user.id === bob.id);
  978. assert.deepEqual(aliceWithCounts.resourceCounts, {
  979. domains: 2,
  980. dnsCredentials: 2,
  981. apiTokens: 2,
  982. sendEvents: 2,
  983. smtpCredential: 2
  984. });
  985. assert.deepEqual(bobWithCounts.resourceCounts, {
  986. domains: 1,
  987. dnsCredentials: 0,
  988. apiTokens: 1,
  989. sendEvents: 1,
  990. smtpCredential: 0
  991. });
  992. assert.equal(typeof aliceWithCounts.resourceCounts.domains, 'number');
  993. assert.equal('passwordHash' in aliceWithCounts, false);
  994. assert.equal(JSON.stringify(users).includes('alice-secret-dns-token'), false);
  995. assert.equal(JSON.stringify(users).includes('smtp-secret-123'), false);
  996. });
  997. test('builds admin resource inventory grouped by user with ownership warnings', () => {
  998. initDatabase(tempDataDir(), 'test-secret');
  999. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1000. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1001. const bobCredential = saveDnsCredential(bob.id, {
  1002. name: 'Bob DNS',
  1003. provider: 'cloudflare',
  1004. zoneName: 'bob.example',
  1005. credentials: { apiToken: 'bob-secret-token', zoneId: 'bob-zone' }
  1006. });
  1007. const aliceDomain = createDomain(alice.id, {
  1008. ...domainFixture('alice.example'),
  1009. dnsCredentialId: bobCredential.id
  1010. });
  1011. createDomain(bob.id, domainFixture('bob.example'));
  1012. createApiToken(alice.id, 'primary');
  1013. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  1014. logSendEvent({
  1015. userId: alice.id,
  1016. domainId: aliceDomain.id,
  1017. sender: 'noreply@alice.example',
  1018. recipients: ['a@example.com'],
  1019. subject: 'Queued',
  1020. status: 'queued'
  1021. });
  1022. const inventory = getAdminResourceInventory();
  1023. const aliceResources = inventory.users.find((entry) => entry.user.id === alice.id);
  1024. const bobResources = inventory.users.find((entry) => entry.user.id === bob.id);
  1025. assert.equal(aliceResources.domains.length, 1);
  1026. assert.equal(aliceResources.domains[0].domain, 'alice.example');
  1027. assert.equal(aliceResources.dnsCredentials.length, 0);
  1028. assert.equal(aliceResources.apiTokens.length, 1);
  1029. assert.equal(aliceResources.smtpCredential.username, 'smtp-alice');
  1030. assert.equal(aliceResources.smtpCredential.passwordSet, true);
  1031. assert.equal(aliceResources.sendEventCount, 1);
  1032. assert.equal(bobResources.domains.length, 1);
  1033. assert.equal(bobResources.dnsCredentials.length, 1);
  1034. assert.deepEqual(inventory.warnings, [{
  1035. type: 'domain_dns_credential_owner_mismatch',
  1036. domainId: aliceDomain.id,
  1037. domain: 'alice.example',
  1038. domainUserId: alice.id,
  1039. dnsCredentialId: bobCredential.id,
  1040. dnsCredentialUserId: bob.id
  1041. }]);
  1042. assert.equal(JSON.stringify(inventory).includes('bob-secret-token'), false);
  1043. assert.equal(JSON.stringify(inventory).includes('smtp-secret-123'), false);
  1044. });
  1045. test('transfers individual resources with audit logs', () => {
  1046. initDatabase(tempDataDir(), 'test-secret');
  1047. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  1048. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1049. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1050. const disabled = createUser({ username: 'disabled', email: 'disabled@example.com', password: 'password123', status: 'disabled' });
  1051. const domainOnlyCredential = saveDnsCredential(alice.id, {
  1052. name: 'Alice DNS 1',
  1053. provider: 'cloudflare',
  1054. zoneName: 'alice.example',
  1055. credentials: { apiToken: 'alice-secret-1' }
  1056. });
  1057. const clearCredential = saveDnsCredential(alice.id, {
  1058. name: 'Alice DNS 2',
  1059. provider: 'cloudflare',
  1060. zoneName: 'clear.example',
  1061. credentials: { apiToken: 'alice-secret-2' }
  1062. });
  1063. const withCredential = saveDnsCredential(alice.id, {
  1064. name: 'Alice DNS 3',
  1065. provider: 'cloudflare',
  1066. zoneName: 'with.example',
  1067. credentials: { apiToken: 'alice-secret-3' }
  1068. });
  1069. const standaloneCredential = saveDnsCredential(alice.id, {
  1070. name: 'Alice DNS 4',
  1071. provider: 'cloudflare',
  1072. zoneName: 'standalone.example',
  1073. credentials: { apiToken: 'alice-secret-4' }
  1074. });
  1075. const domainOnly = createDomain(alice.id, { ...domainFixture('domain-only.example'), dnsCredentialId: domainOnlyCredential.id });
  1076. const clearDomain = createDomain(alice.id, { ...domainFixture('clear.example'), dnsCredentialId: clearCredential.id });
  1077. const withDomain = createDomain(alice.id, { ...domainFixture('with.example'), dnsCredentialId: withCredential.id });
  1078. const apiToken = createApiToken(alice.id, 'primary');
  1079. assert.equal(transferDomain({
  1080. actorUserId: admin.id,
  1081. domainId: domainOnly.id,
  1082. targetUserId: bob.id,
  1083. dnsCredentialMode: 'domain_only'
  1084. }).userId, bob.id);
  1085. assert.equal(getDomain(domainOnly.id).dnsCredentialId, domainOnlyCredential.id);
  1086. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  1087. assert.throws(
  1088. () => transferDomain({
  1089. actorUserId: admin.id,
  1090. domainId: domainOnly.id,
  1091. targetUserId: bob.id,
  1092. dnsCredentialMode: 'with_dns_credential'
  1093. }),
  1094. /DNS 凭据归属不一致。/
  1095. );
  1096. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  1097. assert.equal(transferDomain({
  1098. actorUserId: admin.id,
  1099. domainId: clearDomain.id,
  1100. targetUserId: bob.id,
  1101. dnsCredentialMode: 'clear_dns_credential'
  1102. }).dnsCredentialId, null);
  1103. assert.equal(getDnsCredential(clearCredential.id, alice.id).id, clearCredential.id);
  1104. assert.equal(transferDomain({
  1105. actorUserId: admin.id,
  1106. domainId: withDomain.id,
  1107. targetUserId: bob.id,
  1108. dnsCredentialMode: 'with_dns_credential'
  1109. }).userId, bob.id);
  1110. assert.equal(getDnsCredential(withCredential.id, bob.id).id, withCredential.id);
  1111. assert.equal(transferDnsCredential({
  1112. actorUserId: admin.id,
  1113. credentialId: standaloneCredential.id,
  1114. targetUserId: bob.id
  1115. }).userId, bob.id);
  1116. assert.equal(getDnsCredential(standaloneCredential.id, bob.id).id, standaloneCredential.id);
  1117. const transferredTokens = transferApiTokens({
  1118. actorUserId: admin.id,
  1119. tokenIds: [apiToken.id],
  1120. targetUserId: bob.id
  1121. });
  1122. assert.equal(transferredTokens.length, 1);
  1123. assert.equal(verifyApiToken(apiToken.token).id, bob.id);
  1124. assert.throws(
  1125. () => transferDomain({ actorUserId: admin.id, domainId: domainOnly.id, targetUserId: disabled.id }),
  1126. /目标用户不可用。/
  1127. );
  1128. assert.throws(
  1129. () => transferApiTokens({ actorUserId: admin.id, tokenIds: [apiToken.id], targetUserId: 999999 }),
  1130. /目标用户不可用。/
  1131. );
  1132. const actions = listAuditLogs({ actorUserId: admin.id }).map((entry) => entry.action);
  1133. assert.ok(actions.includes('admin.transfer_domain'));
  1134. assert.ok(actions.includes('admin.transfer_dns_credential'));
  1135. assert.ok(actions.includes('admin.transfer_api_tokens'));
  1136. assert.equal(JSON.stringify(listAuditLogs({ actorUserId: admin.id })).includes('alice-secret'), false);
  1137. });
  1138. test('previews and executes user merge with resource counts and multiple smtp credentials', () => {
  1139. initDatabase(tempDataDir(), 'test-secret');
  1140. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  1141. const source = createUser({ username: 'source', email: 'source@example.com', password: 'password123' });
  1142. const target = createUser({ username: 'target', email: 'target@example.com', password: 'password123' });
  1143. const credential = saveDnsCredential(source.id, {
  1144. name: 'Source DNS',
  1145. provider: 'cloudflare',
  1146. zoneName: 'source.example',
  1147. credentials: { apiToken: 'source-secret' }
  1148. });
  1149. const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
  1150. const apiToken = createApiToken(source.id, 'primary');
  1151. saveSmtpCredential(source.id, { username: 'smtp-source', password: 'source-secret-123' });
  1152. saveSmtpCredential(source.id, { username: 'smtp-source-app', password: 'source-secret-456' });
  1153. saveSmtpCredential(target.id, { username: 'smtp-target', password: 'target-secret-123' });
  1154. logSendEvent({
  1155. userId: source.id,
  1156. domainId: domain.id,
  1157. sender: 'noreply@source.example',
  1158. recipients: ['a@example.com'],
  1159. subject: 'Queued',
  1160. status: 'queued'
  1161. });
  1162. const preview = previewUserMerge({ sourceUserId: source.id, targetUserId: target.id });
  1163. assert.equal(preview.confirmationText, 'MERGE source INTO target');
  1164. assert.deepEqual(preview.counts, {
  1165. domains: 1,
  1166. dnsCredentials: 1,
  1167. apiTokens: 1,
  1168. sendEvents: 1,
  1169. smtpCredential: 2
  1170. });
  1171. assert.equal(preview.resources.source.domains[0].domain, 'source.example');
  1172. assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
  1173. assert.equal(preview.resources.source.apiTokens[0].name, 'primary');
  1174. assert.equal(preview.resources.source.sendEventCount, 1);
  1175. assert.equal(preview.resources.target.domains.length, 0);
  1176. assert.deepEqual(preview.selectedCounts, {
  1177. domains: 1,
  1178. dnsCredentials: 1,
  1179. apiTokens: 1,
  1180. sendEvents: 1,
  1181. smtpCredential: 2
  1182. });
  1183. assert.equal(preview.smtp.conflict, false);
  1184. assert.deepEqual(preview.warnings, []);
  1185. assert.throws(
  1186. () => executeUserMerge({
  1187. actorUserId: admin.id,
  1188. sourceUserId: source.id,
  1189. targetUserId: target.id,
  1190. confirmation: 'wrong'
  1191. }),
  1192. /确认文本不匹配。/
  1193. );
  1194. assert.equal(getDomain(domain.id).userId, source.id);
  1195. const result = executeUserMerge({
  1196. actorUserId: admin.id,
  1197. sourceUserId: source.id,
  1198. targetUserId: target.id,
  1199. confirmation: preview.confirmationText
  1200. });
  1201. assert.deepEqual(result.counts, {
  1202. domains: 1,
  1203. dnsCredentials: 1,
  1204. apiTokens: 1,
  1205. sendEvents: 1,
  1206. smtpCredential: 2
  1207. });
  1208. assert.equal(getDomain(domain.id).userId, target.id);
  1209. assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);
  1210. assert.equal(verifyApiToken(apiToken.token).id, target.id);
  1211. assert.equal(listSendEvents(target.id).length, 1);
  1212. assert.deepEqual(listSmtpCredentials(target.id).map((item) => item.username).sort(), ['smtp-source', 'smtp-source-app', 'smtp-target']);
  1213. assert.deepEqual(listSmtpCredentials(source.id), []);
  1214. assert.equal(getUser(source.id).status, 'disabled');
  1215. const [audit] = listAuditLogs({ action: 'admin.user_merge' });
  1216. assert.equal(audit.targetUserId, target.id);
  1217. assert.equal(audit.summary.sourceUserId, source.id);
  1218. assert.deepEqual(audit.summary.counts, result.counts);
  1219. assert.equal(JSON.stringify(audit).includes('source-secret'), false);
  1220. assert.equal(JSON.stringify(audit).includes('target-secret'), false);
  1221. });
  1222. test('stores system email settings without exposing smtp password publicly', () => {
  1223. const database = initDatabase(tempDataDir(), 'test-secret');
  1224. const saved = saveSystemEmailSettings({
  1225. host: 'smtp.example.com',
  1226. port: 465,
  1227. secure: true,
  1228. username: 'mailer@example.com',
  1229. password: 'smtp-password-123',
  1230. helo: 'mail.example.com',
  1231. fromEmail: 'notify@example.com',
  1232. fromName: 'MailHub Notify',
  1233. testRecipient: 'admin@example.com'
  1234. });
  1235. assert.equal(saved.host, 'smtp.example.com');
  1236. assert.equal(saved.port, 465);
  1237. assert.equal(saved.secure, true);
  1238. assert.equal(saved.passwordSet, true);
  1239. assert.equal('password' in saved, false);
  1240. assert.equal(JSON.stringify(saved).includes('smtp-password-123'), false);
  1241. const storedRows = database.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'").all();
  1242. assert.equal(storedRows.some((row) => row.value === 'smtp-password-123'), false);
  1243. const publicSettings = getSystemEmailSettings();
  1244. assert.equal(publicSettings.passwordSet, true);
  1245. assert.equal('password' in publicSettings, false);
  1246. assert.equal(JSON.stringify(publicSettings).includes('smtp-password-123'), false);
  1247. const internalSettings = getSystemEmailSettings({ includeSecret: true });
  1248. assert.equal(internalSettings.password, 'smtp-password-123');
  1249. assert.equal(internalSettings.passwordSet, true);
  1250. const unchangedPassword = saveSystemEmailSettings({
  1251. host: 'smtp2.example.com',
  1252. password: ''
  1253. });
  1254. assert.equal(unchangedPassword.host, 'smtp2.example.com');
  1255. assert.equal(unchangedPassword.passwordSet, true);
  1256. assert.equal(getSystemEmailSettings({ includeSecret: true }).password, 'smtp-password-123');
  1257. });
  1258. test('summarizes send analytics by user', () => {
  1259. initDatabase(tempDataDir(), 'test-secret');
  1260. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1261. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1262. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  1263. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  1264. logSendEvent({
  1265. userId: alice.id,
  1266. domainId: aliceDomain.id,
  1267. sender: 'noreply@alice.example',
  1268. recipients: ['a@example.com', 'b@example.com'],
  1269. subject: 'Queued',
  1270. status: 'queued'
  1271. });
  1272. logSendEvent({
  1273. userId: alice.id,
  1274. domainId: aliceDomain.id,
  1275. sender: 'noreply@alice.example',
  1276. recipients: ['c@example.com'],
  1277. subject: 'Failed',
  1278. status: 'failed',
  1279. detail: 'relay rejected'
  1280. });
  1281. logSendEvent({
  1282. userId: bob.id,
  1283. domainId: bobDomain.id,
  1284. sender: 'noreply@bob.example',
  1285. recipients: ['x@example.com'],
  1286. subject: 'Hidden',
  1287. status: 'queued'
  1288. });
  1289. const analytics = getSendAnalytics(alice.id, { days: 7 });
  1290. assert.equal(analytics.summary.total, 2);
  1291. assert.equal(analytics.summary.queued, 1);
  1292. assert.equal(analytics.summary.failed, 1);
  1293. assert.equal(analytics.summary.recipients, 3);
  1294. assert.equal(analytics.summary.successRate, 50);
  1295. assert.equal(analytics.byDomain.length, 1);
  1296. assert.equal(analytics.byDomain[0].domain, 'alice.example');
  1297. assert.equal(analytics.recentFailures.length, 1);
  1298. assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
  1299. });
  1300. test('excludes queued and sent messages from recent delivery failures', () => {
  1301. initDatabase(tempDataDir(), 'test-secret');
  1302. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1303. const domain = createDomain(alice.id, domainFixture('alice.example'));
  1304. for (const event of [
  1305. { subject: 'Queued', status: 'queued', detail: 'accepted by postfix' },
  1306. { subject: 'Sent', status: 'sent', detail: '250 OK' },
  1307. { subject: 'Deferred', status: 'deferred', detail: 'temporary failure' },
  1308. { subject: 'Bounced', status: 'bounced', detail: '550 user unknown' },
  1309. { subject: 'Failed', status: 'failed', detail: 'relay rejected' }
  1310. ]) {
  1311. logSendEvent({
  1312. userId: alice.id,
  1313. domainId: domain.id,
  1314. sender: 'noreply@alice.example',
  1315. recipients: ['user@example.com'],
  1316. subject: event.subject,
  1317. status: event.status,
  1318. detail: event.detail
  1319. });
  1320. }
  1321. const analytics = getSendAnalytics(alice.id, { days: 7 });
  1322. assert.deepEqual(
  1323. analytics.recentFailures.map((event) => event.subject),
  1324. ['Failed', 'Bounced', 'Deferred']
  1325. );
  1326. });
  1327. test('stores and returns structured delivery logs for send events', () => {
  1328. initDatabase(tempDataDir(), 'test-secret');
  1329. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1330. const domain = createDomain(alice.id, domainFixture('alice.example'));
  1331. const deliveryLog = [
  1332. {
  1333. at: '2026-07-08T00:00:00.000Z',
  1334. phase: 'connect',
  1335. direction: 'system',
  1336. message: 'Connected to relay.test:25',
  1337. ok: true
  1338. },
  1339. {
  1340. at: '2026-07-08T00:00:01.000Z',
  1341. phase: 'queue',
  1342. direction: 'server',
  1343. code: 250,
  1344. response: '250 queued as ABC123',
  1345. ok: true
  1346. }
  1347. ];
  1348. logSendEvent({
  1349. userId: alice.id,
  1350. domainId: domain.id,
  1351. sender: 'noreply@alice.example',
  1352. recipients: ['user@example.com'],
  1353. subject: 'Delivery log',
  1354. status: 'queued',
  1355. detail: '250 queued as ABC123',
  1356. deliveryLog
  1357. });
  1358. const [event] = listSendEvents(alice.id);
  1359. assert.deepEqual(event.deliveryLog, deliveryLog);
  1360. });
  1361. test('records audit logs without storing secrets', () => {
  1362. initDatabase(tempDataDir(), 'test-secret');
  1363. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  1364. const user = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1365. logAudit({
  1366. actorUserId: admin.id,
  1367. action: 'admin.temporary_password',
  1368. targetType: 'user',
  1369. targetId: String(user.id),
  1370. targetUserId: user.id,
  1371. summary: {
  1372. username: user.username,
  1373. temporaryPassword: 'secret-password',
  1374. passwordSet: true,
  1375. dkimPrivate: 'private-key',
  1376. dkimPublic: 'public-key',
  1377. dkim_private: 'private-key',
  1378. dkim_public: 'public-key',
  1379. authorization: 'Bearer top-level-token',
  1380. headers: {
  1381. authorization: 'Bearer nested-token',
  1382. from: 'admin@example.com'
  1383. },
  1384. nested: {
  1385. apiToken: 'secret-token',
  1386. note: 'kept'
  1387. },
  1388. changes: [
  1389. {
  1390. credential: 'secret-credential',
  1391. field: 'password'
  1392. },
  1393. {
  1394. field: 'password',
  1395. value: 'plain-secret',
  1396. label: 'password change'
  1397. },
  1398. {
  1399. name: 'apiToken',
  1400. oldValue: 'old-token',
  1401. newValue: 'new-token'
  1402. },
  1403. {
  1404. header: 'authorization',
  1405. value: 'Bearer token',
  1406. status: 'set'
  1407. },
  1408. {
  1409. path: 'smtp.password',
  1410. from: 'old-password',
  1411. to: 'new-password'
  1412. },
  1413. {
  1414. key: 'credentials.accessKeySecret',
  1415. before: 'old-secret',
  1416. after: 'new-secret'
  1417. },
  1418. {
  1419. field: 'displayName',
  1420. value: 'Alice Example'
  1421. },
  1422. {
  1423. field: 'password',
  1424. old: 'old-secret',
  1425. new: 'new-secret'
  1426. },
  1427. {
  1428. change: { field: 'password' },
  1429. value: 'plain-secret'
  1430. },
  1431. {
  1432. context: { path: 'smtp.password' },
  1433. from: 'old-wrapper-secret',
  1434. to: 'new-wrapper-secret'
  1435. },
  1436. {
  1437. change: { field: 'displayName' },
  1438. value: 'Alice Wrapper'
  1439. }
  1440. ]
  1441. }
  1442. });
  1443. const [entry] = listAuditLogs({ actorUserId: admin.id });
  1444. assert.equal(entry.action, 'admin.temporary_password');
  1445. assert.equal(entry.targetType, 'user');
  1446. assert.equal(entry.targetId, String(user.id));
  1447. assert.equal(entry.targetUserId, user.id);
  1448. assert.equal(entry.summary.username, 'alice');
  1449. assert.equal(entry.summary.temporaryPassword, undefined);
  1450. assert.equal(entry.summary.passwordSet, true);
  1451. assert.equal(entry.summary.dkimPrivate, undefined);
  1452. assert.equal(entry.summary.dkimPublic, 'public-key');
  1453. assert.equal(entry.summary.dkim_private, undefined);
  1454. assert.equal(entry.summary.dkim_public, 'public-key');
  1455. assert.equal(entry.summary.authorization, undefined);
  1456. assert.equal(entry.summary.headers.authorization, undefined);
  1457. assert.equal(entry.summary.headers.from, 'admin@example.com');
  1458. assert.equal(entry.summary.nested.apiToken, undefined);
  1459. assert.equal(entry.summary.nested.note, 'kept');
  1460. assert.equal(entry.summary.changes[0].credential, undefined);
  1461. assert.equal(entry.summary.changes[0].field, 'password');
  1462. assert.equal(entry.summary.changes[1].field, 'password');
  1463. assert.equal(entry.summary.changes[1].value, undefined);
  1464. assert.equal(entry.summary.changes[1].label, 'password change');
  1465. assert.equal(entry.summary.changes[2].name, 'apiToken');
  1466. assert.equal(entry.summary.changes[2].oldValue, undefined);
  1467. assert.equal(entry.summary.changes[2].newValue, undefined);
  1468. assert.equal(entry.summary.changes[3].header, 'authorization');
  1469. assert.equal(entry.summary.changes[3].value, undefined);
  1470. assert.equal(entry.summary.changes[3].status, 'set');
  1471. assert.equal(entry.summary.changes[4].path, 'smtp.password');
  1472. assert.equal(entry.summary.changes[4].from, undefined);
  1473. assert.equal(entry.summary.changes[4].to, undefined);
  1474. assert.equal(entry.summary.changes[5].key, undefined);
  1475. assert.equal(entry.summary.changes[5].before, undefined);
  1476. assert.equal(entry.summary.changes[5].after, undefined);
  1477. assert.equal(entry.summary.changes[6].field, 'displayName');
  1478. assert.equal(entry.summary.changes[6].value, 'Alice Example');
  1479. assert.equal(entry.summary.changes[7].field, 'password');
  1480. assert.equal(entry.summary.changes[7].old, undefined);
  1481. assert.equal(entry.summary.changes[7].new, undefined);
  1482. assert.deepEqual(entry.summary.changes[8].change, { field: 'password' });
  1483. assert.equal(entry.summary.changes[8].value, undefined);
  1484. assert.deepEqual(entry.summary.changes[9].context, { path: 'smtp.password' });
  1485. assert.equal(entry.summary.changes[9].from, undefined);
  1486. assert.equal(entry.summary.changes[9].to, undefined);
  1487. assert.deepEqual(entry.summary.changes[10].change, { field: 'displayName' });
  1488. assert.equal(entry.summary.changes[10].value, 'Alice Wrapper');
  1489. });
  1490. test('filters audit logs and returns newest entries first', () => {
  1491. initDatabase(tempDataDir(), 'test-secret');
  1492. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  1493. const alice = createUser({ username: 'alice2', email: 'alice2@example.com', password: 'password123' });
  1494. const bob = createUser({ username: 'bob2', email: 'bob2@example.com', password: 'password123' });
  1495. logAudit({
  1496. actorUserId: admin.id,
  1497. action: 'admin.disable_user',
  1498. targetType: 'user',
  1499. targetId: String(alice.id),
  1500. targetUserId: alice.id,
  1501. summary: { username: alice.username }
  1502. });
  1503. logAudit({
  1504. actorUserId: admin.id,
  1505. action: 'admin.reset_password',
  1506. targetType: 'user',
  1507. targetId: String(alice.id),
  1508. targetUserId: alice.id,
  1509. summary: { username: alice.username }
  1510. });
  1511. logAudit({
  1512. actorUserId: null,
  1513. action: 'system.rotation',
  1514. targetType: 'system',
  1515. summary: { reason: 'scheduled' }
  1516. });
  1517. logAudit({
  1518. actorUserId: admin.id,
  1519. action: 'admin.reset_password',
  1520. targetType: 'user',
  1521. targetId: String(bob.id),
  1522. targetUserId: bob.id,
  1523. summary: { username: bob.username }
  1524. });
  1525. assert.deepEqual(
  1526. listAuditLogs({ action: 'admin.reset_password' }).map((entry) => entry.summary.username),
  1527. ['bob2', 'alice2']
  1528. );
  1529. assert.deepEqual(
  1530. listAuditLogs({ targetUserId: alice.id }).map((entry) => entry.action),
  1531. ['admin.reset_password', 'admin.disable_user']
  1532. );
  1533. assert.deepEqual(
  1534. listAuditLogs({ actorUserId: null }).map((entry) => entry.action),
  1535. ['system.rotation']
  1536. );
  1537. const pastIso = '2000-01-01T00:00:00.000Z';
  1538. const futureIso = '2999-01-01T00:00:00.000Z';
  1539. assert.deepEqual(listAuditLogs({ from: futureIso }), []);
  1540. assert.deepEqual(listAuditLogs({ to: pastIso }), []);
  1541. assert.deepEqual(
  1542. listAuditLogs({ from: pastIso, to: futureIso }).map((entry) => entry.action),
  1543. ['admin.reset_password', 'system.rotation', 'admin.reset_password', 'admin.disable_user']
  1544. );
  1545. });
  1546. function domainFixture(domain) {
  1547. return {
  1548. domain,
  1549. selector: 'mh202607',
  1550. verificationToken: 'token',
  1551. dkimPublic: 'public',
  1552. dkimPrivate: 'private',
  1553. senderHost: `mail.${domain}`,
  1554. sendingIp: '127.0.0.1',
  1555. spfExtra: '',
  1556. dmarcPolicy: 'none',
  1557. dmarcRua: ''
  1558. };
  1559. }
  1560. function tempDataDir() {
  1561. return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
  1562. }