server-admin-api.test.js 71 KB

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