server-admin-api.test.js 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync, readdirSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import process from 'node:process';
  7. import { test } from 'node:test';
  8. import net from 'node:net';
  9. test('admin API routes respond once and keep the server alive', async () => {
  10. const port = await freePort();
  11. const child = spawn(process.execPath, ['src/server.js'], {
  12. cwd: process.cwd(),
  13. env: {
  14. ...process.env,
  15. PORT: String(port),
  16. DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
  17. ADMIN_PASSWORD: 'password123',
  18. SUBMISSION_ENABLED: 'false'
  19. },
  20. stdio: ['ignore', 'pipe', 'pipe']
  21. });
  22. try {
  23. await waitForOutput(child, 'MailHub listening');
  24. const baseUrl = `http://127.0.0.1:${port}`;
  25. const login = await fetch(`${baseUrl}/api/login`, {
  26. method: 'POST',
  27. headers: { 'Content-Type': 'application/json' },
  28. body: JSON.stringify({ username: 'admin', password: 'password123' })
  29. });
  30. assert.equal(login.status, 200);
  31. const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
  32. assert.ok(cookie);
  33. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  34. headers: { Cookie: cookie }
  35. });
  36. assert.equal(settings.status, 200);
  37. assert.equal((await settings.json()).settings.mailHostname, 'mailhub.local');
  38. const exited = await waitForExit(child, 300);
  39. assert.equal(exited, false);
  40. } finally {
  41. child.kill('SIGTERM');
  42. await waitForExit(child, 1000);
  43. }
  44. });
  45. test('built auth assets are served before authentication', async () => {
  46. const assetName = readdirSync(path.join(process.cwd(), 'public', 'assets')).find((name) => /\.(js|css)$/.test(name));
  47. assert.ok(assetName, 'expected at least one built frontend asset');
  48. const port = await freePort();
  49. const child = spawn(process.execPath, ['src/server.js'], {
  50. cwd: process.cwd(),
  51. env: {
  52. ...process.env,
  53. PORT: String(port),
  54. DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
  55. ADMIN_PASSWORD: 'password123',
  56. SUBMISSION_ENABLED: 'false'
  57. },
  58. stdio: ['ignore', 'pipe', 'pipe']
  59. });
  60. try {
  61. await waitForOutput(child, 'MailHub listening');
  62. const baseUrl = `http://127.0.0.1:${port}`;
  63. const login = await fetch(`${baseUrl}/login`);
  64. assert.equal(login.status, 200);
  65. const asset = await fetch(`${baseUrl}/assets/${assetName}`, { redirect: 'manual' });
  66. assert.equal(asset.status, 200);
  67. assert.notEqual(asset.headers.get('location'), '/login');
  68. } finally {
  69. child.kill('SIGTERM');
  70. await waitForExit(child, 1000);
  71. }
  72. });
  73. test('auth pages preserve query messages instead of redirecting them away', async () => {
  74. const { child, baseUrl } = await startTestServer();
  75. try {
  76. for (const pathname of ['/login?error=hello', '/reset-password?token=abc123']) {
  77. const response = await fetch(`${baseUrl}${pathname}`, { redirect: 'manual' });
  78. assert.equal(response.status, 200);
  79. assert.equal(response.headers.get('location'), null);
  80. assert.match(await response.text(), /auth-root/);
  81. }
  82. } finally {
  83. child.kill('SIGTERM');
  84. await waitForExit(child, 1000);
  85. }
  86. });
  87. test('admin users can list audit logs', async () => {
  88. const { child, baseUrl } = await startTestServer();
  89. try {
  90. const cookie = await login(baseUrl, 'admin', 'password123');
  91. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  92. headers: { Cookie: cookie }
  93. });
  94. assert.equal(response.status, 200);
  95. assert.deepEqual(await response.json(), { logs: [] });
  96. } finally {
  97. child.kill('SIGTERM');
  98. await waitForExit(child, 1000);
  99. }
  100. });
  101. test('admin users can list resource inventory', async () => {
  102. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  103. try {
  104. seedUsers(dataDir, sessionSecret, [{
  105. username: 'alice',
  106. email: 'alice@example.com',
  107. password: 'password123',
  108. status: 'active'
  109. }]);
  110. const adminCookie = await login(baseUrl, 'admin', 'password123');
  111. const userCookie = await login(baseUrl, 'alice', 'password123');
  112. const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
  113. headers: { Cookie: userCookie }
  114. });
  115. assert.equal(forbidden.status, 403);
  116. const response = await fetch(`${baseUrl}/api/admin/resources`, {
  117. headers: { Cookie: adminCookie }
  118. });
  119. assert.equal(response.status, 200);
  120. const body = await response.json();
  121. assert.ok(Array.isArray(body.inventory.users));
  122. assert.ok(Array.isArray(body.inventory.warnings));
  123. assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
  124. } finally {
  125. child.kill('SIGTERM');
  126. await waitForExit(child, 1000);
  127. }
  128. });
  129. test('admin users can transfer individual resources', async () => {
  130. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  131. try {
  132. const seeded = seedTransferResources(dataDir, sessionSecret);
  133. const adminCookie = await login(baseUrl, 'admin', 'password123');
  134. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  135. const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  136. method: 'POST',
  137. headers: {
  138. 'Content-Type': 'application/json',
  139. Cookie: aliceCookie
  140. },
  141. body: JSON.stringify({ targetUserId: seeded.bobId })
  142. });
  143. assert.equal(forbidden.status, 403);
  144. const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  145. method: 'POST',
  146. headers: {
  147. 'Content-Type': 'application/json',
  148. Cookie: adminCookie
  149. },
  150. body: JSON.stringify({
  151. targetUserId: seeded.bobId,
  152. dnsCredentialMode: 'with_dns_credential'
  153. })
  154. });
  155. assert.equal(domain.status, 200);
  156. const domainBody = await domain.json();
  157. assert.equal(domainBody.domain.userId, seeded.bobId);
  158. assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
  159. const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
  160. method: 'POST',
  161. headers: {
  162. 'Content-Type': 'application/json',
  163. Cookie: adminCookie
  164. },
  165. body: JSON.stringify({ targetUserId: seeded.bobId })
  166. });
  167. assert.equal(dns.status, 200);
  168. assert.equal((await dns.json()).credential.userId, seeded.bobId);
  169. const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
  170. method: 'POST',
  171. headers: {
  172. 'Content-Type': 'application/json',
  173. Cookie: adminCookie
  174. },
  175. body: JSON.stringify({
  176. targetUserId: seeded.bobId,
  177. tokenIds: [seeded.apiTokenId]
  178. })
  179. });
  180. assert.equal(tokens.status, 200);
  181. const tokensBody = await tokens.json();
  182. assert.equal(tokensBody.tokens.length, 1);
  183. assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
  184. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
  185. headers: { Cookie: adminCookie }
  186. });
  187. const actions = (await audit.json()).logs.map((entry) => entry.action);
  188. assert.ok(actions.includes('admin.transfer_domain'));
  189. assert.ok(actions.includes('admin.transfer_dns_credential'));
  190. assert.ok(actions.includes('admin.transfer_api_tokens'));
  191. } finally {
  192. child.kill('SIGTERM');
  193. await waitForExit(child, 1000);
  194. }
  195. });
  196. test('admin users can preview and execute user merge', async () => {
  197. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  198. try {
  199. const seeded = seedMergeResources(dataDir, sessionSecret);
  200. const adminCookie = await login(baseUrl, 'admin', 'password123');
  201. const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
  202. method: 'POST',
  203. headers: {
  204. 'Content-Type': 'application/json',
  205. Cookie: adminCookie
  206. },
  207. body: JSON.stringify({
  208. sourceUserId: seeded.sourceId,
  209. targetUserId: seeded.targetId
  210. })
  211. });
  212. assert.equal(previewResponse.status, 200);
  213. const preview = (await previewResponse.json()).preview;
  214. assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
  215. assert.equal(preview.counts.domains, 1);
  216. const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  217. method: 'POST',
  218. headers: {
  219. 'Content-Type': 'application/json',
  220. Cookie: adminCookie
  221. },
  222. body: JSON.stringify({
  223. sourceUserId: seeded.sourceId,
  224. targetUserId: seeded.targetId,
  225. confirmation: 'wrong'
  226. })
  227. });
  228. assert.equal(invalid.status, 400);
  229. const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  230. method: 'POST',
  231. headers: {
  232. 'Content-Type': 'application/json',
  233. Cookie: adminCookie
  234. },
  235. body: JSON.stringify({
  236. sourceUserId: seeded.sourceId,
  237. targetUserId: seeded.targetId,
  238. confirmation: preview.confirmationText
  239. })
  240. });
  241. assert.equal(execute.status, 200);
  242. const result = (await execute.json()).result;
  243. assert.equal(result.counts.domains, 1);
  244. assert.equal(result.sourceUser.status, 'disabled');
  245. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
  246. headers: { Cookie: adminCookie }
  247. });
  248. assert.equal(audit.status, 200);
  249. assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
  250. } finally {
  251. child.kill('SIGTERM');
  252. await waitForExit(child, 1000);
  253. }
  254. });
  255. test('admin users can manage system email settings without exposing password', async () => {
  256. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  257. try {
  258. seedUsers(dataDir, sessionSecret, [{
  259. username: 'alice',
  260. email: 'alice@example.com',
  261. password: 'password123',
  262. status: 'active'
  263. }]);
  264. const adminCookie = await login(baseUrl, 'admin', 'password123');
  265. const userCookie = await login(baseUrl, 'alice', 'password123');
  266. const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
  267. headers: { Cookie: userCookie }
  268. });
  269. assert.equal(forbidden.status, 403);
  270. const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
  271. headers: { Cookie: adminCookie }
  272. });
  273. assert.equal(empty.status, 200);
  274. assert.equal((await empty.json()).settings.passwordSet, false);
  275. const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
  276. method: 'PATCH',
  277. headers: {
  278. 'Content-Type': 'application/json',
  279. Cookie: adminCookie
  280. },
  281. body: JSON.stringify({
  282. host: 'smtp.example.com',
  283. port: 587,
  284. secure: false,
  285. username: 'mailer@example.com',
  286. password: 'smtp-password-123',
  287. helo: 'mail.example.com',
  288. fromEmail: 'notify@example.com',
  289. fromName: 'MailHub Notify',
  290. testRecipient: 'admin@example.com'
  291. })
  292. });
  293. assert.equal(saved.status, 200);
  294. const savedBody = await saved.json();
  295. assert.equal(savedBody.settings.host, 'smtp.example.com');
  296. assert.equal(savedBody.settings.port, 587);
  297. assert.equal(savedBody.settings.secure, false);
  298. assert.equal(savedBody.settings.passwordSet, true);
  299. assert.equal('password' in savedBody.settings, false);
  300. assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
  301. const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
  302. method: 'PATCH',
  303. headers: {
  304. 'Content-Type': 'application/json',
  305. Cookie: adminCookie
  306. },
  307. body: JSON.stringify({
  308. host: 'smtp2.example.com',
  309. password: ''
  310. })
  311. });
  312. assert.equal(preserved.status, 200);
  313. const preservedBody = await preserved.json();
  314. assert.equal(preservedBody.settings.host, 'smtp2.example.com');
  315. assert.equal(preservedBody.settings.passwordSet, true);
  316. assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
  317. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
  318. headers: { Cookie: adminCookie }
  319. });
  320. assert.equal(audit.status, 200);
  321. const [entry] = (await audit.json()).logs;
  322. assert.equal(entry.action, 'admin.update_system_email');
  323. assert.equal(entry.targetType, 'system_email');
  324. assert.equal(entry.summary.host, 'smtp2.example.com');
  325. assert.equal(entry.summary.password, undefined);
  326. assert.equal(entry.summary.passwordSet, true);
  327. } finally {
  328. child.kill('SIGTERM');
  329. await waitForExit(child, 1000);
  330. }
  331. });
  332. test('registration and verification resend use configured system email', async () => {
  333. const smtp = await startFakeSmtpServer();
  334. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  335. try {
  336. seedUsers(dataDir, sessionSecret, [
  337. { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
  338. { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
  339. ]);
  340. const adminCookie = await login(baseUrl, 'admin', 'password123');
  341. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  342. const register = await fetch(`${baseUrl}/api/register`, {
  343. method: 'POST',
  344. headers: { 'Content-Type': 'application/json' },
  345. body: JSON.stringify({
  346. username: 'mailuser',
  347. email: 'mailuser@example.com',
  348. password: 'password123'
  349. })
  350. });
  351. assert.equal(register.status, 201);
  352. const registerBody = await register.json();
  353. assert.equal(registerBody.user.status, 'pending_email');
  354. assert.equal(registerBody.verificationEmailSent, true);
  355. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
  356. const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  357. method: 'POST',
  358. headers: { 'Content-Type': 'application/json' },
  359. body: JSON.stringify({ email: 'publicpending@example.com' })
  360. });
  361. assert.equal(publicResend.status, 202);
  362. const publicResendBody = await publicResend.json();
  363. assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
  364. assert.equal('verificationEmailSent' in publicResendBody, false);
  365. assert.equal('result' in publicResendBody, false);
  366. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
  367. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  368. headers: { Cookie: adminCookie }
  369. });
  370. const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
  371. assert.ok(adminPending);
  372. const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
  373. method: 'POST',
  374. headers: { Cookie: adminCookie }
  375. });
  376. assert.equal(adminResend.status, 202);
  377. assert.equal((await adminResend.json()).verificationEmailSent, true);
  378. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
  379. assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
  380. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
  381. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
  382. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
  383. assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
  384. } finally {
  385. child.kill('SIGTERM');
  386. await waitForExit(child, 1000);
  387. await smtp.close();
  388. }
  389. });
  390. test('public verification resend is generic and does not create tokens without mail config', async () => {
  391. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  392. try {
  393. seedUsers(dataDir, sessionSecret, [{
  394. username: 'pendingnomail',
  395. email: 'pendingnomail@example.com',
  396. password: 'password123',
  397. status: 'pending_email'
  398. }]);
  399. const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  400. method: 'POST',
  401. headers: { 'Content-Type': 'application/json' },
  402. body: JSON.stringify({ email: 'pendingnomail@example.com' })
  403. });
  404. assert.equal(response.status, 202);
  405. assert.deepEqual(await response.json(), {
  406. message: '如果账号需要验证,我们会发送验证邮件。'
  407. });
  408. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
  409. } finally {
  410. child.kill('SIGTERM');
  411. await waitForExit(child, 1000);
  412. }
  413. });
  414. test('registration reports pending email when system email is not configured', async () => {
  415. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  416. try {
  417. const register = await fetch(`${baseUrl}/api/register`, {
  418. method: 'POST',
  419. headers: { 'Content-Type': 'application/json' },
  420. body: JSON.stringify({
  421. username: 'nomailuser',
  422. email: 'nomailuser@example.com',
  423. password: 'password123'
  424. })
  425. });
  426. assert.equal(register.status, 201);
  427. const body = await register.json();
  428. assert.equal(body.user.status, 'pending_email');
  429. assert.equal(body.verificationEmailSent, false);
  430. assert.match(body.message, /验证邮件暂未发送/);
  431. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
  432. } finally {
  433. child.kill('SIGTERM');
  434. await waitForExit(child, 1000);
  435. }
  436. });
  437. test('admin users can send system email test messages', async () => {
  438. const smtp = await startFakeSmtpServer();
  439. const { child, baseUrl } = await startTestServer();
  440. try {
  441. const adminCookie = await login(baseUrl, 'admin', 'password123');
  442. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  443. const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
  444. method: 'POST',
  445. headers: {
  446. 'Content-Type': 'application/json',
  447. Cookie: adminCookie
  448. },
  449. body: JSON.stringify({ to: 'operator@example.com' })
  450. });
  451. assert.equal(response.status, 202);
  452. const body = await response.json();
  453. assert.equal(body.result.ok, true);
  454. assert.equal(body.result.queueId, 'SYS123');
  455. assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
  456. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
  457. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
  458. headers: { Cookie: adminCookie }
  459. });
  460. assert.equal(audit.status, 200);
  461. const [entry] = (await audit.json()).logs;
  462. assert.equal(entry.targetType, 'system_email');
  463. assert.equal(entry.summary.to, 'operator@example.com');
  464. assert.equal(entry.summary.ok, true);
  465. } finally {
  466. child.kill('SIGTERM');
  467. await waitForExit(child, 1000);
  468. await smtp.close();
  469. }
  470. });
  471. test('public forgot password is generic and sends reset email when configured', async () => {
  472. const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
  473. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  474. try {
  475. seedUsers(dataDir, sessionSecret, [{
  476. username: 'resetme',
  477. email: 'resetme@example.com',
  478. password: 'password123',
  479. status: 'active'
  480. }]);
  481. const adminCookie = await login(baseUrl, 'admin', 'password123');
  482. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  483. const startedAt = Date.now();
  484. const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  485. method: 'POST',
  486. headers: { 'Content-Type': 'application/json' },
  487. body: JSON.stringify({ email: 'resetme@example.com' })
  488. });
  489. const elapsedMs = Date.now() - startedAt;
  490. assert.equal(existing.status, 202);
  491. assert.equal(elapsedMs < 500, true);
  492. assert.deepEqual(await existing.json(), {
  493. message: '如果邮箱存在,我们会发送密码重置邮件。'
  494. });
  495. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
  496. const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  497. method: 'POST',
  498. headers: { 'Content-Type': 'application/json' },
  499. body: JSON.stringify({ email: 'missing@example.com' })
  500. });
  501. assert.equal(missing.status, 202);
  502. assert.deepEqual(await missing.json(), {
  503. message: '如果邮箱存在,我们会发送密码重置邮件。'
  504. });
  505. await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
  506. } finally {
  507. child.kill('SIGTERM');
  508. await waitForExit(child, 1000);
  509. await smtp.close();
  510. }
  511. });
  512. test('public reset password consumes token and updates password', async () => {
  513. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  514. try {
  515. seedUsers(dataDir, sessionSecret, [{
  516. username: 'tokenreset',
  517. email: 'tokenreset@example.com',
  518. password: 'password123',
  519. status: 'active'
  520. }]);
  521. const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
  522. const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
  523. method: 'POST',
  524. headers: { 'Content-Type': 'application/json' },
  525. body: JSON.stringify({
  526. token,
  527. password: 'new-password-123'
  528. })
  529. });
  530. assert.equal(response.status, 200);
  531. assert.deepEqual(await response.json(), {
  532. message: '密码已重置,请使用新密码登录。'
  533. });
  534. const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
  535. assert.equal(oldLogin.status, 401);
  536. const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
  537. assert.equal(newLogin.status, 200);
  538. const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
  539. method: 'POST',
  540. headers: { 'Content-Type': 'application/json' },
  541. body: JSON.stringify({
  542. token,
  543. password: 'another-password-123'
  544. })
  545. });
  546. assert.equal(reused.status, 400);
  547. } finally {
  548. child.kill('SIGTERM');
  549. await waitForExit(child, 1000);
  550. }
  551. });
  552. test('admin users can trigger password reset email and set temporary password', async () => {
  553. const smtp = await startFakeSmtpServer();
  554. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  555. try {
  556. seedUsers(dataDir, sessionSecret, [
  557. { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
  558. { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
  559. ]);
  560. const adminCookie = await login(baseUrl, 'admin', 'password123');
  561. const memberCookie = await login(baseUrl, 'member2', 'password123');
  562. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  563. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  564. headers: { Cookie: adminCookie }
  565. });
  566. const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
  567. assert.ok(target);
  568. const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  569. method: 'POST',
  570. headers: { Cookie: memberCookie }
  571. });
  572. assert.equal(forbiddenReset.status, 403);
  573. const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  574. method: 'POST',
  575. headers: { Cookie: adminCookie }
  576. });
  577. assert.equal(reset.status, 202);
  578. assert.equal((await reset.json()).result.ok, true);
  579. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
  580. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
  581. const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  582. method: 'POST',
  583. headers: {
  584. 'Content-Type': 'application/json',
  585. Cookie: memberCookie
  586. },
  587. body: JSON.stringify({ password: 'temporary-123' })
  588. });
  589. assert.equal(forbiddenTemporary.status, 403);
  590. const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  591. method: 'POST',
  592. headers: {
  593. 'Content-Type': 'application/json',
  594. Cookie: adminCookie
  595. },
  596. body: JSON.stringify({ password: 'temporary-123' })
  597. });
  598. assert.equal(temporary.status, 200);
  599. assert.equal((await temporary.json()).user.id, target.id);
  600. assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
  601. const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
  602. assert.equal(oldLogin.status, 401);
  603. const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
  604. assert.equal(tempLogin.status, 200);
  605. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
  606. headers: { Cookie: adminCookie }
  607. });
  608. assert.equal(audit.status, 200);
  609. const logs = (await audit.json()).logs;
  610. assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
  611. const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
  612. assert.ok(temporaryLog);
  613. assert.equal(temporaryLog.summary.username, 'targetuser');
  614. assert.equal(temporaryLog.summary.password, undefined);
  615. assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
  616. } finally {
  617. child.kill('SIGTERM');
  618. await waitForExit(child, 1000);
  619. await smtp.close();
  620. }
  621. });
  622. test('non-admin users cannot list audit logs', async () => {
  623. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  624. try {
  625. seedUsers(dataDir, sessionSecret, [{
  626. username: 'alice',
  627. email: 'alice@example.com',
  628. password: 'password123',
  629. status: 'active'
  630. }]);
  631. const cookie = await login(baseUrl, 'alice', 'password123');
  632. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  633. headers: { Cookie: cookie }
  634. });
  635. assert.equal(response.status, 403);
  636. } finally {
  637. child.kill('SIGTERM');
  638. await waitForExit(child, 1000);
  639. }
  640. });
  641. test('admin user patch rejects invalid status with a bad request', async () => {
  642. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  643. try {
  644. seedUsers(dataDir, sessionSecret, [{
  645. username: 'badstatus',
  646. email: 'badstatus@example.com',
  647. password: 'password123',
  648. status: 'active'
  649. }]);
  650. const cookie = await login(baseUrl, 'admin', 'password123');
  651. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  652. headers: { Cookie: cookie }
  653. });
  654. assert.equal(usersResponse.status, 200);
  655. const usersBody = await usersResponse.json();
  656. const target = usersBody.users.find((user) => user.username === 'badstatus');
  657. assert.ok(target);
  658. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  659. method: 'PATCH',
  660. headers: {
  661. 'Content-Type': 'application/json',
  662. Cookie: cookie
  663. },
  664. body: JSON.stringify({ status: 'archived' })
  665. });
  666. assert.equal(response.status, 400);
  667. assert.equal((await response.json()).error, '用户状态不正确。');
  668. const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  669. method: 'PATCH',
  670. headers: {
  671. 'Content-Type': 'application/json',
  672. Cookie: cookie
  673. },
  674. body: JSON.stringify({ password: 'short' })
  675. });
  676. assert.equal(shortPassword.status, 400);
  677. assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
  678. } finally {
  679. child.kill('SIGTERM');
  680. await waitForExit(child, 1000);
  681. }
  682. });
  683. test('self registration creates a pending email user and verification token without a session', async () => {
  684. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  685. try {
  686. const register = await fetch(`${baseUrl}/api/register`, {
  687. method: 'POST',
  688. headers: { 'Content-Type': 'application/json' },
  689. body: JSON.stringify({
  690. username: 'newuser',
  691. email: 'newuser@example.com',
  692. password: 'password123'
  693. })
  694. });
  695. assert.equal(register.status, 201);
  696. assert.equal(sessionCookieFrom(register), '');
  697. const text = await register.text();
  698. assert.doesNotMatch(text, /token/i);
  699. const body = JSON.parse(text);
  700. assert.equal('token' in body, false);
  701. assert.equal('token' in body.user, false);
  702. assert.equal('tokenHash' in body.user, false);
  703. assert.equal(body.user.status, 'pending_email');
  704. assert.match(body.message, /验证邮箱/);
  705. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
  706. const login = await loginResponse(baseUrl, 'newuser', 'password123');
  707. assert.equal(login.status, 403);
  708. assert.equal(sessionCookieFrom(login), '');
  709. assert.equal((await login.json()).error, '请先验证邮箱。');
  710. } finally {
  711. child.kill('SIGTERM');
  712. await waitForExit(child, 1000);
  713. }
  714. });
  715. test('email verification route consumes token and moves user to admin review', async () => {
  716. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  717. try {
  718. const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  719. username: 'verifyme',
  720. email: 'verifyme@example.com',
  721. password: 'password123',
  722. status: 'pending_email'
  723. });
  724. const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
  725. assert.equal(missing.status, 400);
  726. assert.equal(sessionCookieFrom(missing), '');
  727. const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
  728. assert.equal(invalid.status, 400);
  729. assert.equal(sessionCookieFrom(invalid), '');
  730. const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  731. assert.equal(response.status, 200);
  732. assert.equal(sessionCookieFrom(response), '');
  733. const body = await response.json();
  734. assert.equal(body.user.id, created.user.id);
  735. assert.equal(body.user.status, 'pending_review');
  736. assert.match(body.message, /管理员审核/);
  737. const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  738. assert.equal(reused.status, 400);
  739. assert.equal(sessionCookieFrom(reused), '');
  740. const login = await loginResponse(baseUrl, 'verifyme', 'password123');
  741. assert.equal(login.status, 403);
  742. assert.equal(sessionCookieFrom(login), '');
  743. assert.equal((await login.json()).error, '账号正在等待管理员审核。');
  744. } finally {
  745. child.kill('SIGTERM');
  746. await waitForExit(child, 1000);
  747. }
  748. });
  749. test('admin users can approve pending review users with an audit log', async () => {
  750. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  751. try {
  752. seedUsers(dataDir, sessionSecret, [
  753. { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
  754. { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
  755. { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
  756. { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
  757. ]);
  758. const adminCookie = await login(baseUrl, 'admin', 'password123');
  759. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  760. headers: { Cookie: adminCookie }
  761. });
  762. assert.equal(usersResponse.status, 200);
  763. const users = (await usersResponse.json()).users;
  764. const target = users.find((user) => user.username === 'reviewme');
  765. const pendingEmail = users.find((user) => user.username === 'emailonly');
  766. const disabled = users.find((user) => user.username === 'disabledreview');
  767. assert.ok(target);
  768. assert.ok(pendingEmail);
  769. assert.ok(disabled);
  770. const memberCookie = await login(baseUrl, 'member', 'password123');
  771. const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  772. method: 'POST',
  773. headers: { Cookie: memberCookie }
  774. });
  775. assert.equal(nonAdmin.status, 403);
  776. const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
  777. method: 'POST',
  778. headers: { Cookie: adminCookie }
  779. });
  780. assert.equal(missing.status, 404);
  781. const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
  782. method: 'POST',
  783. headers: { Cookie: adminCookie }
  784. });
  785. assert.equal(pendingEmailResponse.status, 400);
  786. assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
  787. const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
  788. method: 'POST',
  789. headers: { Cookie: adminCookie }
  790. });
  791. assert.equal(disabledResponse.status, 400);
  792. assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
  793. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  794. method: 'POST',
  795. headers: { Cookie: adminCookie }
  796. });
  797. assert.equal(response.status, 200);
  798. const body = await response.json();
  799. assert.equal(body.user.id, target.id);
  800. assert.equal(body.user.status, 'active');
  801. const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
  802. assert.ok(approvedCookie);
  803. const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
  804. headers: { Cookie: adminCookie }
  805. });
  806. assert.equal(auditResponse.status, 200);
  807. const [entry] = (await auditResponse.json()).logs;
  808. assert.equal(entry.action, 'admin.approve_user');
  809. assert.equal(entry.targetType, 'user');
  810. assert.equal(entry.targetId, String(target.id));
  811. assert.equal(entry.targetUserId, target.id);
  812. assert.equal(entry.summary.username, 'reviewme');
  813. assert.equal(entry.summary.status, 'active');
  814. assert.equal(entry.summary.password, undefined);
  815. assert.equal(entry.summary.token, undefined);
  816. } finally {
  817. child.kill('SIGTERM');
  818. await waitForExit(child, 1000);
  819. }
  820. });
  821. test('login returns account status restrictions only after password verification', async () => {
  822. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  823. try {
  824. seedUsers(dataDir, sessionSecret, [
  825. { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
  826. { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
  827. { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
  828. { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
  829. ]);
  830. await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
  831. await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
  832. await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
  833. const active = await loginResponse(baseUrl, 'activeuser', 'password123');
  834. assert.equal(active.status, 200);
  835. assert.ok(sessionCookieFrom(active));
  836. } finally {
  837. child.kill('SIGTERM');
  838. await waitForExit(child, 1000);
  839. }
  840. });
  841. test('admin audit log actor filter rejects non-decimal user ids', async () => {
  842. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  843. try {
  844. seedAuditLogs(dataDir, sessionSecret);
  845. const cookie = await login(baseUrl, 'admin', 'password123');
  846. assert.deepEqual(
  847. await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
  848. ['audit.actor-one']
  849. );
  850. assert.deepEqual(
  851. await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
  852. ['audit.actor-one-hundred', 'audit.actor-one']
  853. );
  854. } finally {
  855. child.kill('SIGTERM');
  856. await waitForExit(child, 1000);
  857. }
  858. });
  859. test('admin audit log date filter ignores invalid dates', async () => {
  860. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  861. try {
  862. seedAuditLogs(dataDir, sessionSecret);
  863. const cookie = await login(baseUrl, 'admin', 'password123');
  864. assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
  865. assert.deepEqual(
  866. await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
  867. ['audit.actor-one-hundred', 'audit.actor-one']
  868. );
  869. } finally {
  870. child.kill('SIGTERM');
  871. await waitForExit(child, 1000);
  872. }
  873. });
  874. async function startTestServer() {
  875. const port = await freePort();
  876. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
  877. const sessionSecret = 'test-session-secret';
  878. const child = spawn(process.execPath, ['src/server.js'], {
  879. cwd: process.cwd(),
  880. env: {
  881. ...process.env,
  882. PORT: String(port),
  883. DATA_DIR: dataDir,
  884. ADMIN_PASSWORD: 'password123',
  885. SESSION_SECRET: sessionSecret,
  886. SUBMISSION_ENABLED: 'false'
  887. },
  888. stdio: ['ignore', 'pipe', 'pipe']
  889. });
  890. await waitForOutput(child, 'MailHub listening');
  891. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  892. }
  893. async function login(baseUrl, username, password) {
  894. const response = await loginResponse(baseUrl, username, password);
  895. assert.equal(response.status, 200);
  896. const cookie = sessionCookieFrom(response);
  897. assert.ok(cookie);
  898. return cookie;
  899. }
  900. function loginResponse(baseUrl, username, password) {
  901. return fetch(`${baseUrl}/api/login`, {
  902. method: 'POST',
  903. headers: { 'Content-Type': 'application/json' },
  904. body: JSON.stringify({ username, password })
  905. });
  906. }
  907. async function assertLoginDeniedByStatus(baseUrl, username, message) {
  908. const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
  909. assert.equal(wrongPassword.status, 401);
  910. assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
  911. assert.equal(sessionCookieFrom(wrongPassword), '');
  912. const correctPassword = await loginResponse(baseUrl, username, 'password123');
  913. assert.equal(correctPassword.status, 403);
  914. assert.equal((await correctPassword.json()).error, message);
  915. assert.equal(sessionCookieFrom(correctPassword), '');
  916. }
  917. function sessionCookieFrom(response) {
  918. return response.headers.get('set-cookie')?.split(';')[0] || '';
  919. }
  920. function seedUsers(dataDir, sessionSecret, users) {
  921. const script = `
  922. import { initDatabase, createUser } from './src/db.js';
  923. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  924. for (const user of JSON.parse(process.env.SEED_USERS)) {
  925. createUser(user);
  926. }
  927. `;
  928. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  929. cwd: process.cwd(),
  930. env: {
  931. ...process.env,
  932. DATA_DIR: dataDir,
  933. SESSION_SECRET: sessionSecret,
  934. SEED_USERS: JSON.stringify(users)
  935. },
  936. encoding: 'utf8'
  937. });
  938. assert.equal(result.status, 0, result.stderr || result.stdout);
  939. }
  940. function seedTransferResources(dataDir, sessionSecret) {
  941. const script = `
  942. import {
  943. initDatabase,
  944. createApiToken,
  945. createDomain,
  946. createUser,
  947. saveDnsCredential
  948. } from './src/db.js';
  949. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  950. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
  951. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
  952. const credential = saveDnsCredential(alice.id, {
  953. name: 'Alice DNS',
  954. provider: 'cloudflare',
  955. zoneName: 'alice.example',
  956. credentials: { apiToken: 'secret-token' }
  957. });
  958. const standaloneCredential = saveDnsCredential(alice.id, {
  959. name: 'Standalone DNS',
  960. provider: 'cloudflare',
  961. zoneName: 'standalone.example',
  962. credentials: { apiToken: 'standalone-secret-token' }
  963. });
  964. const domain = createDomain(alice.id, {
  965. dnsCredentialId: credential.id,
  966. domain: 'alice.example',
  967. selector: 'mh202607',
  968. verificationToken: 'token',
  969. dkimPublic: 'public',
  970. dkimPrivate: 'private',
  971. senderHost: 'mail.alice.example',
  972. sendingIp: '127.0.0.1',
  973. spfExtra: '',
  974. dmarcPolicy: 'none',
  975. dmarcRua: ''
  976. });
  977. const apiToken = createApiToken(alice.id, 'primary');
  978. console.log(JSON.stringify({
  979. aliceId: alice.id,
  980. bobId: bob.id,
  981. domainId: domain.id,
  982. credentialId: credential.id,
  983. standaloneCredentialId: standaloneCredential.id,
  984. apiTokenId: apiToken.id
  985. }));
  986. `;
  987. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  988. cwd: process.cwd(),
  989. env: {
  990. ...process.env,
  991. DATA_DIR: dataDir,
  992. SESSION_SECRET: sessionSecret
  993. },
  994. encoding: 'utf8'
  995. });
  996. assert.equal(result.status, 0, result.stderr || result.stdout);
  997. return JSON.parse(result.stdout);
  998. }
  999. function seedMergeResources(dataDir, sessionSecret) {
  1000. const script = `
  1001. import {
  1002. initDatabase,
  1003. createApiToken,
  1004. createDomain,
  1005. createUser,
  1006. logSendEvent,
  1007. saveDnsCredential
  1008. } from './src/db.js';
  1009. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1010. const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
  1011. const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
  1012. const credential = saveDnsCredential(source.id, {
  1013. name: 'Merge DNS',
  1014. provider: 'cloudflare',
  1015. zoneName: 'merge.example',
  1016. credentials: { apiToken: 'merge-secret-token' }
  1017. });
  1018. const domain = createDomain(source.id, {
  1019. dnsCredentialId: credential.id,
  1020. domain: 'merge.example',
  1021. selector: 'mh202607',
  1022. verificationToken: 'token',
  1023. dkimPublic: 'public',
  1024. dkimPrivate: 'private',
  1025. senderHost: 'mail.merge.example',
  1026. sendingIp: '127.0.0.1',
  1027. spfExtra: '',
  1028. dmarcPolicy: 'none',
  1029. dmarcRua: ''
  1030. });
  1031. createApiToken(source.id, 'primary');
  1032. logSendEvent({
  1033. userId: source.id,
  1034. domainId: domain.id,
  1035. sender: 'noreply@merge.example',
  1036. recipients: ['a@example.com'],
  1037. subject: 'Queued',
  1038. status: 'queued'
  1039. });
  1040. console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
  1041. `;
  1042. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1043. cwd: process.cwd(),
  1044. env: {
  1045. ...process.env,
  1046. DATA_DIR: dataDir,
  1047. SESSION_SECRET: sessionSecret
  1048. },
  1049. encoding: 'utf8'
  1050. });
  1051. assert.equal(result.status, 0, result.stderr || result.stdout);
  1052. return JSON.parse(result.stdout);
  1053. }
  1054. function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
  1055. const script = `
  1056. import { initDatabase, createUser, createAccountToken } from './src/db.js';
  1057. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1058. const user = createUser(JSON.parse(process.env.SEED_USER));
  1059. const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
  1060. console.log(JSON.stringify({ user, token: token.token }));
  1061. `;
  1062. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1063. cwd: process.cwd(),
  1064. env: {
  1065. ...process.env,
  1066. DATA_DIR: dataDir,
  1067. SESSION_SECRET: sessionSecret,
  1068. SEED_USER: JSON.stringify(user)
  1069. },
  1070. encoding: 'utf8'
  1071. });
  1072. assert.equal(result.status, 0, result.stderr || result.stdout);
  1073. return JSON.parse(result.stdout);
  1074. }
  1075. function createPasswordResetToken(dataDir, sessionSecret, username) {
  1076. const script = `
  1077. import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
  1078. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1079. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1080. const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
  1081. console.log(token.token);
  1082. `;
  1083. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1084. cwd: process.cwd(),
  1085. env: {
  1086. ...process.env,
  1087. DATA_DIR: dataDir,
  1088. SESSION_SECRET: sessionSecret,
  1089. TOKEN_USERNAME: username
  1090. },
  1091. encoding: 'utf8'
  1092. });
  1093. assert.equal(result.status, 0, result.stderr || result.stdout);
  1094. return result.stdout.trim();
  1095. }
  1096. function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1097. return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
  1098. }
  1099. function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1100. return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
  1101. }
  1102. function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
  1103. const script = `
  1104. import path from 'node:path';
  1105. import { DatabaseSync } from 'node:sqlite';
  1106. import { initDatabase, getUserByLogin } from './src/db.js';
  1107. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1108. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1109. const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1110. database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1111. const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
  1112. const row = user
  1113. ? database
  1114. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
  1115. .get(user.id, process.env.TOKEN_PURPOSE)
  1116. : { count: 0 };
  1117. console.log(String(row.count));
  1118. `;
  1119. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1120. cwd: process.cwd(),
  1121. env: {
  1122. ...process.env,
  1123. DATA_DIR: dataDir,
  1124. SESSION_SECRET: sessionSecret,
  1125. TOKEN_USERNAME: username,
  1126. TOKEN_PURPOSE: purpose,
  1127. TOKEN_UNUSED_ONLY: String(unusedOnly)
  1128. },
  1129. encoding: 'utf8'
  1130. });
  1131. assert.equal(result.status, 0, result.stderr || result.stdout);
  1132. return Number(result.stdout.trim());
  1133. }
  1134. function seedAuditLogs(dataDir, sessionSecret) {
  1135. const script = `
  1136. import path from 'node:path';
  1137. import { DatabaseSync } from 'node:sqlite';
  1138. import { initDatabase, logAudit } from './src/db.js';
  1139. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1140. const actorOneId = logAudit({
  1141. actorUserId: 1,
  1142. action: 'audit.actor-one',
  1143. targetType: 'system',
  1144. summary: { label: 'actor-one' }
  1145. });
  1146. const actorOneHundredId = logAudit({
  1147. actorUserId: 100,
  1148. action: 'audit.actor-one-hundred',
  1149. targetType: 'system',
  1150. summary: { label: 'actor-one-hundred' }
  1151. });
  1152. const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1153. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1154. const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
  1155. update.run('2026-02-01T00:00:00.000Z', actorOneId);
  1156. update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
  1157. `;
  1158. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1159. cwd: process.cwd(),
  1160. env: {
  1161. ...process.env,
  1162. DATA_DIR: dataDir,
  1163. SESSION_SECRET: sessionSecret
  1164. },
  1165. encoding: 'utf8'
  1166. });
  1167. assert.equal(result.status, 0, result.stderr || result.stdout);
  1168. }
  1169. async function auditLogActions(baseUrl, cookie, query) {
  1170. const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
  1171. headers: { Cookie: cookie }
  1172. });
  1173. assert.equal(response.status, 200);
  1174. const body = await response.json();
  1175. return body.logs.map((log) => log.action);
  1176. }
  1177. async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
  1178. const response = await fetch(`${baseUrl}/api/admin/system-email`, {
  1179. method: 'PATCH',
  1180. headers: {
  1181. 'Content-Type': 'application/json',
  1182. Cookie: cookie
  1183. },
  1184. body: JSON.stringify({
  1185. host: '127.0.0.1',
  1186. port: smtpPort,
  1187. secure: false,
  1188. username: 'mailer@example.com',
  1189. password: 'smtp-password-123',
  1190. helo: 'mail.example.com',
  1191. fromEmail: 'notify@example.com',
  1192. fromName: 'MailHub Notify',
  1193. testRecipient: 'admin@example.com'
  1194. })
  1195. });
  1196. assert.equal(response.status, 200);
  1197. }
  1198. function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
  1199. const commands = [];
  1200. const messages = [];
  1201. const server = net.createServer((socket) => {
  1202. socket.setEncoding('utf8');
  1203. writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
  1204. let buffer = '';
  1205. let dataMode = false;
  1206. let messageLines = [];
  1207. socket.on('data', (chunk) => {
  1208. buffer += chunk;
  1209. let index;
  1210. while ((index = buffer.indexOf('\n')) !== -1) {
  1211. const line = buffer.slice(0, index).replace(/\r$/, '');
  1212. buffer = buffer.slice(index + 1);
  1213. if (dataMode) {
  1214. if (line === '.') {
  1215. dataMode = false;
  1216. messages.push(messageLines.join('\n'));
  1217. messageLines = [];
  1218. writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
  1219. } else {
  1220. messageLines.push(line);
  1221. }
  1222. continue;
  1223. }
  1224. commands.push(line);
  1225. if (line.startsWith('EHLO')) {
  1226. writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
  1227. } else if (line.startsWith('AUTH PLAIN')) {
  1228. writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
  1229. } else if (line.startsWith('MAIL FROM')) {
  1230. writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
  1231. } else if (line.startsWith('RCPT TO')) {
  1232. writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
  1233. } else if (line === 'DATA') {
  1234. dataMode = true;
  1235. writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
  1236. } else if (line === 'QUIT') {
  1237. writeSmtpResponse(socket, '221 bye', responseDelayMs);
  1238. socket.end();
  1239. }
  1240. }
  1241. });
  1242. });
  1243. return new Promise((resolve, reject) => {
  1244. server.once('error', reject);
  1245. server.listen(0, '127.0.0.1', () => {
  1246. server.off('error', reject);
  1247. resolve({
  1248. port: server.address().port,
  1249. commands,
  1250. messages,
  1251. close: () => new Promise((closeResolve) => server.close(closeResolve))
  1252. });
  1253. });
  1254. });
  1255. }
  1256. function writeSmtpResponse(socket, response, delayMs) {
  1257. const write = () => socket.write(`${response}\r\n`);
  1258. if (delayMs > 0) setTimeout(write, delayMs);
  1259. else write();
  1260. }
  1261. async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
  1262. const startedAt = Date.now();
  1263. while (Date.now() - startedAt < timeoutMs) {
  1264. if (predicate()) return;
  1265. await new Promise((resolve) => setTimeout(resolve, intervalMs));
  1266. }
  1267. assert.fail('Timed out waiting for condition.');
  1268. }
  1269. function freePort() {
  1270. return new Promise((resolve, reject) => {
  1271. const server = net.createServer();
  1272. server.listen(0, '127.0.0.1', () => {
  1273. const address = server.address();
  1274. server.close(() => {
  1275. if (address && typeof address === 'object') resolve(address.port);
  1276. else reject(new Error('Unable to allocate a test port.'));
  1277. });
  1278. });
  1279. });
  1280. }
  1281. function waitForOutput(child, text) {
  1282. return new Promise((resolve, reject) => {
  1283. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  1284. const chunks = [];
  1285. const onData = (chunk) => {
  1286. chunks.push(String(chunk));
  1287. if (chunks.join('').includes(text)) {
  1288. clearTimeout(timeout);
  1289. child.stdout.off('data', onData);
  1290. child.stderr.off('data', onData);
  1291. resolve();
  1292. }
  1293. };
  1294. child.stdout.on('data', onData);
  1295. child.stderr.on('data', onData);
  1296. child.once('exit', (code) => {
  1297. clearTimeout(timeout);
  1298. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  1299. });
  1300. });
  1301. }
  1302. function waitForExit(child, timeoutMs) {
  1303. if (child.exitCode !== null) return Promise.resolve(true);
  1304. return new Promise((resolve) => {
  1305. const timeout = setTimeout(() => {
  1306. child.off('exit', onExit);
  1307. resolve(false);
  1308. }, timeoutMs);
  1309. const onExit = () => {
  1310. clearTimeout(timeout);
  1311. resolve(true);
  1312. };
  1313. child.once('exit', onExit);
  1314. });
  1315. }