server-admin-api.test.js 62 KB

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