db.test.js 39 KB

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