server-admin-api.test.js 64 KB

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