server-admin-api.test.js 65 KB

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