db.test.js 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  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. createDomain,
  15. createUser,
  16. createUserWithAccountToken,
  17. createWebhook,
  18. deleteSmtpCredential,
  19. deleteSmtpRelay,
  20. getDnsCredential,
  21. getDomain,
  22. getSendEvent,
  23. getSendAnalytics,
  24. getSmtpRelay,
  25. getSmtpCredential,
  26. getAdminResourceInventory,
  27. getSystemEmailSettings,
  28. getUser,
  29. initDatabase,
  30. listAuditLogs,
  31. listDomains,
  32. listSendEvents,
  33. listSmtpCredentials,
  34. listSmtpRelays,
  35. listUsersWithResourceCounts,
  36. invalidateAccountTokens,
  37. logAudit,
  38. logSendEvent,
  39. approveUser,
  40. markUserEmailVerified,
  41. previewUserMerge,
  42. saveDnsCredential,
  43. saveSmtpRelay,
  44. saveSmtpCredential,
  45. saveSystemEmailSettings,
  46. seedAdminUser,
  47. transferApiTokens,
  48. transferDnsCredential,
  49. transferDomain,
  50. updateUser,
  51. updateUserStatus,
  52. executeUserMerge,
  53. verifyApiToken,
  54. verifySmtpCredential
  55. } from '../src/db.js';
  56. test('migrates legacy data to the seeded admin user', () => {
  57. const dataDir = tempDataDir();
  58. const dbPath = path.join(dataDir, 'mailhub.sqlite');
  59. const legacy = new DatabaseSync(dbPath);
  60. legacy.exec(`
  61. CREATE TABLE domains (
  62. id INTEGER PRIMARY KEY AUTOINCREMENT,
  63. domain TEXT NOT NULL UNIQUE,
  64. selector TEXT NOT NULL,
  65. verification_token TEXT NOT NULL,
  66. dkim_public TEXT NOT NULL,
  67. dkim_private TEXT NOT NULL,
  68. sender_host TEXT NOT NULL,
  69. sending_ip TEXT NOT NULL,
  70. spf_extra TEXT NOT NULL DEFAULT '',
  71. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  72. dmarc_rua TEXT NOT NULL DEFAULT '',
  73. status_json TEXT NOT NULL DEFAULT '{}',
  74. created_at TEXT NOT NULL,
  75. updated_at TEXT NOT NULL
  76. );
  77. CREATE TABLE send_events (
  78. id INTEGER PRIMARY KEY AUTOINCREMENT,
  79. domain_id INTEGER,
  80. sender TEXT NOT NULL,
  81. recipients TEXT NOT NULL,
  82. subject TEXT NOT NULL,
  83. status TEXT NOT NULL,
  84. detail TEXT NOT NULL DEFAULT '',
  85. created_at TEXT NOT NULL
  86. );
  87. CREATE TABLE smtp_credentials (
  88. id INTEGER PRIMARY KEY CHECK (id = 1),
  89. username TEXT NOT NULL,
  90. password_hash TEXT NOT NULL,
  91. password_secret TEXT NOT NULL DEFAULT '',
  92. created_at TEXT NOT NULL,
  93. updated_at TEXT NOT NULL
  94. );
  95. `);
  96. legacy
  97. .prepare(`
  98. INSERT INTO domains (
  99. domain, selector, verification_token, dkim_public, dkim_private,
  100. sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua, created_at, updated_at
  101. ) VALUES ('legacy.example', 'mh', 'tok', 'pub', 'priv', 'mail.legacy.example', '127.0.0.1', '', 'none', '', 'now', 'now')
  102. `)
  103. .run();
  104. legacy
  105. .prepare(`
  106. INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
  107. VALUES (1, 'noreply@legacy.example', '["user@example.com"]', 'hi', 'queued', '', 'now')
  108. `)
  109. .run();
  110. legacy
  111. .prepare(`
  112. INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
  113. VALUES (1, 'legacy-smtp', 'scrypt$salt$hash', '', 'now', 'now')
  114. `)
  115. .run();
  116. legacy.close();
  117. initDatabase(dataDir, 'test-secret');
  118. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  119. claimLegacyData(admin.id);
  120. assert.equal(listDomains(admin.id).length, 1);
  121. assert.equal(listSendEvents(admin.id).length, 1);
  122. assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
  123. });
  124. test('isolates domains, smtp credentials, and api tokens by user', () => {
  125. initDatabase(tempDataDir(), 'test-secret');
  126. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  127. claimLegacyData(admin.id);
  128. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  129. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  130. assert.equal(authenticateUser('alice', 'password123').id, alice.id);
  131. assert.equal(authenticateUser('alice', 'wrong'), null);
  132. createDomain(alice.id, domainFixture('alice.example'));
  133. assert.equal(listDomains(alice.id).length, 1);
  134. assert.equal(listDomains(bob.id).length, 0);
  135. logSendEvent({
  136. userId: alice.id,
  137. domainId: listDomains(alice.id)[0].id,
  138. sender: 'noreply@alice.example',
  139. recipients: ['user@example.com'],
  140. subject: 'Hello',
  141. status: 'queued'
  142. });
  143. assert.equal(listSendEvents(alice.id).length, 1);
  144. assert.equal(listSendEvents(bob.id).length, 0);
  145. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'copy-me-123' });
  146. assert.equal(getSmtpCredential(alice.id, { includePassword: true }).password, 'copy-me-123');
  147. assert.equal(verifySmtpCredential('smtp-alice', 'copy-me-123').user.id, alice.id);
  148. assert.equal(verifySmtpCredential('smtp-alice', 'wrong'), null);
  149. const token = createApiToken(alice.id, 'send');
  150. assert.equal(verifyApiToken(token.token).id, alice.id);
  151. });
  152. test('stores multiple smtp login credentials per user', () => {
  153. initDatabase(tempDataDir(), 'test-secret');
  154. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  155. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  156. const first = saveSmtpCredential(alice.id, { username: 'alice-main', password: 'first-secret' });
  157. const second = saveSmtpCredential(alice.id, { username: 'alice-app', password: 'second-secret' });
  158. saveSmtpCredential(bob.id, { username: 'bob-main', password: 'bob-secret' });
  159. assert.equal(first.passwordSet, true);
  160. assert.equal(second.passwordSet, true);
  161. assert.deepEqual(listSmtpCredentials(alice.id).map((credential) => credential.username), ['alice-app', 'alice-main']);
  162. assert.equal(getSmtpCredential(first.id, alice.id, { includePassword: true }).password, 'first-secret');
  163. assert.equal(getSmtpCredential(first.id, bob.id), null);
  164. assert.equal(verifySmtpCredential('alice-main', 'first-secret').user.id, alice.id);
  165. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  166. assert.equal(verifySmtpCredential('alice-app', 'wrong'), null);
  167. assert.equal(deleteSmtpCredential(first.id, alice.id), true);
  168. assert.equal(verifySmtpCredential('alice-main', 'first-secret'), null);
  169. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  170. });
  171. test('stores multiple outbound smtp relays with encrypted recoverable passwords', () => {
  172. const database = initDatabase(tempDataDir(), 'test-secret');
  173. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  174. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  175. const primary = saveSmtpRelay(alice.id, {
  176. name: 'Primary relay',
  177. host: 'smtp.primary.example',
  178. port: 587,
  179. secure: false,
  180. username: 'primary-user',
  181. password: 'primary-secret',
  182. helo: 'mail.alice.example',
  183. isDefault: true
  184. });
  185. const backup = saveSmtpRelay(alice.id, {
  186. name: 'Backup relay',
  187. host: 'smtp.backup.example',
  188. port: 465,
  189. secure: true,
  190. username: 'backup-user',
  191. password: 'backup-secret',
  192. isDefault: true
  193. });
  194. saveSmtpRelay(bob.id, {
  195. name: 'Bob relay',
  196. host: 'smtp.bob.example',
  197. port: 25,
  198. secure: false,
  199. username: '',
  200. password: '',
  201. isDefault: true
  202. });
  203. assert.equal(primary.passwordSet, true);
  204. assert.equal('password' in primary, false);
  205. assert.deepEqual(
  206. listSmtpRelays(alice.id).map((relay) => [relay.name, relay.isDefault, 'password' in relay]),
  207. [
  208. ['Backup relay', true, false],
  209. ['Primary relay', false, false]
  210. ]
  211. );
  212. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  213. assert.equal(getSmtpRelay(backup.id, bob.id), null);
  214. assert.equal(JSON.stringify(listSmtpRelays(alice.id)).includes('backup-secret'), false);
  215. const stored = database.prepare('SELECT password_secret FROM smtp_relays WHERE id = ?').get(backup.id);
  216. assert.ok(stored.password_secret);
  217. assert.equal(stored.password_secret.includes('backup-secret'), false);
  218. const updated = saveSmtpRelay(alice.id, {
  219. id: backup.id,
  220. name: 'Backup relay updated',
  221. host: 'smtp.backup.example',
  222. port: 2525,
  223. secure: false,
  224. username: 'backup-user',
  225. helo: 'helo.backup.example',
  226. isDefault: false
  227. });
  228. assert.equal(updated.port, 2525);
  229. assert.equal(updated.isDefault, false);
  230. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  231. const cleared = saveSmtpRelay(alice.id, {
  232. id: backup.id,
  233. name: 'Backup relay without auth',
  234. host: 'smtp.backup.example',
  235. username: '',
  236. password: ''
  237. });
  238. assert.equal(cleared.passwordSet, false);
  239. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, '');
  240. assert.equal(saveSmtpRelay(bob.id, {
  241. id: backup.id,
  242. name: 'Should not create',
  243. host: 'smtp.invalid.example'
  244. }), null);
  245. assert.equal(deleteSmtpRelay(backup.id, bob.id), false);
  246. assert.equal(deleteSmtpRelay(backup.id, alice.id), true);
  247. assert.equal(getSmtpRelay(backup.id, alice.id), null);
  248. });
  249. test('returns scoped send event detail with webhook deliveries', () => {
  250. initDatabase(tempDataDir(), 'test-secret');
  251. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  252. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  253. const domain = createDomain(alice.id, domainFixture('detail.example'));
  254. createWebhook(alice.id, {
  255. name: 'Delivery audit',
  256. url: 'https://hooks.example.com/mailhub',
  257. events: ['sent']
  258. });
  259. const eventId = logSendEvent({
  260. userId: alice.id,
  261. domainId: domain.id,
  262. sender: 'noreply@detail.example',
  263. recipients: ['user@example.com'],
  264. subject: 'Tracked message',
  265. status: 'sent',
  266. detail: '250 2.0.0 queued as QDETAIL',
  267. queueId: 'QDETAIL',
  268. deliveryAttempts: [{
  269. at: '2026-07-09T12:00:00.000Z',
  270. queueId: 'QDETAIL',
  271. recipient: 'user@example.com',
  272. relay: 'mx.example.net',
  273. dsn: '2.0.0',
  274. status: 'sent',
  275. response: '250 2.0.0 ok'
  276. }],
  277. deliveryLog: [{
  278. at: '2026-07-09T11:59:58.000Z',
  279. phase: 'queue',
  280. direction: 'server',
  281. response: '250 2.0.0 queued as QDETAIL',
  282. ok: true
  283. }],
  284. deliveredAt: '2026-07-09T12:00:00.000Z'
  285. });
  286. const detail = getSendEvent(alice.id, eventId);
  287. assert.equal(detail.id, eventId);
  288. assert.equal(detail.domain, 'detail.example');
  289. assert.equal(detail.queueId, 'QDETAIL');
  290. assert.equal(detail.deliveryAttempts[0].relay, 'mx.example.net');
  291. assert.equal(detail.deliveryLog[0].phase, 'queue');
  292. assert.equal(detail.webhookDeliveries.length, 1);
  293. assert.equal(detail.webhookDeliveries[0].sendEventId, eventId);
  294. assert.equal(detail.webhookDeliveries[0].eventType, 'sent');
  295. assert.equal(getSendEvent(bob.id, eventId), null);
  296. });
  297. test('send analytics includes delivery funnel and top failure reasons', () => {
  298. initDatabase(tempDataDir(), 'test-secret');
  299. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  300. const domain = createDomain(alice.id, domainFixture('analytics.example'));
  301. for (const [index, status] of ['sent', 'sent', 'queued'].entries()) {
  302. logSendEvent({
  303. userId: alice.id,
  304. domainId: domain.id,
  305. sender: 'noreply@analytics.example',
  306. recipients: ['user@example.com'],
  307. subject: `Message ${status}`,
  308. status,
  309. detail: status === 'queued' ? 'queued as QPENDING' : '250 2.0.0 ok',
  310. queueId: status === 'queued' ? 'QPENDING' : `QSENT${index + 1}`
  311. });
  312. }
  313. logSendEvent({
  314. userId: alice.id,
  315. domainId: domain.id,
  316. sender: 'noreply@analytics.example',
  317. recipients: ['bad@example.com'],
  318. subject: 'Bounce',
  319. status: 'bounced',
  320. detail: 'Mailbox unavailable',
  321. queueId: 'QBOUNCE'
  322. });
  323. logSendEvent({
  324. userId: alice.id,
  325. domainId: domain.id,
  326. sender: 'noreply@analytics.example',
  327. recipients: ['bad2@example.com'],
  328. subject: 'Failure',
  329. status: 'failed',
  330. detail: 'Mailbox unavailable'
  331. });
  332. const analytics = getSendAnalytics(alice.id, { days: 7 });
  333. assert.equal(analytics.summary.submitted, 5);
  334. assert.equal(analytics.summary.accepted, 4);
  335. assert.equal(analytics.summary.delivered, 2);
  336. assert.equal(analytics.summary.pending, 1);
  337. assert.equal(analytics.summary.failed, 2);
  338. assert.equal(analytics.summary.acceptanceRate, 80);
  339. assert.equal(analytics.summary.deliveryRate, 40);
  340. assert.equal(analytics.summary.failureRate, 40);
  341. assert.deepEqual(analytics.deliveryFunnel.map((item) => [item.stage, item.total, item.rate]), [
  342. ['submitted', 5, 100],
  343. ['accepted', 4, 80],
  344. ['delivered', 2, 40],
  345. ['pending', 1, 20],
  346. ['failed', 2, 40]
  347. ]);
  348. assert.equal(analytics.failureReasons[0].reason, 'Mailbox unavailable');
  349. assert.equal(analytics.failureReasons[0].total, 2);
  350. });
  351. test('stores account tokens as hashes and enforces token lifecycle', () => {
  352. const database = initDatabase(tempDataDir(), 'test-secret');
  353. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  354. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  355. const purpose = 'email_verification';
  356. const created = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  357. const expectedHash = createHash('sha256').update(created.token).digest('hex');
  358. assert.equal(created.userId, alice.id);
  359. assert.equal(created.purpose, purpose);
  360. assert.equal(typeof created.token, 'string');
  361. assert.equal(created.token.length > 32, true);
  362. assert.equal(created.usedAt, null);
  363. assert.equal('tokenHash' in created, false);
  364. const stored = database.prepare('SELECT * FROM account_tokens WHERE id = ?').get(created.id);
  365. assert.equal(stored.user_id, alice.id);
  366. assert.equal(stored.purpose, purpose);
  367. assert.equal(stored.token_hash, expectedHash);
  368. assert.notEqual(stored.token_hash, created.token);
  369. assert.equal(Object.values(stored).includes(created.token), false);
  370. assert.equal(consumeAccountToken(created.token, 'password_reset'), null);
  371. const consumed = consumeAccountToken(created.token, purpose);
  372. assert.equal(consumed.id, created.id);
  373. assert.equal(consumed.userId, alice.id);
  374. assert.equal(consumed.purpose, purpose);
  375. assert.equal(typeof consumed.usedAt, 'string');
  376. assert.equal('token' in consumed, false);
  377. assert.equal(consumeAccountToken(created.token, purpose), null);
  378. const expired = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  379. database
  380. .prepare('UPDATE account_tokens SET expires_at = ? WHERE id = ?')
  381. .run('2000-01-01T00:00:00.000Z', expired.id);
  382. assert.equal(consumeAccountToken(expired.token, purpose), null);
  383. const alreadyUsedReset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  384. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset').id, alreadyUsedReset.id);
  385. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  386. const otherPurpose = createAccountToken(alice.id, 'email_change', { ttlMinutes: 30 });
  387. const otherUserReset = createAccountToken(bob.id, 'password_reset', { ttlMinutes: 30 });
  388. assert.equal(invalidateAccountTokens(alice.id, 'password_reset'), 1);
  389. assert.equal(typeof database.prepare('SELECT used_at FROM account_tokens WHERE id = ?').get(reset.id).used_at, 'string');
  390. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  391. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset'), null);
  392. assert.equal(consumeAccountToken(otherPurpose.token, 'email_change').id, otherPurpose.id);
  393. assert.equal(consumeAccountToken(otherUserReset.token, 'password_reset').id, otherUserReset.id);
  394. });
  395. test('password updates invalidate unused password reset tokens only', () => {
  396. initDatabase(tempDataDir(), 'test-secret');
  397. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  398. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  399. const verification = createAccountToken(alice.id, 'email_verification', { ttlMinutes: 30 });
  400. updateUser(alice.id, { password: 'new-password-123' });
  401. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  402. assert.equal(consumeAccountToken(verification.token, 'email_verification').id, verification.id);
  403. assert.equal(authenticateUser('alice', 'password123'), null);
  404. assert.equal(authenticateUser('alice', 'new-password-123').id, alice.id);
  405. });
  406. test('validates account token ttl minute boundaries', () => {
  407. initDatabase(tempDataDir(), 'test-secret');
  408. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  409. const maxTtlMinutes = 7 * 24 * 60;
  410. for (const ttlMinutes of [0, -1, 1.5, maxTtlMinutes + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
  411. assert.throws(
  412. () => createAccountToken(alice.id, 'email_verification', { ttlMinutes }),
  413. /令牌有效期不正确。/
  414. );
  415. }
  416. assert.equal(createAccountToken(alice.id, 'email_verification', { ttlMinutes: 1 }).userId, alice.id);
  417. assert.equal(createAccountToken(alice.id, 'password_reset', { ttlMinutes: maxTtlMinutes }).userId, alice.id);
  418. });
  419. test('creates users with account tokens atomically', () => {
  420. const database = initDatabase(tempDataDir(), 'test-secret');
  421. assert.throws(
  422. () => createUserWithAccountToken({
  423. username: 'rollback',
  424. email: 'rollback@example.com',
  425. password: 'password123',
  426. status: 'pending_email'
  427. }, 'email_verification', { ttlMinutes: 0 }),
  428. /令牌有效期不正确。/
  429. );
  430. assert.equal(
  431. database
  432. .prepare("SELECT COUNT(*) AS count FROM users WHERE username = 'rollback' OR email = 'rollback@example.com'")
  433. .get()
  434. .count,
  435. 0
  436. );
  437. assert.equal(database.prepare('SELECT COUNT(*) AS count FROM account_tokens').get().count, 0);
  438. const { user, accountToken } = createUserWithAccountToken({
  439. username: 'atomic',
  440. email: 'atomic@example.com',
  441. password: 'password123',
  442. status: 'pending_email'
  443. }, 'email_verification', { ttlMinutes: 30 });
  444. assert.equal(user.status, 'pending_email');
  445. assert.equal(accountToken.userId, user.id);
  446. assert.equal(accountToken.purpose, 'email_verification');
  447. assert.equal(typeof accountToken.token, 'string');
  448. assert.equal('tokenHash' in accountToken, false);
  449. assert.equal(
  450. database
  451. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?')
  452. .get(user.id, 'email_verification')
  453. .count,
  454. 1
  455. );
  456. });
  457. test('moves users through the extended status lifecycle', () => {
  458. initDatabase(tempDataDir(), 'test-secret');
  459. const pendingEmail = createUser({
  460. username: 'pending',
  461. email: 'pending@example.com',
  462. password: 'password123',
  463. status: 'pending_email'
  464. });
  465. const seededAdmin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  466. assert.equal(pendingEmail.status, 'pending_email');
  467. assert.equal(seededAdmin.status, 'active');
  468. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  469. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  470. assert.equal(approveUser(pendingEmail.id).status, 'active');
  471. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'active');
  472. assert.equal(updateUserStatus(pendingEmail.id, 'disabled').status, 'disabled');
  473. assert.equal(updateUser(pendingEmail.id, { status: 'pending_review' }).status, 'pending_review');
  474. assert.equal(markUserEmailVerified(999999), null);
  475. assert.throws(() => updateUserStatus(pendingEmail.id, 'archived'), /用户状态不正确/);
  476. assert.throws(
  477. () => createUser({
  478. username: 'invalidstatus',
  479. email: 'invalidstatus@example.com',
  480. password: 'password123',
  481. status: 'archived'
  482. }),
  483. /用户状态不正确/
  484. );
  485. assert.equal(getUser(pendingEmail.id).status, 'pending_review');
  486. });
  487. test('lists users with owned resource counts', () => {
  488. initDatabase(tempDataDir(), 'test-secret');
  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. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  492. createDomain(alice.id, domainFixture('news.alice.example'));
  493. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  494. saveDnsCredential(alice.id, {
  495. name: 'Alice Cloudflare',
  496. provider: 'cloudflare',
  497. zoneName: 'alice.example',
  498. credentials: { apiToken: 'alice-secret-dns-token', zoneId: 'alice-zone' }
  499. });
  500. saveDnsCredential(alice.id, {
  501. name: 'Alice News Cloudflare',
  502. provider: 'cloudflare',
  503. zoneName: 'news.alice.example',
  504. credentials: { apiToken: 'alice-news-secret-dns-token', zoneId: 'alice-news-zone' }
  505. });
  506. createApiToken(alice.id, 'primary');
  507. createApiToken(alice.id, 'secondary');
  508. createApiToken(bob.id, 'primary');
  509. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  510. saveSmtpCredential(alice.id, { username: 'smtp-alice-app', password: 'smtp-secret-456' });
  511. logSendEvent({
  512. userId: alice.id,
  513. domainId: aliceDomain.id,
  514. sender: 'noreply@alice.example',
  515. recipients: ['one@example.com'],
  516. subject: 'First',
  517. status: 'queued'
  518. });
  519. logSendEvent({
  520. userId: alice.id,
  521. domainId: aliceDomain.id,
  522. sender: 'noreply@alice.example',
  523. recipients: ['two@example.com'],
  524. subject: 'Second',
  525. status: 'sent'
  526. });
  527. logSendEvent({
  528. userId: bob.id,
  529. domainId: bobDomain.id,
  530. sender: 'noreply@bob.example',
  531. recipients: ['bob@example.com'],
  532. subject: 'Bob',
  533. status: 'queued'
  534. });
  535. const users = listUsersWithResourceCounts();
  536. const aliceWithCounts = users.find((user) => user.id === alice.id);
  537. const bobWithCounts = users.find((user) => user.id === bob.id);
  538. assert.deepEqual(aliceWithCounts.resourceCounts, {
  539. domains: 2,
  540. dnsCredentials: 2,
  541. apiTokens: 2,
  542. sendEvents: 2,
  543. smtpCredential: 2
  544. });
  545. assert.deepEqual(bobWithCounts.resourceCounts, {
  546. domains: 1,
  547. dnsCredentials: 0,
  548. apiTokens: 1,
  549. sendEvents: 1,
  550. smtpCredential: 0
  551. });
  552. assert.equal(typeof aliceWithCounts.resourceCounts.domains, 'number');
  553. assert.equal('passwordHash' in aliceWithCounts, false);
  554. assert.equal(JSON.stringify(users).includes('alice-secret-dns-token'), false);
  555. assert.equal(JSON.stringify(users).includes('smtp-secret-123'), false);
  556. });
  557. test('builds admin resource inventory grouped by user with ownership warnings', () => {
  558. initDatabase(tempDataDir(), 'test-secret');
  559. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  560. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  561. const bobCredential = saveDnsCredential(bob.id, {
  562. name: 'Bob DNS',
  563. provider: 'cloudflare',
  564. zoneName: 'bob.example',
  565. credentials: { apiToken: 'bob-secret-token', zoneId: 'bob-zone' }
  566. });
  567. const aliceDomain = createDomain(alice.id, {
  568. ...domainFixture('alice.example'),
  569. dnsCredentialId: bobCredential.id
  570. });
  571. createDomain(bob.id, domainFixture('bob.example'));
  572. createApiToken(alice.id, 'primary');
  573. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  574. logSendEvent({
  575. userId: alice.id,
  576. domainId: aliceDomain.id,
  577. sender: 'noreply@alice.example',
  578. recipients: ['a@example.com'],
  579. subject: 'Queued',
  580. status: 'queued'
  581. });
  582. const inventory = getAdminResourceInventory();
  583. const aliceResources = inventory.users.find((entry) => entry.user.id === alice.id);
  584. const bobResources = inventory.users.find((entry) => entry.user.id === bob.id);
  585. assert.equal(aliceResources.domains.length, 1);
  586. assert.equal(aliceResources.domains[0].domain, 'alice.example');
  587. assert.equal(aliceResources.dnsCredentials.length, 0);
  588. assert.equal(aliceResources.apiTokens.length, 1);
  589. assert.equal(aliceResources.smtpCredential.username, 'smtp-alice');
  590. assert.equal(aliceResources.smtpCredential.passwordSet, true);
  591. assert.equal(aliceResources.sendEventCount, 1);
  592. assert.equal(bobResources.domains.length, 1);
  593. assert.equal(bobResources.dnsCredentials.length, 1);
  594. assert.deepEqual(inventory.warnings, [{
  595. type: 'domain_dns_credential_owner_mismatch',
  596. domainId: aliceDomain.id,
  597. domain: 'alice.example',
  598. domainUserId: alice.id,
  599. dnsCredentialId: bobCredential.id,
  600. dnsCredentialUserId: bob.id
  601. }]);
  602. assert.equal(JSON.stringify(inventory).includes('bob-secret-token'), false);
  603. assert.equal(JSON.stringify(inventory).includes('smtp-secret-123'), false);
  604. });
  605. test('transfers individual resources with audit logs', () => {
  606. initDatabase(tempDataDir(), 'test-secret');
  607. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  608. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  609. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  610. const disabled = createUser({ username: 'disabled', email: 'disabled@example.com', password: 'password123', status: 'disabled' });
  611. const domainOnlyCredential = saveDnsCredential(alice.id, {
  612. name: 'Alice DNS 1',
  613. provider: 'cloudflare',
  614. zoneName: 'alice.example',
  615. credentials: { apiToken: 'alice-secret-1' }
  616. });
  617. const clearCredential = saveDnsCredential(alice.id, {
  618. name: 'Alice DNS 2',
  619. provider: 'cloudflare',
  620. zoneName: 'clear.example',
  621. credentials: { apiToken: 'alice-secret-2' }
  622. });
  623. const withCredential = saveDnsCredential(alice.id, {
  624. name: 'Alice DNS 3',
  625. provider: 'cloudflare',
  626. zoneName: 'with.example',
  627. credentials: { apiToken: 'alice-secret-3' }
  628. });
  629. const standaloneCredential = saveDnsCredential(alice.id, {
  630. name: 'Alice DNS 4',
  631. provider: 'cloudflare',
  632. zoneName: 'standalone.example',
  633. credentials: { apiToken: 'alice-secret-4' }
  634. });
  635. const domainOnly = createDomain(alice.id, { ...domainFixture('domain-only.example'), dnsCredentialId: domainOnlyCredential.id });
  636. const clearDomain = createDomain(alice.id, { ...domainFixture('clear.example'), dnsCredentialId: clearCredential.id });
  637. const withDomain = createDomain(alice.id, { ...domainFixture('with.example'), dnsCredentialId: withCredential.id });
  638. const apiToken = createApiToken(alice.id, 'primary');
  639. assert.equal(transferDomain({
  640. actorUserId: admin.id,
  641. domainId: domainOnly.id,
  642. targetUserId: bob.id,
  643. dnsCredentialMode: 'domain_only'
  644. }).userId, bob.id);
  645. assert.equal(getDomain(domainOnly.id).dnsCredentialId, domainOnlyCredential.id);
  646. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  647. assert.throws(
  648. () => transferDomain({
  649. actorUserId: admin.id,
  650. domainId: domainOnly.id,
  651. targetUserId: bob.id,
  652. dnsCredentialMode: 'with_dns_credential'
  653. }),
  654. /DNS 凭据归属不一致。/
  655. );
  656. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  657. assert.equal(transferDomain({
  658. actorUserId: admin.id,
  659. domainId: clearDomain.id,
  660. targetUserId: bob.id,
  661. dnsCredentialMode: 'clear_dns_credential'
  662. }).dnsCredentialId, null);
  663. assert.equal(getDnsCredential(clearCredential.id, alice.id).id, clearCredential.id);
  664. assert.equal(transferDomain({
  665. actorUserId: admin.id,
  666. domainId: withDomain.id,
  667. targetUserId: bob.id,
  668. dnsCredentialMode: 'with_dns_credential'
  669. }).userId, bob.id);
  670. assert.equal(getDnsCredential(withCredential.id, bob.id).id, withCredential.id);
  671. assert.equal(transferDnsCredential({
  672. actorUserId: admin.id,
  673. credentialId: standaloneCredential.id,
  674. targetUserId: bob.id
  675. }).userId, bob.id);
  676. assert.equal(getDnsCredential(standaloneCredential.id, bob.id).id, standaloneCredential.id);
  677. const transferredTokens = transferApiTokens({
  678. actorUserId: admin.id,
  679. tokenIds: [apiToken.id],
  680. targetUserId: bob.id
  681. });
  682. assert.equal(transferredTokens.length, 1);
  683. assert.equal(verifyApiToken(apiToken.token).id, bob.id);
  684. assert.throws(
  685. () => transferDomain({ actorUserId: admin.id, domainId: domainOnly.id, targetUserId: disabled.id }),
  686. /目标用户不可用。/
  687. );
  688. assert.throws(
  689. () => transferApiTokens({ actorUserId: admin.id, tokenIds: [apiToken.id], targetUserId: 999999 }),
  690. /目标用户不可用。/
  691. );
  692. const actions = listAuditLogs({ actorUserId: admin.id }).map((entry) => entry.action);
  693. assert.ok(actions.includes('admin.transfer_domain'));
  694. assert.ok(actions.includes('admin.transfer_dns_credential'));
  695. assert.ok(actions.includes('admin.transfer_api_tokens'));
  696. assert.equal(JSON.stringify(listAuditLogs({ actorUserId: admin.id })).includes('alice-secret'), false);
  697. });
  698. test('previews and executes user merge with resource counts and multiple smtp credentials', () => {
  699. initDatabase(tempDataDir(), 'test-secret');
  700. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  701. const source = createUser({ username: 'source', email: 'source@example.com', password: 'password123' });
  702. const target = createUser({ username: 'target', email: 'target@example.com', password: 'password123' });
  703. const credential = saveDnsCredential(source.id, {
  704. name: 'Source DNS',
  705. provider: 'cloudflare',
  706. zoneName: 'source.example',
  707. credentials: { apiToken: 'source-secret' }
  708. });
  709. const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
  710. const apiToken = createApiToken(source.id, 'primary');
  711. saveSmtpCredential(source.id, { username: 'smtp-source', password: 'source-secret-123' });
  712. saveSmtpCredential(source.id, { username: 'smtp-source-app', password: 'source-secret-456' });
  713. saveSmtpCredential(target.id, { username: 'smtp-target', password: 'target-secret-123' });
  714. logSendEvent({
  715. userId: source.id,
  716. domainId: domain.id,
  717. sender: 'noreply@source.example',
  718. recipients: ['a@example.com'],
  719. subject: 'Queued',
  720. status: 'queued'
  721. });
  722. const preview = previewUserMerge({ sourceUserId: source.id, targetUserId: target.id });
  723. assert.equal(preview.confirmationText, 'MERGE source INTO target');
  724. assert.deepEqual(preview.counts, {
  725. domains: 1,
  726. dnsCredentials: 1,
  727. apiTokens: 1,
  728. sendEvents: 1,
  729. smtpCredential: 2
  730. });
  731. assert.equal(preview.resources.source.domains[0].domain, 'source.example');
  732. assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
  733. assert.equal(preview.resources.source.apiTokens[0].name, 'primary');
  734. assert.equal(preview.resources.source.sendEventCount, 1);
  735. assert.equal(preview.resources.target.domains.length, 0);
  736. assert.deepEqual(preview.selectedCounts, {
  737. domains: 1,
  738. dnsCredentials: 1,
  739. apiTokens: 1,
  740. sendEvents: 1,
  741. smtpCredential: 2
  742. });
  743. assert.equal(preview.smtp.conflict, false);
  744. assert.deepEqual(preview.warnings, []);
  745. assert.throws(
  746. () => executeUserMerge({
  747. actorUserId: admin.id,
  748. sourceUserId: source.id,
  749. targetUserId: target.id,
  750. confirmation: 'wrong'
  751. }),
  752. /确认文本不匹配。/
  753. );
  754. assert.equal(getDomain(domain.id).userId, source.id);
  755. const result = executeUserMerge({
  756. actorUserId: admin.id,
  757. sourceUserId: source.id,
  758. targetUserId: target.id,
  759. confirmation: preview.confirmationText
  760. });
  761. assert.deepEqual(result.counts, {
  762. domains: 1,
  763. dnsCredentials: 1,
  764. apiTokens: 1,
  765. sendEvents: 1,
  766. smtpCredential: 2
  767. });
  768. assert.equal(getDomain(domain.id).userId, target.id);
  769. assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);
  770. assert.equal(verifyApiToken(apiToken.token).id, target.id);
  771. assert.equal(listSendEvents(target.id).length, 1);
  772. assert.deepEqual(listSmtpCredentials(target.id).map((item) => item.username).sort(), ['smtp-source', 'smtp-source-app', 'smtp-target']);
  773. assert.deepEqual(listSmtpCredentials(source.id), []);
  774. assert.equal(getUser(source.id).status, 'disabled');
  775. const [audit] = listAuditLogs({ action: 'admin.user_merge' });
  776. assert.equal(audit.targetUserId, target.id);
  777. assert.equal(audit.summary.sourceUserId, source.id);
  778. assert.deepEqual(audit.summary.counts, result.counts);
  779. assert.equal(JSON.stringify(audit).includes('source-secret'), false);
  780. assert.equal(JSON.stringify(audit).includes('target-secret'), false);
  781. });
  782. test('stores system email settings without exposing smtp password publicly', () => {
  783. const database = initDatabase(tempDataDir(), 'test-secret');
  784. const saved = saveSystemEmailSettings({
  785. host: 'smtp.example.com',
  786. port: 465,
  787. secure: true,
  788. username: 'mailer@example.com',
  789. password: 'smtp-password-123',
  790. helo: 'mail.example.com',
  791. fromEmail: 'notify@example.com',
  792. fromName: 'MailHub Notify',
  793. testRecipient: 'admin@example.com'
  794. });
  795. assert.equal(saved.host, 'smtp.example.com');
  796. assert.equal(saved.port, 465);
  797. assert.equal(saved.secure, true);
  798. assert.equal(saved.passwordSet, true);
  799. assert.equal('password' in saved, false);
  800. assert.equal(JSON.stringify(saved).includes('smtp-password-123'), false);
  801. const storedRows = database.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'").all();
  802. assert.equal(storedRows.some((row) => row.value === 'smtp-password-123'), false);
  803. const publicSettings = getSystemEmailSettings();
  804. assert.equal(publicSettings.passwordSet, true);
  805. assert.equal('password' in publicSettings, false);
  806. assert.equal(JSON.stringify(publicSettings).includes('smtp-password-123'), false);
  807. const internalSettings = getSystemEmailSettings({ includeSecret: true });
  808. assert.equal(internalSettings.password, 'smtp-password-123');
  809. assert.equal(internalSettings.passwordSet, true);
  810. const unchangedPassword = saveSystemEmailSettings({
  811. host: 'smtp2.example.com',
  812. password: ''
  813. });
  814. assert.equal(unchangedPassword.host, 'smtp2.example.com');
  815. assert.equal(unchangedPassword.passwordSet, true);
  816. assert.equal(getSystemEmailSettings({ includeSecret: true }).password, 'smtp-password-123');
  817. });
  818. test('summarizes send analytics by user', () => {
  819. initDatabase(tempDataDir(), 'test-secret');
  820. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  821. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  822. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  823. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  824. logSendEvent({
  825. userId: alice.id,
  826. domainId: aliceDomain.id,
  827. sender: 'noreply@alice.example',
  828. recipients: ['a@example.com', 'b@example.com'],
  829. subject: 'Queued',
  830. status: 'queued'
  831. });
  832. logSendEvent({
  833. userId: alice.id,
  834. domainId: aliceDomain.id,
  835. sender: 'noreply@alice.example',
  836. recipients: ['c@example.com'],
  837. subject: 'Failed',
  838. status: 'failed',
  839. detail: 'relay rejected'
  840. });
  841. logSendEvent({
  842. userId: bob.id,
  843. domainId: bobDomain.id,
  844. sender: 'noreply@bob.example',
  845. recipients: ['x@example.com'],
  846. subject: 'Hidden',
  847. status: 'queued'
  848. });
  849. const analytics = getSendAnalytics(alice.id, { days: 7 });
  850. assert.equal(analytics.summary.total, 2);
  851. assert.equal(analytics.summary.queued, 1);
  852. assert.equal(analytics.summary.failed, 1);
  853. assert.equal(analytics.summary.recipients, 3);
  854. assert.equal(analytics.summary.successRate, 50);
  855. assert.equal(analytics.byDomain.length, 1);
  856. assert.equal(analytics.byDomain[0].domain, 'alice.example');
  857. assert.equal(analytics.recentFailures.length, 1);
  858. assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
  859. });
  860. test('excludes queued and sent messages from recent delivery failures', () => {
  861. initDatabase(tempDataDir(), 'test-secret');
  862. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  863. const domain = createDomain(alice.id, domainFixture('alice.example'));
  864. for (const event of [
  865. { subject: 'Queued', status: 'queued', detail: 'accepted by postfix' },
  866. { subject: 'Sent', status: 'sent', detail: '250 OK' },
  867. { subject: 'Deferred', status: 'deferred', detail: 'temporary failure' },
  868. { subject: 'Bounced', status: 'bounced', detail: '550 user unknown' },
  869. { subject: 'Failed', status: 'failed', detail: 'relay rejected' }
  870. ]) {
  871. logSendEvent({
  872. userId: alice.id,
  873. domainId: domain.id,
  874. sender: 'noreply@alice.example',
  875. recipients: ['user@example.com'],
  876. subject: event.subject,
  877. status: event.status,
  878. detail: event.detail
  879. });
  880. }
  881. const analytics = getSendAnalytics(alice.id, { days: 7 });
  882. assert.deepEqual(
  883. analytics.recentFailures.map((event) => event.subject),
  884. ['Failed', 'Bounced', 'Deferred']
  885. );
  886. });
  887. test('stores and returns structured delivery logs for send events', () => {
  888. initDatabase(tempDataDir(), 'test-secret');
  889. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  890. const domain = createDomain(alice.id, domainFixture('alice.example'));
  891. const deliveryLog = [
  892. {
  893. at: '2026-07-08T00:00:00.000Z',
  894. phase: 'connect',
  895. direction: 'system',
  896. message: 'Connected to relay.test:25',
  897. ok: true
  898. },
  899. {
  900. at: '2026-07-08T00:00:01.000Z',
  901. phase: 'queue',
  902. direction: 'server',
  903. code: 250,
  904. response: '250 queued as ABC123',
  905. ok: true
  906. }
  907. ];
  908. logSendEvent({
  909. userId: alice.id,
  910. domainId: domain.id,
  911. sender: 'noreply@alice.example',
  912. recipients: ['user@example.com'],
  913. subject: 'Delivery log',
  914. status: 'queued',
  915. detail: '250 queued as ABC123',
  916. deliveryLog
  917. });
  918. const [event] = listSendEvents(alice.id);
  919. assert.deepEqual(event.deliveryLog, deliveryLog);
  920. });
  921. test('records audit logs without storing secrets', () => {
  922. initDatabase(tempDataDir(), 'test-secret');
  923. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  924. const user = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  925. logAudit({
  926. actorUserId: admin.id,
  927. action: 'admin.temporary_password',
  928. targetType: 'user',
  929. targetId: String(user.id),
  930. targetUserId: user.id,
  931. summary: {
  932. username: user.username,
  933. temporaryPassword: 'secret-password',
  934. passwordSet: true,
  935. dkimPrivate: 'private-key',
  936. dkimPublic: 'public-key',
  937. dkim_private: 'private-key',
  938. dkim_public: 'public-key',
  939. authorization: 'Bearer top-level-token',
  940. headers: {
  941. authorization: 'Bearer nested-token',
  942. from: 'admin@example.com'
  943. },
  944. nested: {
  945. apiToken: 'secret-token',
  946. note: 'kept'
  947. },
  948. changes: [
  949. {
  950. credential: 'secret-credential',
  951. field: 'password'
  952. },
  953. {
  954. field: 'password',
  955. value: 'plain-secret',
  956. label: 'password change'
  957. },
  958. {
  959. name: 'apiToken',
  960. oldValue: 'old-token',
  961. newValue: 'new-token'
  962. },
  963. {
  964. header: 'authorization',
  965. value: 'Bearer token',
  966. status: 'set'
  967. },
  968. {
  969. path: 'smtp.password',
  970. from: 'old-password',
  971. to: 'new-password'
  972. },
  973. {
  974. key: 'credentials.accessKeySecret',
  975. before: 'old-secret',
  976. after: 'new-secret'
  977. },
  978. {
  979. field: 'displayName',
  980. value: 'Alice Example'
  981. },
  982. {
  983. field: 'password',
  984. old: 'old-secret',
  985. new: 'new-secret'
  986. },
  987. {
  988. change: { field: 'password' },
  989. value: 'plain-secret'
  990. },
  991. {
  992. context: { path: 'smtp.password' },
  993. from: 'old-wrapper-secret',
  994. to: 'new-wrapper-secret'
  995. },
  996. {
  997. change: { field: 'displayName' },
  998. value: 'Alice Wrapper'
  999. }
  1000. ]
  1001. }
  1002. });
  1003. const [entry] = listAuditLogs({ actorUserId: admin.id });
  1004. assert.equal(entry.action, 'admin.temporary_password');
  1005. assert.equal(entry.targetType, 'user');
  1006. assert.equal(entry.targetId, String(user.id));
  1007. assert.equal(entry.targetUserId, user.id);
  1008. assert.equal(entry.summary.username, 'alice');
  1009. assert.equal(entry.summary.temporaryPassword, undefined);
  1010. assert.equal(entry.summary.passwordSet, true);
  1011. assert.equal(entry.summary.dkimPrivate, undefined);
  1012. assert.equal(entry.summary.dkimPublic, 'public-key');
  1013. assert.equal(entry.summary.dkim_private, undefined);
  1014. assert.equal(entry.summary.dkim_public, 'public-key');
  1015. assert.equal(entry.summary.authorization, undefined);
  1016. assert.equal(entry.summary.headers.authorization, undefined);
  1017. assert.equal(entry.summary.headers.from, 'admin@example.com');
  1018. assert.equal(entry.summary.nested.apiToken, undefined);
  1019. assert.equal(entry.summary.nested.note, 'kept');
  1020. assert.equal(entry.summary.changes[0].credential, undefined);
  1021. assert.equal(entry.summary.changes[0].field, 'password');
  1022. assert.equal(entry.summary.changes[1].field, 'password');
  1023. assert.equal(entry.summary.changes[1].value, undefined);
  1024. assert.equal(entry.summary.changes[1].label, 'password change');
  1025. assert.equal(entry.summary.changes[2].name, 'apiToken');
  1026. assert.equal(entry.summary.changes[2].oldValue, undefined);
  1027. assert.equal(entry.summary.changes[2].newValue, undefined);
  1028. assert.equal(entry.summary.changes[3].header, 'authorization');
  1029. assert.equal(entry.summary.changes[3].value, undefined);
  1030. assert.equal(entry.summary.changes[3].status, 'set');
  1031. assert.equal(entry.summary.changes[4].path, 'smtp.password');
  1032. assert.equal(entry.summary.changes[4].from, undefined);
  1033. assert.equal(entry.summary.changes[4].to, undefined);
  1034. assert.equal(entry.summary.changes[5].key, undefined);
  1035. assert.equal(entry.summary.changes[5].before, undefined);
  1036. assert.equal(entry.summary.changes[5].after, undefined);
  1037. assert.equal(entry.summary.changes[6].field, 'displayName');
  1038. assert.equal(entry.summary.changes[6].value, 'Alice Example');
  1039. assert.equal(entry.summary.changes[7].field, 'password');
  1040. assert.equal(entry.summary.changes[7].old, undefined);
  1041. assert.equal(entry.summary.changes[7].new, undefined);
  1042. assert.deepEqual(entry.summary.changes[8].change, { field: 'password' });
  1043. assert.equal(entry.summary.changes[8].value, undefined);
  1044. assert.deepEqual(entry.summary.changes[9].context, { path: 'smtp.password' });
  1045. assert.equal(entry.summary.changes[9].from, undefined);
  1046. assert.equal(entry.summary.changes[9].to, undefined);
  1047. assert.deepEqual(entry.summary.changes[10].change, { field: 'displayName' });
  1048. assert.equal(entry.summary.changes[10].value, 'Alice Wrapper');
  1049. });
  1050. test('filters audit logs and returns newest entries first', () => {
  1051. initDatabase(tempDataDir(), 'test-secret');
  1052. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  1053. const alice = createUser({ username: 'alice2', email: 'alice2@example.com', password: 'password123' });
  1054. const bob = createUser({ username: 'bob2', email: 'bob2@example.com', password: 'password123' });
  1055. logAudit({
  1056. actorUserId: admin.id,
  1057. action: 'admin.disable_user',
  1058. targetType: 'user',
  1059. targetId: String(alice.id),
  1060. targetUserId: alice.id,
  1061. summary: { username: alice.username }
  1062. });
  1063. logAudit({
  1064. actorUserId: admin.id,
  1065. action: 'admin.reset_password',
  1066. targetType: 'user',
  1067. targetId: String(alice.id),
  1068. targetUserId: alice.id,
  1069. summary: { username: alice.username }
  1070. });
  1071. logAudit({
  1072. actorUserId: null,
  1073. action: 'system.rotation',
  1074. targetType: 'system',
  1075. summary: { reason: 'scheduled' }
  1076. });
  1077. logAudit({
  1078. actorUserId: admin.id,
  1079. action: 'admin.reset_password',
  1080. targetType: 'user',
  1081. targetId: String(bob.id),
  1082. targetUserId: bob.id,
  1083. summary: { username: bob.username }
  1084. });
  1085. assert.deepEqual(
  1086. listAuditLogs({ action: 'admin.reset_password' }).map((entry) => entry.summary.username),
  1087. ['bob2', 'alice2']
  1088. );
  1089. assert.deepEqual(
  1090. listAuditLogs({ targetUserId: alice.id }).map((entry) => entry.action),
  1091. ['admin.reset_password', 'admin.disable_user']
  1092. );
  1093. assert.deepEqual(
  1094. listAuditLogs({ actorUserId: null }).map((entry) => entry.action),
  1095. ['system.rotation']
  1096. );
  1097. const pastIso = '2000-01-01T00:00:00.000Z';
  1098. const futureIso = '2999-01-01T00:00:00.000Z';
  1099. assert.deepEqual(listAuditLogs({ from: futureIso }), []);
  1100. assert.deepEqual(listAuditLogs({ to: pastIso }), []);
  1101. assert.deepEqual(
  1102. listAuditLogs({ from: pastIso, to: futureIso }).map((entry) => entry.action),
  1103. ['admin.reset_password', 'system.rotation', 'admin.reset_password', 'admin.disable_user']
  1104. );
  1105. });
  1106. function domainFixture(domain) {
  1107. return {
  1108. domain,
  1109. selector: 'mh202607',
  1110. verificationToken: 'token',
  1111. dkimPublic: 'public',
  1112. dkimPrivate: 'private',
  1113. senderHost: `mail.${domain}`,
  1114. sendingIp: '127.0.0.1',
  1115. spfExtra: '',
  1116. dmarcPolicy: 'none',
  1117. dmarcRua: ''
  1118. };
  1119. }
  1120. function tempDataDir() {
  1121. return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
  1122. }