server-admin-api.test.js 64 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836
  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('users can manage multiple smtp login credentials', async () => {
  88. const { child, baseUrl } = await startTestServer();
  89. try {
  90. const cookie = await login(baseUrl, 'admin', 'password123');
  91. const first = await createSmtpCredential(baseUrl, cookie, {
  92. username: 'admin-smtp-main',
  93. password: 'main-secret'
  94. });
  95. const second = await createSmtpCredential(baseUrl, cookie, {
  96. username: 'admin-smtp-app',
  97. password: 'app-secret'
  98. });
  99. assert.equal(first.username, 'admin-smtp-main');
  100. assert.equal(first.password, 'main-secret');
  101. assert.equal(second.username, 'admin-smtp-app');
  102. const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
  103. assert.equal(list.status, 200);
  104. const listPayload = await list.json();
  105. assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
  106. assert.equal(listPayload.credentials[0].password, 'app-secret');
  107. const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
  108. method: 'PATCH',
  109. headers: {
  110. 'Content-Type': 'application/json',
  111. Cookie: cookie
  112. },
  113. body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
  114. });
  115. assert.equal(update.status, 200);
  116. assert.equal((await update.json()).credential.password, 'app-secret');
  117. const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
  118. method: 'DELETE',
  119. headers: { Cookie: cookie }
  120. });
  121. assert.equal(deleted.status, 200);
  122. assert.equal((await deleted.json()).deleted, true);
  123. } finally {
  124. child.kill('SIGTERM');
  125. await waitForExit(child, 1000);
  126. }
  127. });
  128. test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
  129. const relayServer = await startFakeSmtpServer();
  130. const { child, baseUrl } = await startTestServer();
  131. try {
  132. const cookie = await login(baseUrl, 'admin', 'password123');
  133. const domainResponse = await fetch(`${baseUrl}/api/domains`, {
  134. method: 'POST',
  135. headers: {
  136. 'Content-Type': 'application/json',
  137. Cookie: cookie
  138. },
  139. body: JSON.stringify({
  140. domain: 'relay.example',
  141. selector: 'mh',
  142. senderHost: 'mail.relay.example',
  143. sendingIp: '127.0.0.1'
  144. })
  145. });
  146. assert.equal(domainResponse.status, 201);
  147. const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, {
  148. method: 'POST',
  149. headers: {
  150. 'Content-Type': 'application/json',
  151. Cookie: cookie
  152. },
  153. body: JSON.stringify({
  154. name: 'Primary outbound',
  155. host: '127.0.0.1',
  156. port: relayServer.port,
  157. secure: false,
  158. username: 'relay-user',
  159. password: 'relay-password',
  160. helo: 'helo.relay.example',
  161. isDefault: true
  162. })
  163. });
  164. assert.equal(createRelay.status, 201);
  165. const created = await createRelay.json();
  166. assert.equal(created.relay.passwordSet, true);
  167. assert.equal('password' in created.relay, false);
  168. const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } });
  169. assert.equal(list.status, 200);
  170. const listed = await list.json();
  171. assert.equal(listed.relays.length, 1);
  172. assert.equal('password' in listed.relays[0], false);
  173. const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  174. assert.equal(detail.status, 200);
  175. const detailBody = await detail.json();
  176. assert.equal(detailBody.relay.password, 'relay-password');
  177. assert.equal('passwordSecret' in detailBody.relay, false);
  178. const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
  179. method: 'PATCH',
  180. headers: {
  181. 'Content-Type': 'application/json',
  182. Cookie: cookie
  183. },
  184. body: JSON.stringify({
  185. name: 'Missing relay',
  186. host: '127.0.0.1',
  187. port: relayServer.port,
  188. secure: false,
  189. username: 'missing-user',
  190. password: 'missing-password'
  191. })
  192. });
  193. assert.equal(missingPatch.status, 404);
  194. const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, {
  195. method: 'PATCH',
  196. headers: {
  197. 'Content-Type': 'application/json',
  198. Cookie: cookie
  199. },
  200. body: JSON.stringify({
  201. name: 'Primary outbound renamed',
  202. host: '127.0.0.1',
  203. port: relayServer.port,
  204. secure: false,
  205. username: 'relay-user'
  206. })
  207. });
  208. assert.equal(updateWithoutPassword.status, 200);
  209. assert.equal((await updateWithoutPassword.json()).relay.isDefault, true);
  210. const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  211. assert.equal((await detailAfterPatch.json()).relay.password, 'relay-password');
  212. const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
  213. method: 'POST',
  214. headers: {
  215. 'Content-Type': 'application/json',
  216. Cookie: cookie
  217. },
  218. body: JSON.stringify({
  219. from: 'noreply@relay.example',
  220. to: 'user@example.com',
  221. subject: 'Invalid relay',
  222. text: 'hello',
  223. smtpRelayId: 999999
  224. })
  225. });
  226. assert.equal(invalidRelaySend.status, 400);
  227. const send = await fetch(`${baseUrl}/api/send`, {
  228. method: 'POST',
  229. headers: {
  230. 'Content-Type': 'application/json',
  231. Cookie: cookie
  232. },
  233. body: JSON.stringify({
  234. from: 'noreply@relay.example',
  235. to: 'user@example.com',
  236. subject: 'Relay send',
  237. text: 'hello',
  238. smtpRelayId: created.relay.id
  239. })
  240. });
  241. assert.equal(send.status, 202);
  242. assert.equal((await send.json()).smtpRelayId, created.relay.id);
  243. await waitForCondition(() => relayServer.messages.length === 1);
  244. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  245. assert.ok(authCommand);
  246. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password');
  247. const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
  248. assert.equal(events.status, 200);
  249. assert.equal((await events.json()).events[0].smtpRelayId, created.relay.id);
  250. } finally {
  251. child.kill('SIGTERM');
  252. await waitForExit(child, 1000);
  253. await relayServer.close();
  254. }
  255. });
  256. test('smtp relay selection prefers request relay then domain relay then user default relay', async () => {
  257. const requestRelayServer = await startFakeSmtpServer();
  258. const domainRelayServer = await startFakeSmtpServer();
  259. const defaultRelayServer = await startFakeSmtpServer();
  260. const { child, baseUrl } = await startTestServer();
  261. try {
  262. const cookie = await login(baseUrl, 'admin', 'password123');
  263. const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
  264. name: 'Default relay',
  265. host: '127.0.0.1',
  266. port: defaultRelayServer.port,
  267. username: 'default-user',
  268. password: 'default-password',
  269. isDefault: true
  270. });
  271. const domainRelay = await createSmtpRelay(baseUrl, cookie, {
  272. name: 'Domain relay',
  273. host: '127.0.0.1',
  274. port: domainRelayServer.port,
  275. username: 'domain-user',
  276. password: 'domain-password'
  277. });
  278. const requestRelay = await createSmtpRelay(baseUrl, cookie, {
  279. name: 'Request relay',
  280. host: '127.0.0.1',
  281. port: requestRelayServer.port,
  282. username: 'request-user',
  283. password: 'request-password'
  284. });
  285. const domain = await createSendingDomain(baseUrl, cookie, {
  286. domain: 'relay-order.example',
  287. smtpRelayId: domainRelay.id
  288. });
  289. assert.equal(domain.smtpRelayId, domainRelay.id);
  290. const domainSend = await sendApiMail(baseUrl, cookie, {
  291. from: 'noreply@relay-order.example',
  292. to: 'domain@example.com',
  293. subject: 'Domain relay'
  294. });
  295. assert.equal(domainSend.smtpRelayId, domainRelay.id);
  296. await waitForCondition(() => domainRelayServer.messages.length === 1);
  297. assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password');
  298. const requestSend = await sendApiMail(baseUrl, cookie, {
  299. from: 'noreply@relay-order.example',
  300. to: 'request@example.com',
  301. subject: 'Request relay',
  302. smtpRelayId: requestRelay.id
  303. });
  304. assert.equal(requestSend.smtpRelayId, requestRelay.id);
  305. await waitForCondition(() => requestRelayServer.messages.length === 1);
  306. assertRelayAuth(requestRelayServer, 'request-user', 'request-password');
  307. const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  308. method: 'POST',
  309. headers: {
  310. 'Content-Type': 'application/json',
  311. Cookie: cookie
  312. },
  313. body: JSON.stringify({
  314. to: 'test-send@example.com',
  315. subject: 'Selected relay test send',
  316. smtpRelayId: requestRelay.id
  317. })
  318. });
  319. assert.equal(testSend.status, 202);
  320. assert.equal((await testSend.json()).smtpRelayId, requestRelay.id);
  321. await waitForCondition(() => requestRelayServer.messages.length === 2);
  322. const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  323. method: 'POST',
  324. headers: {
  325. 'Content-Type': 'application/json',
  326. Cookie: cookie
  327. },
  328. body: JSON.stringify({
  329. to: 'invalid-test-send@example.com',
  330. smtpRelayId: 999999
  331. })
  332. });
  333. assert.equal(invalidTestSend.status, 400);
  334. const defaultDomain = await createSendingDomain(baseUrl, cookie, {
  335. domain: 'default-relay.example'
  336. });
  337. assert.equal(defaultDomain.smtpRelayId, null);
  338. const defaultSend = await sendApiMail(baseUrl, cookie, {
  339. from: 'noreply@default-relay.example',
  340. to: 'default@example.com',
  341. subject: 'Default relay'
  342. });
  343. assert.equal(defaultSend.smtpRelayId, defaultRelay.id);
  344. await waitForCondition(() => defaultRelayServer.messages.length === 1);
  345. assertRelayAuth(defaultRelayServer, 'default-user', 'default-password');
  346. } finally {
  347. child.kill('SIGTERM');
  348. await waitForExit(child, 1000);
  349. await requestRelayServer.close();
  350. await domainRelayServer.close();
  351. await defaultRelayServer.close();
  352. }
  353. });
  354. test('admin users can list audit logs', async () => {
  355. const { child, baseUrl } = await startTestServer();
  356. try {
  357. const cookie = await login(baseUrl, 'admin', 'password123');
  358. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  359. headers: { Cookie: cookie }
  360. });
  361. assert.equal(response.status, 200);
  362. assert.deepEqual(await response.json(), { logs: [] });
  363. } finally {
  364. child.kill('SIGTERM');
  365. await waitForExit(child, 1000);
  366. }
  367. });
  368. test('admin users can list resource inventory', async () => {
  369. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  370. try {
  371. seedUsers(dataDir, sessionSecret, [{
  372. username: 'alice',
  373. email: 'alice@example.com',
  374. password: 'password123',
  375. status: 'active'
  376. }]);
  377. const adminCookie = await login(baseUrl, 'admin', 'password123');
  378. const userCookie = await login(baseUrl, 'alice', 'password123');
  379. const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
  380. headers: { Cookie: userCookie }
  381. });
  382. assert.equal(forbidden.status, 403);
  383. const response = await fetch(`${baseUrl}/api/admin/resources`, {
  384. headers: { Cookie: adminCookie }
  385. });
  386. assert.equal(response.status, 200);
  387. const body = await response.json();
  388. assert.ok(Array.isArray(body.inventory.users));
  389. assert.ok(Array.isArray(body.inventory.warnings));
  390. assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
  391. } finally {
  392. child.kill('SIGTERM');
  393. await waitForExit(child, 1000);
  394. }
  395. });
  396. test('admin users can transfer individual resources', async () => {
  397. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  398. try {
  399. const seeded = seedTransferResources(dataDir, sessionSecret);
  400. const adminCookie = await login(baseUrl, 'admin', 'password123');
  401. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  402. const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  403. method: 'POST',
  404. headers: {
  405. 'Content-Type': 'application/json',
  406. Cookie: aliceCookie
  407. },
  408. body: JSON.stringify({ targetUserId: seeded.bobId })
  409. });
  410. assert.equal(forbidden.status, 403);
  411. const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  412. method: 'POST',
  413. headers: {
  414. 'Content-Type': 'application/json',
  415. Cookie: adminCookie
  416. },
  417. body: JSON.stringify({
  418. targetUserId: seeded.bobId,
  419. dnsCredentialMode: 'with_dns_credential'
  420. })
  421. });
  422. assert.equal(domain.status, 200);
  423. const domainBody = await domain.json();
  424. assert.equal(domainBody.domain.userId, seeded.bobId);
  425. assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
  426. const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
  427. method: 'POST',
  428. headers: {
  429. 'Content-Type': 'application/json',
  430. Cookie: adminCookie
  431. },
  432. body: JSON.stringify({ targetUserId: seeded.bobId })
  433. });
  434. assert.equal(dns.status, 200);
  435. assert.equal((await dns.json()).credential.userId, seeded.bobId);
  436. const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
  437. method: 'POST',
  438. headers: {
  439. 'Content-Type': 'application/json',
  440. Cookie: adminCookie
  441. },
  442. body: JSON.stringify({
  443. targetUserId: seeded.bobId,
  444. tokenIds: [seeded.apiTokenId]
  445. })
  446. });
  447. assert.equal(tokens.status, 200);
  448. const tokensBody = await tokens.json();
  449. assert.equal(tokensBody.tokens.length, 1);
  450. assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
  451. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
  452. headers: { Cookie: adminCookie }
  453. });
  454. const actions = (await audit.json()).logs.map((entry) => entry.action);
  455. assert.ok(actions.includes('admin.transfer_domain'));
  456. assert.ok(actions.includes('admin.transfer_dns_credential'));
  457. assert.ok(actions.includes('admin.transfer_api_tokens'));
  458. } finally {
  459. child.kill('SIGTERM');
  460. await waitForExit(child, 1000);
  461. }
  462. });
  463. test('admin users can preview and execute user merge', async () => {
  464. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  465. try {
  466. const seeded = seedMergeResources(dataDir, sessionSecret);
  467. const adminCookie = await login(baseUrl, 'admin', 'password123');
  468. const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
  469. method: 'POST',
  470. headers: {
  471. 'Content-Type': 'application/json',
  472. Cookie: adminCookie
  473. },
  474. body: JSON.stringify({
  475. sourceUserId: seeded.sourceId,
  476. targetUserId: seeded.targetId
  477. })
  478. });
  479. assert.equal(previewResponse.status, 200);
  480. const preview = (await previewResponse.json()).preview;
  481. assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
  482. assert.equal(preview.counts.domains, 1);
  483. const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  484. method: 'POST',
  485. headers: {
  486. 'Content-Type': 'application/json',
  487. Cookie: adminCookie
  488. },
  489. body: JSON.stringify({
  490. sourceUserId: seeded.sourceId,
  491. targetUserId: seeded.targetId,
  492. confirmation: 'wrong'
  493. })
  494. });
  495. assert.equal(invalid.status, 400);
  496. const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  497. method: 'POST',
  498. headers: {
  499. 'Content-Type': 'application/json',
  500. Cookie: adminCookie
  501. },
  502. body: JSON.stringify({
  503. sourceUserId: seeded.sourceId,
  504. targetUserId: seeded.targetId,
  505. confirmation: preview.confirmationText
  506. })
  507. });
  508. assert.equal(execute.status, 200);
  509. const result = (await execute.json()).result;
  510. assert.equal(result.counts.domains, 1);
  511. assert.equal(result.sourceUser.status, 'disabled');
  512. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
  513. headers: { Cookie: adminCookie }
  514. });
  515. assert.equal(audit.status, 200);
  516. assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
  517. } finally {
  518. child.kill('SIGTERM');
  519. await waitForExit(child, 1000);
  520. }
  521. });
  522. test('admin users can manage system email settings without exposing password', async () => {
  523. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  524. try {
  525. seedUsers(dataDir, sessionSecret, [{
  526. username: 'alice',
  527. email: 'alice@example.com',
  528. password: 'password123',
  529. status: 'active'
  530. }]);
  531. const adminCookie = await login(baseUrl, 'admin', 'password123');
  532. const userCookie = await login(baseUrl, 'alice', 'password123');
  533. const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
  534. headers: { Cookie: userCookie }
  535. });
  536. assert.equal(forbidden.status, 403);
  537. const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
  538. headers: { Cookie: adminCookie }
  539. });
  540. assert.equal(empty.status, 200);
  541. assert.equal((await empty.json()).settings.passwordSet, false);
  542. const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
  543. method: 'PATCH',
  544. headers: {
  545. 'Content-Type': 'application/json',
  546. Cookie: adminCookie
  547. },
  548. body: JSON.stringify({
  549. host: 'smtp.example.com',
  550. port: 587,
  551. secure: false,
  552. username: 'mailer@example.com',
  553. password: 'smtp-password-123',
  554. helo: 'mail.example.com',
  555. fromEmail: 'notify@example.com',
  556. fromName: 'MailHub Notify',
  557. testRecipient: 'admin@example.com'
  558. })
  559. });
  560. assert.equal(saved.status, 200);
  561. const savedBody = await saved.json();
  562. assert.equal(savedBody.settings.host, 'smtp.example.com');
  563. assert.equal(savedBody.settings.port, 587);
  564. assert.equal(savedBody.settings.secure, false);
  565. assert.equal(savedBody.settings.passwordSet, true);
  566. assert.equal('password' in savedBody.settings, false);
  567. assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
  568. const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
  569. method: 'PATCH',
  570. headers: {
  571. 'Content-Type': 'application/json',
  572. Cookie: adminCookie
  573. },
  574. body: JSON.stringify({
  575. host: 'smtp2.example.com',
  576. password: ''
  577. })
  578. });
  579. assert.equal(preserved.status, 200);
  580. const preservedBody = await preserved.json();
  581. assert.equal(preservedBody.settings.host, 'smtp2.example.com');
  582. assert.equal(preservedBody.settings.passwordSet, true);
  583. assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
  584. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
  585. headers: { Cookie: adminCookie }
  586. });
  587. assert.equal(audit.status, 200);
  588. const [entry] = (await audit.json()).logs;
  589. assert.equal(entry.action, 'admin.update_system_email');
  590. assert.equal(entry.targetType, 'system_email');
  591. assert.equal(entry.summary.host, 'smtp2.example.com');
  592. assert.equal(entry.summary.password, undefined);
  593. assert.equal(entry.summary.passwordSet, true);
  594. } finally {
  595. child.kill('SIGTERM');
  596. await waitForExit(child, 1000);
  597. }
  598. });
  599. test('registration and verification resend use configured system email', async () => {
  600. const smtp = await startFakeSmtpServer();
  601. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  602. try {
  603. seedUsers(dataDir, sessionSecret, [
  604. { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
  605. { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
  606. ]);
  607. const adminCookie = await login(baseUrl, 'admin', 'password123');
  608. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  609. const register = await fetch(`${baseUrl}/api/register`, {
  610. method: 'POST',
  611. headers: { 'Content-Type': 'application/json' },
  612. body: JSON.stringify({
  613. username: 'mailuser',
  614. email: 'mailuser@example.com',
  615. password: 'password123'
  616. })
  617. });
  618. assert.equal(register.status, 201);
  619. const registerBody = await register.json();
  620. assert.equal(registerBody.user.status, 'pending_email');
  621. assert.equal(registerBody.verificationEmailSent, true);
  622. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
  623. const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  624. method: 'POST',
  625. headers: { 'Content-Type': 'application/json' },
  626. body: JSON.stringify({ email: 'publicpending@example.com' })
  627. });
  628. assert.equal(publicResend.status, 202);
  629. const publicResendBody = await publicResend.json();
  630. assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
  631. assert.equal('verificationEmailSent' in publicResendBody, false);
  632. assert.equal('result' in publicResendBody, false);
  633. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
  634. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  635. headers: { Cookie: adminCookie }
  636. });
  637. const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
  638. assert.ok(adminPending);
  639. const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
  640. method: 'POST',
  641. headers: { Cookie: adminCookie }
  642. });
  643. assert.equal(adminResend.status, 202);
  644. assert.equal((await adminResend.json()).verificationEmailSent, true);
  645. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
  646. assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
  647. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
  648. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
  649. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
  650. assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
  651. } finally {
  652. child.kill('SIGTERM');
  653. await waitForExit(child, 1000);
  654. await smtp.close();
  655. }
  656. });
  657. test('public verification resend is generic and does not create tokens without mail config', async () => {
  658. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  659. try {
  660. seedUsers(dataDir, sessionSecret, [{
  661. username: 'pendingnomail',
  662. email: 'pendingnomail@example.com',
  663. password: 'password123',
  664. status: 'pending_email'
  665. }]);
  666. const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  667. method: 'POST',
  668. headers: { 'Content-Type': 'application/json' },
  669. body: JSON.stringify({ email: 'pendingnomail@example.com' })
  670. });
  671. assert.equal(response.status, 202);
  672. assert.deepEqual(await response.json(), {
  673. message: '如果账号需要验证,我们会发送验证邮件。'
  674. });
  675. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
  676. } finally {
  677. child.kill('SIGTERM');
  678. await waitForExit(child, 1000);
  679. }
  680. });
  681. test('registration reports pending email when system email is not configured', async () => {
  682. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  683. try {
  684. const register = await fetch(`${baseUrl}/api/register`, {
  685. method: 'POST',
  686. headers: { 'Content-Type': 'application/json' },
  687. body: JSON.stringify({
  688. username: 'nomailuser',
  689. email: 'nomailuser@example.com',
  690. password: 'password123'
  691. })
  692. });
  693. assert.equal(register.status, 201);
  694. const body = await register.json();
  695. assert.equal(body.user.status, 'pending_email');
  696. assert.equal(body.verificationEmailSent, false);
  697. assert.match(body.message, /验证邮件暂未发送/);
  698. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
  699. } finally {
  700. child.kill('SIGTERM');
  701. await waitForExit(child, 1000);
  702. }
  703. });
  704. test('admin users can send system email test messages', async () => {
  705. const smtp = await startFakeSmtpServer();
  706. const { child, baseUrl } = await startTestServer();
  707. try {
  708. const adminCookie = await login(baseUrl, 'admin', 'password123');
  709. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  710. const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
  711. method: 'POST',
  712. headers: {
  713. 'Content-Type': 'application/json',
  714. Cookie: adminCookie
  715. },
  716. body: JSON.stringify({ to: 'operator@example.com' })
  717. });
  718. assert.equal(response.status, 202);
  719. const body = await response.json();
  720. assert.equal(body.result.ok, true);
  721. assert.equal(body.result.queueId, 'SYS123');
  722. assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
  723. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
  724. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
  725. headers: { Cookie: adminCookie }
  726. });
  727. assert.equal(audit.status, 200);
  728. const [entry] = (await audit.json()).logs;
  729. assert.equal(entry.targetType, 'system_email');
  730. assert.equal(entry.summary.to, 'operator@example.com');
  731. assert.equal(entry.summary.ok, true);
  732. } finally {
  733. child.kill('SIGTERM');
  734. await waitForExit(child, 1000);
  735. await smtp.close();
  736. }
  737. });
  738. test('public forgot password is generic and sends reset email when configured', async () => {
  739. const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
  740. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  741. try {
  742. seedUsers(dataDir, sessionSecret, [{
  743. username: 'resetme',
  744. email: 'resetme@example.com',
  745. password: 'password123',
  746. status: 'active'
  747. }]);
  748. const adminCookie = await login(baseUrl, 'admin', 'password123');
  749. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  750. const startedAt = Date.now();
  751. const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  752. method: 'POST',
  753. headers: { 'Content-Type': 'application/json' },
  754. body: JSON.stringify({ email: 'resetme@example.com' })
  755. });
  756. const elapsedMs = Date.now() - startedAt;
  757. assert.equal(existing.status, 202);
  758. assert.equal(elapsedMs < 500, true);
  759. assert.deepEqual(await existing.json(), {
  760. message: '如果邮箱存在,我们会发送密码重置邮件。'
  761. });
  762. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
  763. const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  764. method: 'POST',
  765. headers: { 'Content-Type': 'application/json' },
  766. body: JSON.stringify({ email: 'missing@example.com' })
  767. });
  768. assert.equal(missing.status, 202);
  769. assert.deepEqual(await missing.json(), {
  770. message: '如果邮箱存在,我们会发送密码重置邮件。'
  771. });
  772. await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
  773. } finally {
  774. child.kill('SIGTERM');
  775. await waitForExit(child, 1000);
  776. await smtp.close();
  777. }
  778. });
  779. test('public reset password consumes token and updates password', async () => {
  780. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  781. try {
  782. seedUsers(dataDir, sessionSecret, [{
  783. username: 'tokenreset',
  784. email: 'tokenreset@example.com',
  785. password: 'password123',
  786. status: 'active'
  787. }]);
  788. const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
  789. const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
  790. method: 'POST',
  791. headers: { 'Content-Type': 'application/json' },
  792. body: JSON.stringify({
  793. token,
  794. password: 'new-password-123'
  795. })
  796. });
  797. assert.equal(response.status, 200);
  798. assert.deepEqual(await response.json(), {
  799. message: '密码已重置,请使用新密码登录。'
  800. });
  801. const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
  802. assert.equal(oldLogin.status, 401);
  803. const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
  804. assert.equal(newLogin.status, 200);
  805. const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
  806. method: 'POST',
  807. headers: { 'Content-Type': 'application/json' },
  808. body: JSON.stringify({
  809. token,
  810. password: 'another-password-123'
  811. })
  812. });
  813. assert.equal(reused.status, 400);
  814. } finally {
  815. child.kill('SIGTERM');
  816. await waitForExit(child, 1000);
  817. }
  818. });
  819. test('admin users can trigger password reset email and set temporary password', async () => {
  820. const smtp = await startFakeSmtpServer();
  821. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  822. try {
  823. seedUsers(dataDir, sessionSecret, [
  824. { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
  825. { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
  826. ]);
  827. const adminCookie = await login(baseUrl, 'admin', 'password123');
  828. const memberCookie = await login(baseUrl, 'member2', 'password123');
  829. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  830. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  831. headers: { Cookie: adminCookie }
  832. });
  833. const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
  834. assert.ok(target);
  835. const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  836. method: 'POST',
  837. headers: { Cookie: memberCookie }
  838. });
  839. assert.equal(forbiddenReset.status, 403);
  840. const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  841. method: 'POST',
  842. headers: { Cookie: adminCookie }
  843. });
  844. assert.equal(reset.status, 202);
  845. assert.equal((await reset.json()).result.ok, true);
  846. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
  847. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
  848. const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  849. method: 'POST',
  850. headers: {
  851. 'Content-Type': 'application/json',
  852. Cookie: memberCookie
  853. },
  854. body: JSON.stringify({ password: 'temporary-123' })
  855. });
  856. assert.equal(forbiddenTemporary.status, 403);
  857. const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  858. method: 'POST',
  859. headers: {
  860. 'Content-Type': 'application/json',
  861. Cookie: adminCookie
  862. },
  863. body: JSON.stringify({ password: 'temporary-123' })
  864. });
  865. assert.equal(temporary.status, 200);
  866. assert.equal((await temporary.json()).user.id, target.id);
  867. assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
  868. const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
  869. assert.equal(oldLogin.status, 401);
  870. const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
  871. assert.equal(tempLogin.status, 200);
  872. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
  873. headers: { Cookie: adminCookie }
  874. });
  875. assert.equal(audit.status, 200);
  876. const logs = (await audit.json()).logs;
  877. assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
  878. const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
  879. assert.ok(temporaryLog);
  880. assert.equal(temporaryLog.summary.username, 'targetuser');
  881. assert.equal(temporaryLog.summary.password, undefined);
  882. assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
  883. } finally {
  884. child.kill('SIGTERM');
  885. await waitForExit(child, 1000);
  886. await smtp.close();
  887. }
  888. });
  889. test('non-admin users cannot list audit logs', async () => {
  890. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  891. try {
  892. seedUsers(dataDir, sessionSecret, [{
  893. username: 'alice',
  894. email: 'alice@example.com',
  895. password: 'password123',
  896. status: 'active'
  897. }]);
  898. const cookie = await login(baseUrl, 'alice', 'password123');
  899. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  900. headers: { Cookie: cookie }
  901. });
  902. assert.equal(response.status, 403);
  903. } finally {
  904. child.kill('SIGTERM');
  905. await waitForExit(child, 1000);
  906. }
  907. });
  908. test('admin user patch rejects invalid status with a bad request', async () => {
  909. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  910. try {
  911. seedUsers(dataDir, sessionSecret, [{
  912. username: 'badstatus',
  913. email: 'badstatus@example.com',
  914. password: 'password123',
  915. status: 'active'
  916. }]);
  917. const cookie = await login(baseUrl, 'admin', 'password123');
  918. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  919. headers: { Cookie: cookie }
  920. });
  921. assert.equal(usersResponse.status, 200);
  922. const usersBody = await usersResponse.json();
  923. const target = usersBody.users.find((user) => user.username === 'badstatus');
  924. assert.ok(target);
  925. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  926. method: 'PATCH',
  927. headers: {
  928. 'Content-Type': 'application/json',
  929. Cookie: cookie
  930. },
  931. body: JSON.stringify({ status: 'archived' })
  932. });
  933. assert.equal(response.status, 400);
  934. assert.equal((await response.json()).error, '用户状态不正确。');
  935. const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  936. method: 'PATCH',
  937. headers: {
  938. 'Content-Type': 'application/json',
  939. Cookie: cookie
  940. },
  941. body: JSON.stringify({ password: 'short' })
  942. });
  943. assert.equal(shortPassword.status, 400);
  944. assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
  945. } finally {
  946. child.kill('SIGTERM');
  947. await waitForExit(child, 1000);
  948. }
  949. });
  950. test('self registration creates a pending email user and verification token without a session', async () => {
  951. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  952. try {
  953. const register = await fetch(`${baseUrl}/api/register`, {
  954. method: 'POST',
  955. headers: { 'Content-Type': 'application/json' },
  956. body: JSON.stringify({
  957. username: 'newuser',
  958. email: 'newuser@example.com',
  959. password: 'password123'
  960. })
  961. });
  962. assert.equal(register.status, 201);
  963. assert.equal(sessionCookieFrom(register), '');
  964. const text = await register.text();
  965. assert.doesNotMatch(text, /token/i);
  966. const body = JSON.parse(text);
  967. assert.equal('token' in body, false);
  968. assert.equal('token' in body.user, false);
  969. assert.equal('tokenHash' in body.user, false);
  970. assert.equal(body.user.status, 'pending_email');
  971. assert.match(body.message, /验证邮箱/);
  972. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
  973. const login = await loginResponse(baseUrl, 'newuser', 'password123');
  974. assert.equal(login.status, 403);
  975. assert.equal(sessionCookieFrom(login), '');
  976. assert.equal((await login.json()).error, '请先验证邮箱。');
  977. } finally {
  978. child.kill('SIGTERM');
  979. await waitForExit(child, 1000);
  980. }
  981. });
  982. test('email verification route consumes token and moves user to admin review', async () => {
  983. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  984. try {
  985. const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  986. username: 'verifyme',
  987. email: 'verifyme@example.com',
  988. password: 'password123',
  989. status: 'pending_email'
  990. });
  991. const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
  992. assert.equal(missing.status, 400);
  993. assert.equal(sessionCookieFrom(missing), '');
  994. const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
  995. assert.equal(invalid.status, 400);
  996. assert.equal(sessionCookieFrom(invalid), '');
  997. const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  998. assert.equal(response.status, 200);
  999. assert.equal(sessionCookieFrom(response), '');
  1000. const body = await response.json();
  1001. assert.equal(body.user.id, created.user.id);
  1002. assert.equal(body.user.status, 'pending_review');
  1003. assert.match(body.message, /管理员审核/);
  1004. const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1005. assert.equal(reused.status, 400);
  1006. assert.equal(sessionCookieFrom(reused), '');
  1007. const login = await loginResponse(baseUrl, 'verifyme', 'password123');
  1008. assert.equal(login.status, 403);
  1009. assert.equal(sessionCookieFrom(login), '');
  1010. assert.equal((await login.json()).error, '账号正在等待管理员审核。');
  1011. } finally {
  1012. child.kill('SIGTERM');
  1013. await waitForExit(child, 1000);
  1014. }
  1015. });
  1016. test('admin users can approve pending review users with an audit log', async () => {
  1017. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1018. try {
  1019. seedUsers(dataDir, sessionSecret, [
  1020. { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
  1021. { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
  1022. { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
  1023. { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
  1024. ]);
  1025. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1026. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1027. headers: { Cookie: adminCookie }
  1028. });
  1029. assert.equal(usersResponse.status, 200);
  1030. const users = (await usersResponse.json()).users;
  1031. const target = users.find((user) => user.username === 'reviewme');
  1032. const pendingEmail = users.find((user) => user.username === 'emailonly');
  1033. const disabled = users.find((user) => user.username === 'disabledreview');
  1034. assert.ok(target);
  1035. assert.ok(pendingEmail);
  1036. assert.ok(disabled);
  1037. const memberCookie = await login(baseUrl, 'member', 'password123');
  1038. const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1039. method: 'POST',
  1040. headers: { Cookie: memberCookie }
  1041. });
  1042. assert.equal(nonAdmin.status, 403);
  1043. const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
  1044. method: 'POST',
  1045. headers: { Cookie: adminCookie }
  1046. });
  1047. assert.equal(missing.status, 404);
  1048. const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
  1049. method: 'POST',
  1050. headers: { Cookie: adminCookie }
  1051. });
  1052. assert.equal(pendingEmailResponse.status, 400);
  1053. assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
  1054. const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
  1055. method: 'POST',
  1056. headers: { Cookie: adminCookie }
  1057. });
  1058. assert.equal(disabledResponse.status, 400);
  1059. assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
  1060. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1061. method: 'POST',
  1062. headers: { Cookie: adminCookie }
  1063. });
  1064. assert.equal(response.status, 200);
  1065. const body = await response.json();
  1066. assert.equal(body.user.id, target.id);
  1067. assert.equal(body.user.status, 'active');
  1068. const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
  1069. assert.ok(approvedCookie);
  1070. const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
  1071. headers: { Cookie: adminCookie }
  1072. });
  1073. assert.equal(auditResponse.status, 200);
  1074. const [entry] = (await auditResponse.json()).logs;
  1075. assert.equal(entry.action, 'admin.approve_user');
  1076. assert.equal(entry.targetType, 'user');
  1077. assert.equal(entry.targetId, String(target.id));
  1078. assert.equal(entry.targetUserId, target.id);
  1079. assert.equal(entry.summary.username, 'reviewme');
  1080. assert.equal(entry.summary.status, 'active');
  1081. assert.equal(entry.summary.password, undefined);
  1082. assert.equal(entry.summary.token, undefined);
  1083. } finally {
  1084. child.kill('SIGTERM');
  1085. await waitForExit(child, 1000);
  1086. }
  1087. });
  1088. test('login returns account status restrictions only after password verification', async () => {
  1089. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1090. try {
  1091. seedUsers(dataDir, sessionSecret, [
  1092. { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
  1093. { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
  1094. { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
  1095. { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
  1096. ]);
  1097. await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
  1098. await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
  1099. await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
  1100. const active = await loginResponse(baseUrl, 'activeuser', 'password123');
  1101. assert.equal(active.status, 200);
  1102. assert.ok(sessionCookieFrom(active));
  1103. } finally {
  1104. child.kill('SIGTERM');
  1105. await waitForExit(child, 1000);
  1106. }
  1107. });
  1108. test('admin audit log actor filter rejects non-decimal user ids', async () => {
  1109. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1110. try {
  1111. seedAuditLogs(dataDir, sessionSecret);
  1112. const cookie = await login(baseUrl, 'admin', 'password123');
  1113. assert.deepEqual(
  1114. await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
  1115. ['audit.actor-one']
  1116. );
  1117. assert.deepEqual(
  1118. await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
  1119. ['audit.actor-one-hundred', 'audit.actor-one']
  1120. );
  1121. } finally {
  1122. child.kill('SIGTERM');
  1123. await waitForExit(child, 1000);
  1124. }
  1125. });
  1126. test('admin audit log date filter ignores invalid dates', async () => {
  1127. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1128. try {
  1129. seedAuditLogs(dataDir, sessionSecret);
  1130. const cookie = await login(baseUrl, 'admin', 'password123');
  1131. assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
  1132. assert.deepEqual(
  1133. await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
  1134. ['audit.actor-one-hundred', 'audit.actor-one']
  1135. );
  1136. } finally {
  1137. child.kill('SIGTERM');
  1138. await waitForExit(child, 1000);
  1139. }
  1140. });
  1141. async function startTestServer() {
  1142. const port = await freePort();
  1143. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
  1144. const sessionSecret = 'test-session-secret';
  1145. const child = spawn(process.execPath, ['src/server.js'], {
  1146. cwd: process.cwd(),
  1147. env: {
  1148. ...process.env,
  1149. PORT: String(port),
  1150. DATA_DIR: dataDir,
  1151. ADMIN_PASSWORD: 'password123',
  1152. SESSION_SECRET: sessionSecret,
  1153. DNS_AUTO_CHECK_ENABLED: 'false',
  1154. SUBMISSION_ENABLED: 'false'
  1155. },
  1156. stdio: ['ignore', 'pipe', 'pipe']
  1157. });
  1158. await waitForOutput(child, 'MailHub listening');
  1159. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  1160. }
  1161. async function login(baseUrl, username, password) {
  1162. const response = await loginResponse(baseUrl, username, password);
  1163. assert.equal(response.status, 200);
  1164. const cookie = sessionCookieFrom(response);
  1165. assert.ok(cookie);
  1166. return cookie;
  1167. }
  1168. function loginResponse(baseUrl, username, password) {
  1169. return fetch(`${baseUrl}/api/login`, {
  1170. method: 'POST',
  1171. headers: { 'Content-Type': 'application/json' },
  1172. body: JSON.stringify({ username, password })
  1173. });
  1174. }
  1175. async function assertLoginDeniedByStatus(baseUrl, username, message) {
  1176. const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
  1177. assert.equal(wrongPassword.status, 401);
  1178. assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
  1179. assert.equal(sessionCookieFrom(wrongPassword), '');
  1180. const correctPassword = await loginResponse(baseUrl, username, 'password123');
  1181. assert.equal(correctPassword.status, 403);
  1182. assert.equal((await correctPassword.json()).error, message);
  1183. assert.equal(sessionCookieFrom(correctPassword), '');
  1184. }
  1185. function sessionCookieFrom(response) {
  1186. return response.headers.get('set-cookie')?.split(';')[0] || '';
  1187. }
  1188. function seedUsers(dataDir, sessionSecret, users) {
  1189. const script = `
  1190. import { initDatabase, createUser } from './src/db.js';
  1191. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1192. for (const user of JSON.parse(process.env.SEED_USERS)) {
  1193. createUser(user);
  1194. }
  1195. `;
  1196. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1197. cwd: process.cwd(),
  1198. env: {
  1199. ...process.env,
  1200. DATA_DIR: dataDir,
  1201. SESSION_SECRET: sessionSecret,
  1202. SEED_USERS: JSON.stringify(users)
  1203. },
  1204. encoding: 'utf8'
  1205. });
  1206. assert.equal(result.status, 0, result.stderr || result.stdout);
  1207. }
  1208. function seedTransferResources(dataDir, sessionSecret) {
  1209. const script = `
  1210. import {
  1211. initDatabase,
  1212. createApiToken,
  1213. createDomain,
  1214. createUser,
  1215. saveDnsCredential
  1216. } from './src/db.js';
  1217. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1218. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
  1219. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
  1220. const credential = saveDnsCredential(alice.id, {
  1221. name: 'Alice DNS',
  1222. provider: 'cloudflare',
  1223. zoneName: 'alice.example',
  1224. credentials: { apiToken: 'secret-token' }
  1225. });
  1226. const standaloneCredential = saveDnsCredential(alice.id, {
  1227. name: 'Standalone DNS',
  1228. provider: 'cloudflare',
  1229. zoneName: 'standalone.example',
  1230. credentials: { apiToken: 'standalone-secret-token' }
  1231. });
  1232. const domain = createDomain(alice.id, {
  1233. dnsCredentialId: credential.id,
  1234. domain: 'alice.example',
  1235. selector: 'mh202607',
  1236. verificationToken: 'token',
  1237. dkimPublic: 'public',
  1238. dkimPrivate: 'private',
  1239. senderHost: 'mail.alice.example',
  1240. sendingIp: '127.0.0.1',
  1241. spfExtra: '',
  1242. dmarcPolicy: 'none',
  1243. dmarcRua: ''
  1244. });
  1245. const apiToken = createApiToken(alice.id, 'primary');
  1246. console.log(JSON.stringify({
  1247. aliceId: alice.id,
  1248. bobId: bob.id,
  1249. domainId: domain.id,
  1250. credentialId: credential.id,
  1251. standaloneCredentialId: standaloneCredential.id,
  1252. apiTokenId: apiToken.id
  1253. }));
  1254. `;
  1255. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1256. cwd: process.cwd(),
  1257. env: {
  1258. ...process.env,
  1259. DATA_DIR: dataDir,
  1260. SESSION_SECRET: sessionSecret
  1261. },
  1262. encoding: 'utf8'
  1263. });
  1264. assert.equal(result.status, 0, result.stderr || result.stdout);
  1265. return JSON.parse(result.stdout);
  1266. }
  1267. function seedMergeResources(dataDir, sessionSecret) {
  1268. const script = `
  1269. import {
  1270. initDatabase,
  1271. createApiToken,
  1272. createDomain,
  1273. createUser,
  1274. logSendEvent,
  1275. saveDnsCredential
  1276. } from './src/db.js';
  1277. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1278. const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
  1279. const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
  1280. const credential = saveDnsCredential(source.id, {
  1281. name: 'Merge DNS',
  1282. provider: 'cloudflare',
  1283. zoneName: 'merge.example',
  1284. credentials: { apiToken: 'merge-secret-token' }
  1285. });
  1286. const domain = createDomain(source.id, {
  1287. dnsCredentialId: credential.id,
  1288. domain: 'merge.example',
  1289. selector: 'mh202607',
  1290. verificationToken: 'token',
  1291. dkimPublic: 'public',
  1292. dkimPrivate: 'private',
  1293. senderHost: 'mail.merge.example',
  1294. sendingIp: '127.0.0.1',
  1295. spfExtra: '',
  1296. dmarcPolicy: 'none',
  1297. dmarcRua: ''
  1298. });
  1299. createApiToken(source.id, 'primary');
  1300. logSendEvent({
  1301. userId: source.id,
  1302. domainId: domain.id,
  1303. sender: 'noreply@merge.example',
  1304. recipients: ['a@example.com'],
  1305. subject: 'Queued',
  1306. status: 'queued'
  1307. });
  1308. console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
  1309. `;
  1310. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1311. cwd: process.cwd(),
  1312. env: {
  1313. ...process.env,
  1314. DATA_DIR: dataDir,
  1315. SESSION_SECRET: sessionSecret
  1316. },
  1317. encoding: 'utf8'
  1318. });
  1319. assert.equal(result.status, 0, result.stderr || result.stdout);
  1320. return JSON.parse(result.stdout);
  1321. }
  1322. function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
  1323. const script = `
  1324. import { initDatabase, createUser, createAccountToken } from './src/db.js';
  1325. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1326. const user = createUser(JSON.parse(process.env.SEED_USER));
  1327. const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
  1328. console.log(JSON.stringify({ user, token: token.token }));
  1329. `;
  1330. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1331. cwd: process.cwd(),
  1332. env: {
  1333. ...process.env,
  1334. DATA_DIR: dataDir,
  1335. SESSION_SECRET: sessionSecret,
  1336. SEED_USER: JSON.stringify(user)
  1337. },
  1338. encoding: 'utf8'
  1339. });
  1340. assert.equal(result.status, 0, result.stderr || result.stdout);
  1341. return JSON.parse(result.stdout);
  1342. }
  1343. function createPasswordResetToken(dataDir, sessionSecret, username) {
  1344. const script = `
  1345. import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
  1346. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1347. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1348. const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
  1349. console.log(token.token);
  1350. `;
  1351. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1352. cwd: process.cwd(),
  1353. env: {
  1354. ...process.env,
  1355. DATA_DIR: dataDir,
  1356. SESSION_SECRET: sessionSecret,
  1357. TOKEN_USERNAME: username
  1358. },
  1359. encoding: 'utf8'
  1360. });
  1361. assert.equal(result.status, 0, result.stderr || result.stdout);
  1362. return result.stdout.trim();
  1363. }
  1364. function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1365. return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
  1366. }
  1367. function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1368. return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
  1369. }
  1370. function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
  1371. const script = `
  1372. import path from 'node:path';
  1373. import { DatabaseSync } from 'node:sqlite';
  1374. import { initDatabase, getUserByLogin } from './src/db.js';
  1375. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1376. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1377. const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1378. database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1379. const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
  1380. const row = user
  1381. ? database
  1382. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
  1383. .get(user.id, process.env.TOKEN_PURPOSE)
  1384. : { count: 0 };
  1385. console.log(String(row.count));
  1386. `;
  1387. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1388. cwd: process.cwd(),
  1389. env: {
  1390. ...process.env,
  1391. DATA_DIR: dataDir,
  1392. SESSION_SECRET: sessionSecret,
  1393. TOKEN_USERNAME: username,
  1394. TOKEN_PURPOSE: purpose,
  1395. TOKEN_UNUSED_ONLY: String(unusedOnly)
  1396. },
  1397. encoding: 'utf8'
  1398. });
  1399. assert.equal(result.status, 0, result.stderr || result.stdout);
  1400. return Number(result.stdout.trim());
  1401. }
  1402. function seedAuditLogs(dataDir, sessionSecret) {
  1403. const script = `
  1404. import path from 'node:path';
  1405. import { DatabaseSync } from 'node:sqlite';
  1406. import { initDatabase, logAudit } from './src/db.js';
  1407. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1408. const actorOneId = logAudit({
  1409. actorUserId: 1,
  1410. action: 'audit.actor-one',
  1411. targetType: 'system',
  1412. summary: { label: 'actor-one' }
  1413. });
  1414. const actorOneHundredId = logAudit({
  1415. actorUserId: 100,
  1416. action: 'audit.actor-one-hundred',
  1417. targetType: 'system',
  1418. summary: { label: 'actor-one-hundred' }
  1419. });
  1420. const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1421. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1422. const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
  1423. update.run('2026-02-01T00:00:00.000Z', actorOneId);
  1424. update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
  1425. `;
  1426. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1427. cwd: process.cwd(),
  1428. env: {
  1429. ...process.env,
  1430. DATA_DIR: dataDir,
  1431. SESSION_SECRET: sessionSecret
  1432. },
  1433. encoding: 'utf8'
  1434. });
  1435. assert.equal(result.status, 0, result.stderr || result.stdout);
  1436. }
  1437. async function auditLogActions(baseUrl, cookie, query) {
  1438. const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
  1439. headers: { Cookie: cookie }
  1440. });
  1441. assert.equal(response.status, 200);
  1442. const body = await response.json();
  1443. return body.logs.map((log) => log.action);
  1444. }
  1445. async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
  1446. const response = await fetch(`${baseUrl}/api/admin/system-email`, {
  1447. method: 'PATCH',
  1448. headers: {
  1449. 'Content-Type': 'application/json',
  1450. Cookie: cookie
  1451. },
  1452. body: JSON.stringify({
  1453. host: '127.0.0.1',
  1454. port: smtpPort,
  1455. secure: false,
  1456. username: 'mailer@example.com',
  1457. password: 'smtp-password-123',
  1458. helo: 'mail.example.com',
  1459. fromEmail: 'notify@example.com',
  1460. fromName: 'MailHub Notify',
  1461. testRecipient: 'admin@example.com'
  1462. })
  1463. });
  1464. assert.equal(response.status, 200);
  1465. }
  1466. async function createSendingDomain(baseUrl, cookie, data = {}) {
  1467. const domain = data.domain || 'send.example';
  1468. const response = await fetch(`${baseUrl}/api/domains`, {
  1469. method: 'POST',
  1470. headers: {
  1471. 'Content-Type': 'application/json',
  1472. Cookie: cookie
  1473. },
  1474. body: JSON.stringify({
  1475. domain,
  1476. selector: data.selector || 'mh',
  1477. senderHost: data.senderHost || `mail.${domain}`,
  1478. sendingIp: data.sendingIp || '127.0.0.1',
  1479. smtpRelayId: data.smtpRelayId
  1480. })
  1481. });
  1482. assert.equal(response.status, 201);
  1483. return (await response.json()).domain;
  1484. }
  1485. async function createSmtpRelay(baseUrl, cookie, data = {}) {
  1486. const response = await fetch(`${baseUrl}/api/smtp-relays`, {
  1487. method: 'POST',
  1488. headers: {
  1489. 'Content-Type': 'application/json',
  1490. Cookie: cookie
  1491. },
  1492. body: JSON.stringify({
  1493. name: data.name || 'Relay',
  1494. host: data.host || '127.0.0.1',
  1495. port: data.port,
  1496. secure: data.secure || false,
  1497. username: data.username || '',
  1498. password: data.password || '',
  1499. helo: data.helo || '',
  1500. isDefault: data.isDefault || false
  1501. })
  1502. });
  1503. assert.equal(response.status, 201);
  1504. return (await response.json()).relay;
  1505. }
  1506. async function createSmtpCredential(baseUrl, cookie, data = {}) {
  1507. const response = await fetch(`${baseUrl}/api/smtp-credentials`, {
  1508. method: 'POST',
  1509. headers: {
  1510. 'Content-Type': 'application/json',
  1511. Cookie: cookie
  1512. },
  1513. body: JSON.stringify(data)
  1514. });
  1515. assert.equal(response.status, 201);
  1516. return (await response.json()).credential;
  1517. }
  1518. async function sendApiMail(baseUrl, cookie, data) {
  1519. const response = await fetch(`${baseUrl}/api/send`, {
  1520. method: 'POST',
  1521. headers: {
  1522. 'Content-Type': 'application/json',
  1523. Cookie: cookie
  1524. },
  1525. body: JSON.stringify({
  1526. from: data.from,
  1527. to: data.to,
  1528. subject: data.subject,
  1529. text: data.text || 'hello',
  1530. smtpRelayId: data.smtpRelayId
  1531. })
  1532. });
  1533. assert.equal(response.status, 202);
  1534. return response.json();
  1535. }
  1536. function assertRelayAuth(relayServer, username, password) {
  1537. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  1538. assert.ok(authCommand);
  1539. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
  1540. }
  1541. function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
  1542. const commands = [];
  1543. const messages = [];
  1544. const server = net.createServer((socket) => {
  1545. socket.setEncoding('utf8');
  1546. writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
  1547. let buffer = '';
  1548. let dataMode = false;
  1549. let messageLines = [];
  1550. socket.on('data', (chunk) => {
  1551. buffer += chunk;
  1552. let index;
  1553. while ((index = buffer.indexOf('\n')) !== -1) {
  1554. const line = buffer.slice(0, index).replace(/\r$/, '');
  1555. buffer = buffer.slice(index + 1);
  1556. if (dataMode) {
  1557. if (line === '.') {
  1558. dataMode = false;
  1559. messages.push(messageLines.join('\n'));
  1560. messageLines = [];
  1561. writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
  1562. } else {
  1563. messageLines.push(line);
  1564. }
  1565. continue;
  1566. }
  1567. commands.push(line);
  1568. if (line.startsWith('EHLO')) {
  1569. writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
  1570. } else if (line.startsWith('AUTH PLAIN')) {
  1571. writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
  1572. } else if (line.startsWith('MAIL FROM')) {
  1573. writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
  1574. } else if (line.startsWith('RCPT TO')) {
  1575. writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
  1576. } else if (line === 'DATA') {
  1577. dataMode = true;
  1578. writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
  1579. } else if (line === 'QUIT') {
  1580. writeSmtpResponse(socket, '221 bye', responseDelayMs);
  1581. socket.end();
  1582. }
  1583. }
  1584. });
  1585. });
  1586. return new Promise((resolve, reject) => {
  1587. server.once('error', reject);
  1588. server.listen(0, '127.0.0.1', () => {
  1589. server.off('error', reject);
  1590. resolve({
  1591. port: server.address().port,
  1592. commands,
  1593. messages,
  1594. close: () => new Promise((closeResolve) => server.close(closeResolve))
  1595. });
  1596. });
  1597. });
  1598. }
  1599. function writeSmtpResponse(socket, response, delayMs) {
  1600. const write = () => socket.write(`${response}\r\n`);
  1601. if (delayMs > 0) setTimeout(write, delayMs);
  1602. else write();
  1603. }
  1604. async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
  1605. const startedAt = Date.now();
  1606. while (Date.now() - startedAt < timeoutMs) {
  1607. if (predicate()) return;
  1608. await new Promise((resolve) => setTimeout(resolve, intervalMs));
  1609. }
  1610. assert.fail('Timed out waiting for condition.');
  1611. }
  1612. function freePort() {
  1613. return new Promise((resolve, reject) => {
  1614. const server = net.createServer();
  1615. server.listen(0, '127.0.0.1', () => {
  1616. const address = server.address();
  1617. server.close(() => {
  1618. if (address && typeof address === 'object') resolve(address.port);
  1619. else reject(new Error('Unable to allocate a test port.'));
  1620. });
  1621. });
  1622. });
  1623. }
  1624. function waitForOutput(child, text) {
  1625. return new Promise((resolve, reject) => {
  1626. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  1627. const chunks = [];
  1628. const onData = (chunk) => {
  1629. chunks.push(String(chunk));
  1630. if (chunks.join('').includes(text)) {
  1631. clearTimeout(timeout);
  1632. child.stdout.off('data', onData);
  1633. child.stderr.off('data', onData);
  1634. resolve();
  1635. }
  1636. };
  1637. child.stdout.on('data', onData);
  1638. child.stderr.on('data', onData);
  1639. child.once('exit', (code) => {
  1640. clearTimeout(timeout);
  1641. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  1642. });
  1643. });
  1644. }
  1645. function waitForExit(child, timeoutMs) {
  1646. if (child.exitCode !== null) return Promise.resolve(true);
  1647. return new Promise((resolve) => {
  1648. const timeout = setTimeout(() => {
  1649. child.off('exit', onExit);
  1650. resolve(false);
  1651. }, timeoutMs);
  1652. const onExit = () => {
  1653. clearTimeout(timeout);
  1654. resolve(true);
  1655. };
  1656. child.once('exit', onExit);
  1657. });
  1658. }