server-admin-api.test.js 92 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526
  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.passwordSet, true);
  107. assert.equal('password' in first, false);
  108. assert.equal(second.username, 'admin-smtp-app');
  109. assert.equal(second.passwordSet, true);
  110. assert.equal('password' in second, false);
  111. const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
  112. assert.equal(list.status, 200);
  113. const listPayload = await list.json();
  114. assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
  115. assert.equal(listPayload.credentials[0].passwordSet, true);
  116. assert.equal('password' in listPayload.credentials[0], false);
  117. const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
  118. method: 'PATCH',
  119. headers: {
  120. 'Content-Type': 'application/json',
  121. Cookie: cookie
  122. },
  123. body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
  124. });
  125. assert.equal(update.status, 200);
  126. const updatedCredential = (await update.json()).credential;
  127. assert.equal(updatedCredential.passwordSet, true);
  128. assert.equal('password' in updatedCredential, false);
  129. const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
  130. method: 'DELETE',
  131. headers: { Cookie: cookie }
  132. });
  133. assert.equal(deleted.status, 200);
  134. assert.equal((await deleted.json()).deleted, true);
  135. } finally {
  136. child.kill('SIGTERM');
  137. await waitForExit(child, 1000);
  138. }
  139. });
  140. test('users can manage inbound mailboxes and read inbound messages', async () => {
  141. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  142. try {
  143. const cookie = await login(baseUrl, 'admin', 'password123');
  144. await createSendingDomain(baseUrl, cookie, { domain: 'inbound-api.example' });
  145. const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  146. method: 'POST',
  147. headers: {
  148. 'Content-Type': 'application/json',
  149. Cookie: cookie
  150. },
  151. body: JSON.stringify({
  152. address: 'Support@inbound-api.example',
  153. displayName: 'Support',
  154. password: 'mailbox-pass-123',
  155. forwardTo: 'archive@example.net',
  156. keepForwarded: true
  157. })
  158. });
  159. assert.equal(createMailbox.status, 201);
  160. const createMailboxBody = await createMailbox.json();
  161. const mailbox = createMailboxBody.mailbox;
  162. assert.equal(mailbox.address, 'support@inbound-api.example');
  163. assert.equal(mailbox.displayName, 'Support');
  164. assert.equal(mailbox.passwordSet, true);
  165. assert.deepEqual(mailbox.forwardTo, ['archive@example.net']);
  166. assert.equal(mailbox.unreadCount, 0);
  167. assert.equal(createMailboxBody.clientConfig.username, 'support@inbound-api.example');
  168. assert.equal(createMailboxBody.clientConfig.password, 'mailbox-pass-123');
  169. assert.equal(createMailboxBody.clientConfig.outgoing.authMethod, 'Normal password');
  170. const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } });
  171. assert.equal(mailboxes.status, 200);
  172. const mailboxesBody = await mailboxes.json();
  173. assert.deepEqual(mailboxesBody.mailboxes.map((entry) => entry.address), ['support@inbound-api.example']);
  174. const messageId = seedInboundMessage(dataDir, sessionSecret, 'support@inbound-api.example');
  175. const messages = await fetch(`${baseUrl}/api/inbound-messages?mailboxId=${mailbox.id}`, { headers: { Cookie: cookie } });
  176. assert.equal(messages.status, 200);
  177. const messagesBody = await messages.json();
  178. assert.equal(messagesBody.messages.length, 1);
  179. assert.equal(messagesBody.messages[0].id, messageId);
  180. assert.equal(messagesBody.messages[0].subject, 'Inbound API message');
  181. assert.equal(messagesBody.messages[0].textBody, undefined);
  182. const detail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
  183. assert.equal(detail.status, 200);
  184. const detailBody = await detail.json();
  185. assert.equal(detailBody.message.textBody, 'Hello from inbound API.');
  186. assert.equal(detailBody.message.rawMessage.includes('Inbound API message'), true);
  187. const markRead = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, {
  188. method: 'PATCH',
  189. headers: {
  190. 'Content-Type': 'application/json',
  191. Cookie: cookie
  192. },
  193. body: JSON.stringify({ read: true })
  194. });
  195. assert.equal(markRead.status, 200);
  196. assert.equal((await markRead.json()).message.read, true);
  197. const createTarget = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  198. method: 'POST',
  199. headers: {
  200. 'Content-Type': 'application/json',
  201. Cookie: cookie
  202. },
  203. body: JSON.stringify({
  204. address: 'archive@inbound-api.example',
  205. displayName: 'Archive',
  206. password: 'archive-pass-123'
  207. })
  208. });
  209. assert.equal(createTarget.status, 201);
  210. const targetMailbox = (await createTarget.json()).mailbox;
  211. const invalidSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/0`, {
  212. method: 'DELETE',
  213. headers: { Cookie: cookie }
  214. });
  215. assert.equal(invalidSourceId.status, 400);
  216. assert.equal((await invalidSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
  217. const unsafeSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/9007199254740993`, {
  218. method: 'DELETE',
  219. headers: { Cookie: cookie }
  220. });
  221. assert.equal(unsafeSourceId.status, 400);
  222. assert.equal((await unsafeSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
  223. const unsafeTargetId = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  224. method: 'DELETE',
  225. headers: {
  226. 'Content-Type': 'application/json',
  227. Cookie: cookie
  228. },
  229. body: JSON.stringify({
  230. targetMailboxId: '9007199254740993',
  231. confirmAddress: mailbox.address
  232. })
  233. });
  234. assert.equal(unsafeTargetId.status, 400);
  235. assert.equal((await unsafeTargetId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
  236. const invalidDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  237. method: 'DELETE',
  238. headers: {
  239. 'Content-Type': 'application/json',
  240. Cookie: cookie
  241. },
  242. body: '{"targetMailboxId":'
  243. });
  244. assert.equal(invalidDeleteBody.status, 400);
  245. assert.equal((await invalidDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_BODY');
  246. const emptyDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  247. method: 'DELETE',
  248. headers: { Cookie: cookie }
  249. });
  250. assert.equal(emptyDeleteBody.status, 400);
  251. assert.equal((await emptyDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH');
  252. const missingConfirmation = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  253. method: 'DELETE',
  254. headers: {
  255. 'Content-Type': 'application/json',
  256. Cookie: cookie
  257. },
  258. body: JSON.stringify({ targetMailboxId: targetMailbox.id })
  259. });
  260. assert.equal(missingConfirmation.status, 400);
  261. assert.equal((await missingConfirmation.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH');
  262. const sameTarget = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  263. method: 'DELETE',
  264. headers: {
  265. 'Content-Type': 'application/json',
  266. Cookie: cookie
  267. },
  268. body: JSON.stringify({
  269. targetMailboxId: mailbox.id,
  270. confirmAddress: mailbox.address
  271. })
  272. });
  273. assert.equal(sameTarget.status, 400);
  274. assert.equal((await sameTarget.json()).code, 'INBOUND_MAILBOX_DELETE_SAME_TARGET');
  275. const deleteMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  276. method: 'DELETE',
  277. headers: {
  278. 'Content-Type': 'application/json',
  279. Cookie: cookie
  280. },
  281. body: JSON.stringify({
  282. targetMailboxId: targetMailbox.id,
  283. confirmAddress: mailbox.address
  284. })
  285. });
  286. assert.equal(deleteMailbox.status, 200);
  287. const deleted = await deleteMailbox.json();
  288. assert.equal(deleted.deleted, true);
  289. assert.deepEqual(deleted.deletedMailbox, { id: mailbox.id, address: mailbox.address });
  290. assert.equal(deleted.targetMailbox.id, targetMailbox.id);
  291. assert.equal(deleted.migratedMessageCount, 1);
  292. const movedDetail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
  293. assert.equal(movedDetail.status, 200);
  294. assert.equal((await movedDetail.json()).message.mailboxId, targetMailbox.id);
  295. const deletedSource = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
  296. method: 'PATCH',
  297. headers: {
  298. 'Content-Type': 'application/json',
  299. Cookie: cookie
  300. },
  301. body: JSON.stringify({ displayName: 'Should not update' })
  302. });
  303. assert.equal(deletedSource.status, 404);
  304. const createEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  305. method: 'POST',
  306. headers: {
  307. 'Content-Type': 'application/json',
  308. Cookie: cookie
  309. },
  310. body: JSON.stringify({
  311. address: 'empty@inbound-api.example',
  312. password: 'empty-mailbox-pass-123'
  313. })
  314. });
  315. assert.equal(createEmpty.status, 201);
  316. const emptyMailbox = (await createEmpty.json()).mailbox;
  317. const deleteEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes/${emptyMailbox.id}`, {
  318. method: 'DELETE',
  319. headers: {
  320. 'Content-Type': 'application/json',
  321. Cookie: cookie
  322. },
  323. body: JSON.stringify({ confirmAddress: emptyMailbox.address })
  324. });
  325. assert.equal(deleteEmpty.status, 200);
  326. const emptyResult = await deleteEmpty.json();
  327. assert.equal(emptyResult.targetMailbox, null);
  328. assert.equal(emptyResult.migratedMessageCount, 0);
  329. } finally {
  330. child.kill('SIGTERM');
  331. await waitForExit(child, 1000);
  332. }
  333. });
  334. test('shared domains are available for mailbox creation but not domain management', async () => {
  335. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  336. try {
  337. const ownerCookie = await login(baseUrl, 'admin', 'password123');
  338. const sharedDomain = await createSendingDomain(baseUrl, ownerCookie, { domain: 'shared-mailbox.example' });
  339. const share = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
  340. method: 'PATCH',
  341. headers: {
  342. 'Content-Type': 'application/json',
  343. Cookie: ownerCookie
  344. },
  345. body: JSON.stringify({ mailboxSignupEnabled: true })
  346. });
  347. assert.equal(share.status, 200);
  348. assert.equal((await share.json()).domain.mailboxSignupEnabled, true);
  349. seedUsers(dataDir, sessionSecret, [
  350. { username: 'shared-user', email: 'shared-user@example.com', password: 'password123', status: 'active' }
  351. ]);
  352. const userCookie = await login(baseUrl, 'shared-user', 'password123');
  353. const manageableDomains = await fetch(`${baseUrl}/api/domains`, { headers: { Cookie: userCookie } });
  354. assert.equal(manageableDomains.status, 200);
  355. assert.deepEqual((await manageableDomains.json()).domains, []);
  356. const mailboxDomains = await fetch(`${baseUrl}/api/inbound-mailbox-domains`, { headers: { Cookie: userCookie } });
  357. assert.equal(mailboxDomains.status, 200);
  358. const mailboxDomainRows = (await mailboxDomains.json()).domains;
  359. assert.deepEqual(mailboxDomainRows.map((domain) => domain.domain), ['shared-mailbox.example']);
  360. assert.equal('dkimPublic' in mailboxDomainRows[0], false);
  361. const ownerMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  362. method: 'POST',
  363. headers: {
  364. 'Content-Type': 'application/json',
  365. Cookie: ownerCookie
  366. },
  367. body: JSON.stringify({
  368. address: 'owner@shared-mailbox.example',
  369. password: 'mailbox-pass-123',
  370. aliases: ['claimed']
  371. })
  372. });
  373. assert.equal(ownerMailbox.status, 201);
  374. const addressConflict = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  375. method: 'POST',
  376. headers: {
  377. 'Content-Type': 'application/json',
  378. Cookie: userCookie
  379. },
  380. body: JSON.stringify({
  381. address: 'claimed@shared-mailbox.example',
  382. password: 'mailbox-pass-123'
  383. })
  384. });
  385. assert.equal(addressConflict.status, 409);
  386. assert.match((await addressConflict.json()).error, /claimed@shared-mailbox\.example 已被其他邮箱占用/);
  387. const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  388. method: 'POST',
  389. headers: {
  390. 'Content-Type': 'application/json',
  391. Cookie: userCookie
  392. },
  393. body: JSON.stringify({
  394. address: 'user@shared-mailbox.example',
  395. password: 'mailbox-pass-123',
  396. aliases: ['user-alias']
  397. })
  398. });
  399. assert.equal(createMailbox.status, 201);
  400. const createdMailbox = (await createMailbox.json()).mailbox;
  401. assert.equal(createdMailbox.address, 'user@shared-mailbox.example');
  402. const aliasConflict = await fetch(`${baseUrl}/api/inbound-mailboxes/${createdMailbox.id}`, {
  403. method: 'PATCH',
  404. headers: {
  405. 'Content-Type': 'application/json',
  406. Cookie: userCookie
  407. },
  408. body: JSON.stringify({ aliases: ['owner'] })
  409. });
  410. assert.equal(aliasConflict.status, 409);
  411. assert.match((await aliasConflict.json()).error, /owner@shared-mailbox\.example 已被其他邮箱占用/);
  412. const blockedDelete = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
  413. method: 'DELETE',
  414. headers: { Cookie: ownerCookie }
  415. });
  416. assert.equal(blockedDelete.status, 409);
  417. assert.match((await blockedDelete.json()).error, /仍有关联收信邮箱/);
  418. const blockedPatch = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
  419. method: 'PATCH',
  420. headers: {
  421. 'Content-Type': 'application/json',
  422. Cookie: userCookie
  423. },
  424. body: JSON.stringify({ catchAllAddress: 'user@shared-mailbox.example' })
  425. });
  426. assert.equal(blockedPatch.status, 404);
  427. } finally {
  428. child.kill('SIGTERM');
  429. await waitForExit(child, 1000);
  430. }
  431. });
  432. test('scoped API tokens create persistent and temporary mailboxes', async () => {
  433. const { child, baseUrl } = await startTestServer();
  434. try {
  435. const cookie = await login(baseUrl, 'admin', 'password123');
  436. await createSendingDomain(baseUrl, cookie, { domain: 'mailbox-token-api.example' });
  437. const sendOnlyResponse = await fetch(`${baseUrl}/api/api-tokens`, {
  438. method: 'POST',
  439. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  440. body: JSON.stringify({ name: 'send only', scopes: ['send'] })
  441. });
  442. assert.equal(sendOnlyResponse.status, 201);
  443. const sendOnly = (await sendOnlyResponse.json()).token;
  444. const denied = await fetch(`${baseUrl}/api/mailboxes`, {
  445. method: 'POST',
  446. headers: {
  447. Authorization: `Bearer ${sendOnly.token}`,
  448. 'Content-Type': 'application/json'
  449. },
  450. body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
  451. });
  452. assert.equal(denied.status, 403);
  453. const tokenResponse = await fetch(`${baseUrl}/api/api-tokens`, {
  454. method: 'POST',
  455. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  456. body: JSON.stringify({ name: 'mailboxes', scopes: ['mailboxes:read', 'mailboxes:write'] })
  457. });
  458. assert.equal(tokenResponse.status, 201);
  459. const mailboxToken = (await tokenResponse.json()).token;
  460. const temporary = await fetch(`${baseUrl}/api/mailboxes`, {
  461. method: 'POST',
  462. headers: {
  463. Authorization: `Bearer ${mailboxToken.token}`,
  464. 'Content-Type': 'application/json'
  465. },
  466. body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
  467. });
  468. assert.equal(temporary.status, 201);
  469. const temporaryBody = await temporary.json();
  470. assert.match(temporaryBody.mailbox.address, /^tmp-[a-f0-9]+@mailbox-token-api\.example$/);
  471. assert.equal(temporaryBody.mailbox.temporary, true);
  472. assert.ok(temporaryBody.mailbox.expiresAt);
  473. assert.equal(temporaryBody.password.length >= 8, true);
  474. assert.equal(temporaryBody.clientConfig.incoming.protocol, 'IMAP');
  475. const permanent = await fetch(`${baseUrl}/api/mailboxes`, {
  476. method: 'POST',
  477. headers: {
  478. Authorization: `Bearer ${mailboxToken.token}`,
  479. 'Content-Type': 'application/json'
  480. },
  481. body: JSON.stringify({
  482. mode: 'permanent',
  483. address: 'support@mailbox-token-api.example',
  484. password: 'mailbox-pass-123'
  485. })
  486. });
  487. assert.equal(permanent.status, 201);
  488. const permanentBody = await permanent.json();
  489. assert.equal(permanentBody.mailbox.temporary, false);
  490. assert.equal(permanentBody.mailbox.expiresAt, null);
  491. assert.equal(permanentBody.clientConfig.outgoing.protocol, 'SMTP');
  492. const listed = await fetch(`${baseUrl}/api/mailboxes`, {
  493. headers: { Authorization: `Bearer ${mailboxToken.token}` }
  494. });
  495. assert.equal(listed.status, 200);
  496. assert.equal((await listed.json()).mailboxes.length, 2);
  497. const sendDenied = await fetch(`${baseUrl}/api/send`, {
  498. method: 'POST',
  499. headers: {
  500. Authorization: `Bearer ${mailboxToken.token}`,
  501. 'Content-Type': 'application/json'
  502. },
  503. body: JSON.stringify({ from: 'noreply@mailbox-token-api.example', to: 'user@example.com', text: 'blocked' })
  504. });
  505. assert.equal(sendDenied.status, 403);
  506. const revoke = await fetch(`${baseUrl}/api/api-tokens/${mailboxToken.id}`, {
  507. method: 'DELETE',
  508. headers: { Cookie: cookie }
  509. });
  510. assert.equal(revoke.status, 200);
  511. assert.equal((await revoke.json()).token.status, 'revoked');
  512. const rejectedAfterRevoke = await fetch(`${baseUrl}/api/mailboxes`, {
  513. headers: { Authorization: `Bearer ${mailboxToken.token}` }
  514. });
  515. assert.equal(rejectedAfterRevoke.status, 401);
  516. } finally {
  517. child.kill('SIGTERM');
  518. await waitForExit(child, 1000);
  519. }
  520. });
  521. test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
  522. const relayServer = await startFakeSmtpServer();
  523. const { child, baseUrl } = await startTestServer();
  524. try {
  525. const cookie = await login(baseUrl, 'admin', 'password123');
  526. const domainResponse = await fetch(`${baseUrl}/api/domains`, {
  527. method: 'POST',
  528. headers: {
  529. 'Content-Type': 'application/json',
  530. Cookie: cookie
  531. },
  532. body: JSON.stringify({
  533. domain: 'relay.example',
  534. selector: 'mh',
  535. senderHost: 'mail.relay.example',
  536. sendingIp: '127.0.0.1'
  537. })
  538. });
  539. assert.equal(domainResponse.status, 201);
  540. const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, {
  541. method: 'POST',
  542. headers: {
  543. 'Content-Type': 'application/json',
  544. Cookie: cookie
  545. },
  546. body: JSON.stringify({
  547. name: 'Primary outbound',
  548. host: '127.0.0.1',
  549. port: relayServer.port,
  550. secure: false,
  551. username: 'relay-user',
  552. password: 'relay-password',
  553. helo: 'helo.relay.example',
  554. isDefault: true
  555. })
  556. });
  557. assert.equal(createRelay.status, 201);
  558. const created = await createRelay.json();
  559. assert.equal(created.relay.passwordSet, true);
  560. assert.equal('password' in created.relay, false);
  561. const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } });
  562. assert.equal(list.status, 200);
  563. const listed = await list.json();
  564. assert.equal(listed.relays.length, 1);
  565. assert.equal('password' in listed.relays[0], false);
  566. const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  567. assert.equal(detail.status, 200);
  568. const detailBody = await detail.json();
  569. assert.equal(detailBody.relay.passwordSet, true);
  570. assert.equal('password' in detailBody.relay, false);
  571. assert.equal('passwordSecret' in detailBody.relay, false);
  572. const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
  573. method: 'PATCH',
  574. headers: {
  575. 'Content-Type': 'application/json',
  576. Cookie: cookie
  577. },
  578. body: JSON.stringify({
  579. name: 'Missing relay',
  580. host: '127.0.0.1',
  581. port: relayServer.port,
  582. secure: false,
  583. username: 'missing-user',
  584. password: 'missing-password'
  585. })
  586. });
  587. assert.equal(missingPatch.status, 404);
  588. const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, {
  589. method: 'PATCH',
  590. headers: {
  591. 'Content-Type': 'application/json',
  592. Cookie: cookie
  593. },
  594. body: JSON.stringify({
  595. name: 'Primary outbound renamed',
  596. host: '127.0.0.1',
  597. port: relayServer.port,
  598. secure: false,
  599. username: 'relay-user'
  600. })
  601. });
  602. assert.equal(updateWithoutPassword.status, 200);
  603. const updatedRelay = (await updateWithoutPassword.json()).relay;
  604. assert.equal(updatedRelay.isDefault, true);
  605. assert.equal(updatedRelay.passwordSet, true);
  606. assert.equal('password' in updatedRelay, false);
  607. const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  608. const detailAfterPatchBody = await detailAfterPatch.json();
  609. assert.equal(detailAfterPatchBody.relay.passwordSet, true);
  610. assert.equal('password' in detailAfterPatchBody.relay, false);
  611. const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
  612. method: 'POST',
  613. headers: {
  614. 'Content-Type': 'application/json',
  615. Cookie: cookie
  616. },
  617. body: JSON.stringify({
  618. from: 'noreply@relay.example',
  619. to: 'user@example.com',
  620. subject: 'Invalid relay',
  621. text: 'hello',
  622. smtpRelayId: 999999
  623. })
  624. });
  625. assert.equal(invalidRelaySend.status, 400);
  626. const send = await fetch(`${baseUrl}/api/send`, {
  627. method: 'POST',
  628. headers: {
  629. 'Content-Type': 'application/json',
  630. Cookie: cookie
  631. },
  632. body: JSON.stringify({
  633. from: 'noreply@relay.example',
  634. to: 'user@example.com',
  635. subject: 'Relay send',
  636. text: 'hello',
  637. smtpRelayId: created.relay.id
  638. })
  639. });
  640. assert.equal(send.status, 202);
  641. assert.equal((await send.json()).smtpRelayId, created.relay.id);
  642. await waitForCondition(() => relayServer.messages.length === 1);
  643. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  644. assert.ok(authCommand);
  645. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password');
  646. const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
  647. assert.equal(events.status, 200);
  648. const eventsBody = await events.json();
  649. assert.equal(eventsBody.events[0].smtpRelayId, created.relay.id);
  650. const eventDetail = await fetch(`${baseUrl}/api/events/${eventsBody.events[0].id}`, {
  651. headers: { Cookie: cookie }
  652. });
  653. assert.equal(eventDetail.status, 200);
  654. const eventDetailBody = await eventDetail.json();
  655. assert.equal(eventDetailBody.event.id, eventsBody.events[0].id);
  656. assert.equal(eventDetailBody.event.smtpRelayId, created.relay.id);
  657. assert.equal(Array.isArray(eventDetailBody.event.webhookDeliveries), true);
  658. } finally {
  659. child.kill('SIGTERM');
  660. await waitForExit(child, 1000);
  661. await relayServer.close();
  662. }
  663. });
  664. test('smtp relay selection prefers request relay then domain relay then user default relay', async () => {
  665. const requestRelayServer = await startFakeSmtpServer();
  666. const domainRelayServer = await startFakeSmtpServer();
  667. const defaultRelayServer = await startFakeSmtpServer();
  668. const { child, baseUrl } = await startTestServer();
  669. try {
  670. const cookie = await login(baseUrl, 'admin', 'password123');
  671. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  672. method: 'PATCH',
  673. headers: {
  674. 'Content-Type': 'application/json',
  675. Cookie: cookie
  676. },
  677. body: JSON.stringify({
  678. appBaseUrl: baseUrl,
  679. engagementTrackingEnabled: true
  680. })
  681. });
  682. assert.equal(settings.status, 200);
  683. const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
  684. name: 'Default relay',
  685. host: '127.0.0.1',
  686. port: defaultRelayServer.port,
  687. username: 'default-user',
  688. password: 'default-password',
  689. isDefault: true
  690. });
  691. const domainRelay = await createSmtpRelay(baseUrl, cookie, {
  692. name: 'Domain relay',
  693. host: '127.0.0.1',
  694. port: domainRelayServer.port,
  695. username: 'domain-user',
  696. password: 'domain-password'
  697. });
  698. const requestRelay = await createSmtpRelay(baseUrl, cookie, {
  699. name: 'Request relay',
  700. host: '127.0.0.1',
  701. port: requestRelayServer.port,
  702. username: 'request-user',
  703. password: 'request-password'
  704. });
  705. const domain = await createSendingDomain(baseUrl, cookie, {
  706. domain: 'relay-order.example',
  707. smtpRelayId: domainRelay.id
  708. });
  709. assert.equal(domain.smtpRelayId, domainRelay.id);
  710. const domainSend = await sendApiMail(baseUrl, cookie, {
  711. from: 'noreply@relay-order.example',
  712. to: 'domain@example.com',
  713. subject: 'Domain relay'
  714. });
  715. assert.equal(domainSend.smtpRelayId, domainRelay.id);
  716. await waitForCondition(() => domainRelayServer.messages.length === 1);
  717. assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password');
  718. const requestSend = await sendApiMail(baseUrl, cookie, {
  719. from: 'noreply@relay-order.example',
  720. to: 'request@example.com',
  721. subject: 'Request relay',
  722. smtpRelayId: requestRelay.id
  723. });
  724. assert.equal(requestSend.smtpRelayId, requestRelay.id);
  725. await waitForCondition(() => requestRelayServer.messages.length === 1);
  726. assertRelayAuth(requestRelayServer, 'request-user', 'request-password');
  727. const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  728. method: 'POST',
  729. headers: {
  730. 'Content-Type': 'application/json',
  731. Cookie: cookie
  732. },
  733. body: JSON.stringify({
  734. to: 'test-send@example.com',
  735. subject: 'Selected relay test send',
  736. text: 'Open the HTML version to verify tracking.',
  737. html: '<html><body><p>MailHub tracking test.</p><a href="https://example.net/tracked">Tracked link</a></body></html>',
  738. smtpRelayId: requestRelay.id
  739. })
  740. });
  741. assert.equal(testSend.status, 202);
  742. const testSendBody = await testSend.json();
  743. assert.equal(testSendBody.smtpRelayId, requestRelay.id);
  744. assert.deepEqual(testSendBody.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
  745. await waitForCondition(() => requestRelayServer.messages.length === 2);
  746. const testHtml = decodeHtmlPart(requestRelayServer.messages[1]);
  747. assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
  748. assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
  749. assert.equal(testHtml.includes('https://example.net/tracked'), false);
  750. const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  751. method: 'POST',
  752. headers: {
  753. 'Content-Type': 'application/json',
  754. Cookie: cookie
  755. },
  756. body: JSON.stringify({
  757. to: 'invalid-test-send@example.com',
  758. smtpRelayId: 999999
  759. })
  760. });
  761. assert.equal(invalidTestSend.status, 400);
  762. const defaultDomain = await createSendingDomain(baseUrl, cookie, {
  763. domain: 'default-relay.example'
  764. });
  765. assert.equal(defaultDomain.smtpRelayId, null);
  766. const defaultSend = await sendApiMail(baseUrl, cookie, {
  767. from: 'noreply@default-relay.example',
  768. to: 'default@example.com',
  769. subject: 'Default relay'
  770. });
  771. assert.equal(defaultSend.smtpRelayId, defaultRelay.id);
  772. await waitForCondition(() => defaultRelayServer.messages.length === 1);
  773. assertRelayAuth(defaultRelayServer, 'default-user', 'default-password');
  774. } finally {
  775. child.kill('SIGTERM');
  776. await waitForExit(child, 1000);
  777. await requestRelayServer.close();
  778. await domainRelayServer.close();
  779. await defaultRelayServer.close();
  780. }
  781. });
  782. test('admin users can list audit logs', async () => {
  783. const { child, baseUrl } = await startTestServer();
  784. try {
  785. const cookie = await login(baseUrl, 'admin', 'password123');
  786. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  787. headers: { Cookie: cookie }
  788. });
  789. assert.equal(response.status, 200);
  790. assert.deepEqual(await response.json(), { logs: [] });
  791. } finally {
  792. child.kill('SIGTERM');
  793. await waitForExit(child, 1000);
  794. }
  795. });
  796. test('admin users can list resource inventory', async () => {
  797. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  798. try {
  799. seedUsers(dataDir, sessionSecret, [{
  800. username: 'alice',
  801. email: 'alice@example.com',
  802. password: 'password123',
  803. status: 'active'
  804. }]);
  805. const adminCookie = await login(baseUrl, 'admin', 'password123');
  806. const userCookie = await login(baseUrl, 'alice', 'password123');
  807. const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
  808. headers: { Cookie: userCookie }
  809. });
  810. assert.equal(forbidden.status, 403);
  811. const response = await fetch(`${baseUrl}/api/admin/resources`, {
  812. headers: { Cookie: adminCookie }
  813. });
  814. assert.equal(response.status, 200);
  815. const body = await response.json();
  816. assert.ok(Array.isArray(body.inventory.users));
  817. assert.ok(Array.isArray(body.inventory.warnings));
  818. assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
  819. } finally {
  820. child.kill('SIGTERM');
  821. await waitForExit(child, 1000);
  822. }
  823. });
  824. test('admin users can transfer individual resources', async () => {
  825. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  826. try {
  827. const seeded = seedTransferResources(dataDir, sessionSecret);
  828. const adminCookie = await login(baseUrl, 'admin', 'password123');
  829. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  830. const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  831. method: 'POST',
  832. headers: {
  833. 'Content-Type': 'application/json',
  834. Cookie: aliceCookie
  835. },
  836. body: JSON.stringify({ targetUserId: seeded.bobId })
  837. });
  838. assert.equal(forbidden.status, 403);
  839. const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  840. method: 'POST',
  841. headers: {
  842. 'Content-Type': 'application/json',
  843. Cookie: adminCookie
  844. },
  845. body: JSON.stringify({
  846. targetUserId: seeded.bobId,
  847. dnsCredentialMode: 'with_dns_credential'
  848. })
  849. });
  850. assert.equal(domain.status, 200);
  851. const domainBody = await domain.json();
  852. assert.equal(domainBody.domain.userId, seeded.bobId);
  853. assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
  854. const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
  855. method: 'POST',
  856. headers: {
  857. 'Content-Type': 'application/json',
  858. Cookie: adminCookie
  859. },
  860. body: JSON.stringify({ targetUserId: seeded.bobId })
  861. });
  862. assert.equal(dns.status, 200);
  863. assert.equal((await dns.json()).credential.userId, seeded.bobId);
  864. const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
  865. method: 'POST',
  866. headers: {
  867. 'Content-Type': 'application/json',
  868. Cookie: adminCookie
  869. },
  870. body: JSON.stringify({
  871. targetUserId: seeded.bobId,
  872. tokenIds: [seeded.apiTokenId]
  873. })
  874. });
  875. assert.equal(tokens.status, 200);
  876. const tokensBody = await tokens.json();
  877. assert.equal(tokensBody.tokens.length, 1);
  878. assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
  879. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
  880. headers: { Cookie: adminCookie }
  881. });
  882. const actions = (await audit.json()).logs.map((entry) => entry.action);
  883. assert.ok(actions.includes('admin.transfer_domain'));
  884. assert.ok(actions.includes('admin.transfer_dns_credential'));
  885. assert.ok(actions.includes('admin.transfer_api_tokens'));
  886. } finally {
  887. child.kill('SIGTERM');
  888. await waitForExit(child, 1000);
  889. }
  890. });
  891. test('admin users can preview and execute user merge', async () => {
  892. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  893. try {
  894. const seeded = seedMergeResources(dataDir, sessionSecret);
  895. const adminCookie = await login(baseUrl, 'admin', 'password123');
  896. const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
  897. method: 'POST',
  898. headers: {
  899. 'Content-Type': 'application/json',
  900. Cookie: adminCookie
  901. },
  902. body: JSON.stringify({
  903. sourceUserId: seeded.sourceId,
  904. targetUserId: seeded.targetId
  905. })
  906. });
  907. assert.equal(previewResponse.status, 200);
  908. const preview = (await previewResponse.json()).preview;
  909. assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
  910. assert.equal(preview.counts.domains, 1);
  911. const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  912. method: 'POST',
  913. headers: {
  914. 'Content-Type': 'application/json',
  915. Cookie: adminCookie
  916. },
  917. body: JSON.stringify({
  918. sourceUserId: seeded.sourceId,
  919. targetUserId: seeded.targetId,
  920. confirmation: 'wrong'
  921. })
  922. });
  923. assert.equal(invalid.status, 400);
  924. const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  925. method: 'POST',
  926. headers: {
  927. 'Content-Type': 'application/json',
  928. Cookie: adminCookie
  929. },
  930. body: JSON.stringify({
  931. sourceUserId: seeded.sourceId,
  932. targetUserId: seeded.targetId,
  933. confirmation: preview.confirmationText
  934. })
  935. });
  936. assert.equal(execute.status, 200);
  937. const result = (await execute.json()).result;
  938. assert.equal(result.counts.domains, 1);
  939. assert.equal(result.sourceUser.status, 'disabled');
  940. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
  941. headers: { Cookie: adminCookie }
  942. });
  943. assert.equal(audit.status, 200);
  944. assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
  945. } finally {
  946. child.kill('SIGTERM');
  947. await waitForExit(child, 1000);
  948. }
  949. });
  950. test('admin users can manage system email settings without exposing password', async () => {
  951. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  952. try {
  953. seedUsers(dataDir, sessionSecret, [{
  954. username: 'alice',
  955. email: 'alice@example.com',
  956. password: 'password123',
  957. status: 'active'
  958. }]);
  959. const adminCookie = await login(baseUrl, 'admin', 'password123');
  960. const userCookie = await login(baseUrl, 'alice', 'password123');
  961. const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
  962. headers: { Cookie: userCookie }
  963. });
  964. assert.equal(forbidden.status, 403);
  965. const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
  966. headers: { Cookie: adminCookie }
  967. });
  968. assert.equal(empty.status, 200);
  969. assert.equal((await empty.json()).settings.passwordSet, false);
  970. const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
  971. method: 'PATCH',
  972. headers: {
  973. 'Content-Type': 'application/json',
  974. Cookie: adminCookie
  975. },
  976. body: JSON.stringify({
  977. host: 'smtp.example.com',
  978. port: 587,
  979. secure: false,
  980. username: 'mailer@example.com',
  981. password: 'smtp-password-123',
  982. helo: 'mail.example.com',
  983. fromEmail: 'notify@example.com',
  984. fromName: 'MailHub Notify',
  985. testRecipient: 'admin@example.com'
  986. })
  987. });
  988. assert.equal(saved.status, 200);
  989. const savedBody = await saved.json();
  990. assert.equal(savedBody.settings.host, 'smtp.example.com');
  991. assert.equal(savedBody.settings.port, 587);
  992. assert.equal(savedBody.settings.secure, false);
  993. assert.equal(savedBody.settings.passwordSet, true);
  994. assert.equal('password' in savedBody.settings, false);
  995. assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
  996. const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
  997. method: 'PATCH',
  998. headers: {
  999. 'Content-Type': 'application/json',
  1000. Cookie: adminCookie
  1001. },
  1002. body: JSON.stringify({
  1003. host: 'smtp2.example.com',
  1004. password: ''
  1005. })
  1006. });
  1007. assert.equal(preserved.status, 200);
  1008. const preservedBody = await preserved.json();
  1009. assert.equal(preservedBody.settings.host, 'smtp2.example.com');
  1010. assert.equal(preservedBody.settings.passwordSet, true);
  1011. assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
  1012. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
  1013. headers: { Cookie: adminCookie }
  1014. });
  1015. assert.equal(audit.status, 200);
  1016. const [entry] = (await audit.json()).logs;
  1017. assert.equal(entry.action, 'admin.update_system_email');
  1018. assert.equal(entry.targetType, 'system_email');
  1019. assert.equal(entry.summary.host, 'smtp2.example.com');
  1020. assert.equal(entry.summary.password, undefined);
  1021. assert.equal(entry.summary.passwordSet, true);
  1022. } finally {
  1023. child.kill('SIGTERM');
  1024. await waitForExit(child, 1000);
  1025. }
  1026. });
  1027. test('registration and verification resend use configured system email', async () => {
  1028. const smtp = await startFakeSmtpServer();
  1029. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1030. try {
  1031. seedUsers(dataDir, sessionSecret, [
  1032. { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
  1033. { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
  1034. ]);
  1035. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1036. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  1037. const register = await fetch(`${baseUrl}/api/register`, {
  1038. method: 'POST',
  1039. headers: { 'Content-Type': 'application/json' },
  1040. body: JSON.stringify({
  1041. username: 'mailuser',
  1042. email: 'mailuser@example.com',
  1043. password: 'password123'
  1044. })
  1045. });
  1046. assert.equal(register.status, 201);
  1047. const registerBody = await register.json();
  1048. assert.equal(registerBody.user.status, 'pending_email');
  1049. assert.equal(registerBody.verificationEmailSent, true);
  1050. assert.equal(registerBody.message, '注册成功,验证邮件已发送,请先验证邮箱。');
  1051. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
  1052. const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  1053. method: 'POST',
  1054. headers: { 'Content-Type': 'application/json' },
  1055. body: JSON.stringify({ email: 'publicpending@example.com' })
  1056. });
  1057. assert.equal(publicResend.status, 202);
  1058. const publicResendBody = await publicResend.json();
  1059. assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
  1060. assert.equal('verificationEmailSent' in publicResendBody, false);
  1061. assert.equal('result' in publicResendBody, false);
  1062. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
  1063. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1064. headers: { Cookie: adminCookie }
  1065. });
  1066. const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
  1067. assert.ok(adminPending);
  1068. const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
  1069. method: 'POST',
  1070. headers: { Cookie: adminCookie }
  1071. });
  1072. assert.equal(adminResend.status, 202);
  1073. assert.equal((await adminResend.json()).verificationEmailSent, true);
  1074. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
  1075. assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
  1076. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
  1077. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
  1078. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
  1079. assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
  1080. } finally {
  1081. child.kill('SIGTERM');
  1082. await waitForExit(child, 1000);
  1083. await smtp.close();
  1084. }
  1085. });
  1086. test('public verification resend is generic and does not create tokens without mail config', async () => {
  1087. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1088. try {
  1089. seedUsers(dataDir, sessionSecret, [{
  1090. username: 'pendingnomail',
  1091. email: 'pendingnomail@example.com',
  1092. password: 'password123',
  1093. status: 'pending_email'
  1094. }]);
  1095. const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  1096. method: 'POST',
  1097. headers: { 'Content-Type': 'application/json' },
  1098. body: JSON.stringify({ email: 'pendingnomail@example.com' })
  1099. });
  1100. assert.equal(response.status, 202);
  1101. assert.deepEqual(await response.json(), {
  1102. message: '如果账号需要验证,我们会发送验证邮件。'
  1103. });
  1104. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
  1105. } finally {
  1106. child.kill('SIGTERM');
  1107. await waitForExit(child, 1000);
  1108. }
  1109. });
  1110. test('registration reports pending email when system email is not configured', async () => {
  1111. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1112. try {
  1113. const register = await fetch(`${baseUrl}/api/register`, {
  1114. method: 'POST',
  1115. headers: { 'Content-Type': 'application/json' },
  1116. body: JSON.stringify({
  1117. username: 'nomailuser',
  1118. email: 'nomailuser@example.com',
  1119. password: 'password123'
  1120. })
  1121. });
  1122. assert.equal(register.status, 201);
  1123. const body = await register.json();
  1124. assert.equal(body.user.status, 'pending_email');
  1125. assert.equal(body.verificationEmailSent, false);
  1126. assert.match(body.message, /验证邮件暂未发送/);
  1127. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
  1128. } finally {
  1129. child.kill('SIGTERM');
  1130. await waitForExit(child, 1000);
  1131. }
  1132. });
  1133. test('admin users can send system email test messages', async () => {
  1134. const smtp = await startFakeSmtpServer();
  1135. const { child, baseUrl } = await startTestServer();
  1136. try {
  1137. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1138. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  1139. const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
  1140. method: 'POST',
  1141. headers: {
  1142. 'Content-Type': 'application/json',
  1143. Cookie: adminCookie
  1144. },
  1145. body: JSON.stringify({ to: 'operator@example.com' })
  1146. });
  1147. assert.equal(response.status, 202);
  1148. const body = await response.json();
  1149. assert.equal(body.result.ok, true);
  1150. assert.equal(body.result.queueId, 'SYS123');
  1151. assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
  1152. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
  1153. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
  1154. headers: { Cookie: adminCookie }
  1155. });
  1156. assert.equal(audit.status, 200);
  1157. const [entry] = (await audit.json()).logs;
  1158. assert.equal(entry.targetType, 'system_email');
  1159. assert.equal(entry.summary.to, 'operator@example.com');
  1160. assert.equal(entry.summary.ok, true);
  1161. } finally {
  1162. child.kill('SIGTERM');
  1163. await waitForExit(child, 1000);
  1164. await smtp.close();
  1165. }
  1166. });
  1167. test('public forgot password is generic and sends reset email when configured', async () => {
  1168. const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
  1169. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1170. try {
  1171. seedUsers(dataDir, sessionSecret, [{
  1172. username: 'resetme',
  1173. email: 'resetme@example.com',
  1174. password: 'password123',
  1175. status: 'active'
  1176. }]);
  1177. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1178. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  1179. const startedAt = Date.now();
  1180. const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  1181. method: 'POST',
  1182. headers: { 'Content-Type': 'application/json' },
  1183. body: JSON.stringify({ email: 'resetme@example.com' })
  1184. });
  1185. const elapsedMs = Date.now() - startedAt;
  1186. assert.equal(existing.status, 202);
  1187. assert.equal(elapsedMs < 500, true);
  1188. assert.deepEqual(await existing.json(), {
  1189. message: '如果邮箱存在,我们会发送密码重置邮件。'
  1190. });
  1191. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
  1192. const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  1193. method: 'POST',
  1194. headers: { 'Content-Type': 'application/json' },
  1195. body: JSON.stringify({ email: 'missing@example.com' })
  1196. });
  1197. assert.equal(missing.status, 202);
  1198. assert.deepEqual(await missing.json(), {
  1199. message: '如果邮箱存在,我们会发送密码重置邮件。'
  1200. });
  1201. await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
  1202. } finally {
  1203. child.kill('SIGTERM');
  1204. await waitForExit(child, 1000);
  1205. await smtp.close();
  1206. }
  1207. });
  1208. test('public reset password consumes token and updates password', async () => {
  1209. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1210. try {
  1211. seedUsers(dataDir, sessionSecret, [{
  1212. username: 'tokenreset',
  1213. email: 'tokenreset@example.com',
  1214. password: 'password123',
  1215. status: 'active'
  1216. }]);
  1217. const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
  1218. const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
  1219. method: 'POST',
  1220. headers: { 'Content-Type': 'application/json' },
  1221. body: JSON.stringify({
  1222. token,
  1223. password: 'new-password-123'
  1224. })
  1225. });
  1226. assert.equal(response.status, 200);
  1227. assert.deepEqual(await response.json(), {
  1228. message: '密码已重置,请使用新密码登录。'
  1229. });
  1230. const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
  1231. assert.equal(oldLogin.status, 401);
  1232. const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
  1233. assert.equal(newLogin.status, 200);
  1234. const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
  1235. method: 'POST',
  1236. headers: { 'Content-Type': 'application/json' },
  1237. body: JSON.stringify({
  1238. token,
  1239. password: 'another-password-123'
  1240. })
  1241. });
  1242. assert.equal(reused.status, 400);
  1243. } finally {
  1244. child.kill('SIGTERM');
  1245. await waitForExit(child, 1000);
  1246. }
  1247. });
  1248. test('admin users can trigger password reset email and set temporary password', async () => {
  1249. const smtp = await startFakeSmtpServer();
  1250. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1251. try {
  1252. seedUsers(dataDir, sessionSecret, [
  1253. { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
  1254. { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
  1255. ]);
  1256. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1257. const memberCookie = await login(baseUrl, 'member2', 'password123');
  1258. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  1259. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1260. headers: { Cookie: adminCookie }
  1261. });
  1262. const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
  1263. assert.ok(target);
  1264. const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  1265. method: 'POST',
  1266. headers: { Cookie: memberCookie }
  1267. });
  1268. assert.equal(forbiddenReset.status, 403);
  1269. const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  1270. method: 'POST',
  1271. headers: { Cookie: adminCookie }
  1272. });
  1273. assert.equal(reset.status, 202);
  1274. assert.equal((await reset.json()).result.ok, true);
  1275. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
  1276. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
  1277. const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  1278. method: 'POST',
  1279. headers: {
  1280. 'Content-Type': 'application/json',
  1281. Cookie: memberCookie
  1282. },
  1283. body: JSON.stringify({ password: 'temporary-123' })
  1284. });
  1285. assert.equal(forbiddenTemporary.status, 403);
  1286. const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  1287. method: 'POST',
  1288. headers: {
  1289. 'Content-Type': 'application/json',
  1290. Cookie: adminCookie
  1291. },
  1292. body: JSON.stringify({ password: 'temporary-123' })
  1293. });
  1294. assert.equal(temporary.status, 200);
  1295. assert.equal((await temporary.json()).user.id, target.id);
  1296. assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
  1297. const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
  1298. assert.equal(oldLogin.status, 401);
  1299. const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
  1300. assert.equal(tempLogin.status, 200);
  1301. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
  1302. headers: { Cookie: adminCookie }
  1303. });
  1304. assert.equal(audit.status, 200);
  1305. const logs = (await audit.json()).logs;
  1306. assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
  1307. const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
  1308. assert.ok(temporaryLog);
  1309. assert.equal(temporaryLog.summary.username, 'targetuser');
  1310. assert.equal(temporaryLog.summary.password, undefined);
  1311. assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
  1312. } finally {
  1313. child.kill('SIGTERM');
  1314. await waitForExit(child, 1000);
  1315. await smtp.close();
  1316. }
  1317. });
  1318. test('non-admin users cannot list audit logs', async () => {
  1319. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1320. try {
  1321. seedUsers(dataDir, sessionSecret, [{
  1322. username: 'alice',
  1323. email: 'alice@example.com',
  1324. password: 'password123',
  1325. status: 'active'
  1326. }]);
  1327. const cookie = await login(baseUrl, 'alice', 'password123');
  1328. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  1329. headers: { Cookie: cookie }
  1330. });
  1331. assert.equal(response.status, 403);
  1332. } finally {
  1333. child.kill('SIGTERM');
  1334. await waitForExit(child, 1000);
  1335. }
  1336. });
  1337. test('admin user patch rejects invalid status with a bad request', async () => {
  1338. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1339. try {
  1340. seedUsers(dataDir, sessionSecret, [{
  1341. username: 'badstatus',
  1342. email: 'badstatus@example.com',
  1343. password: 'password123',
  1344. status: 'active'
  1345. }]);
  1346. const cookie = await login(baseUrl, 'admin', 'password123');
  1347. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1348. headers: { Cookie: cookie }
  1349. });
  1350. assert.equal(usersResponse.status, 200);
  1351. const usersBody = await usersResponse.json();
  1352. const target = usersBody.users.find((user) => user.username === 'badstatus');
  1353. assert.ok(target);
  1354. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  1355. method: 'PATCH',
  1356. headers: {
  1357. 'Content-Type': 'application/json',
  1358. Cookie: cookie
  1359. },
  1360. body: JSON.stringify({ status: 'archived' })
  1361. });
  1362. assert.equal(response.status, 400);
  1363. assert.equal((await response.json()).error, '用户状态不正确。');
  1364. const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  1365. method: 'PATCH',
  1366. headers: {
  1367. 'Content-Type': 'application/json',
  1368. Cookie: cookie
  1369. },
  1370. body: JSON.stringify({ password: 'short' })
  1371. });
  1372. assert.equal(shortPassword.status, 400);
  1373. assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
  1374. } finally {
  1375. child.kill('SIGTERM');
  1376. await waitForExit(child, 1000);
  1377. }
  1378. });
  1379. test('self registration creates a pending email user and verification token without a session', async () => {
  1380. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1381. try {
  1382. const register = await fetch(`${baseUrl}/api/register`, {
  1383. method: 'POST',
  1384. headers: { 'Content-Type': 'application/json' },
  1385. body: JSON.stringify({
  1386. username: 'newuser',
  1387. email: 'newuser@example.com',
  1388. password: 'password123'
  1389. })
  1390. });
  1391. assert.equal(register.status, 201);
  1392. assert.equal(sessionCookieFrom(register), '');
  1393. const text = await register.text();
  1394. assert.doesNotMatch(text, /token/i);
  1395. const body = JSON.parse(text);
  1396. assert.equal('token' in body, false);
  1397. assert.equal('token' in body.user, false);
  1398. assert.equal('tokenHash' in body.user, false);
  1399. assert.equal(body.user.status, 'pending_email');
  1400. assert.match(body.message, /验证邮箱/);
  1401. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
  1402. const login = await loginResponse(baseUrl, 'newuser', 'password123');
  1403. assert.equal(login.status, 403);
  1404. assert.equal(sessionCookieFrom(login), '');
  1405. assert.equal((await login.json()).error, '请先验证邮箱。');
  1406. } finally {
  1407. child.kill('SIGTERM');
  1408. await waitForExit(child, 1000);
  1409. }
  1410. });
  1411. test('email verification activates users by default and redirects browsers with an exact success message', async () => {
  1412. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1413. try {
  1414. const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1415. username: 'verifyme',
  1416. email: 'verifyme@example.com',
  1417. password: 'password123',
  1418. status: 'pending_email'
  1419. });
  1420. const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
  1421. assert.equal(missing.status, 400);
  1422. assert.equal(sessionCookieFrom(missing), '');
  1423. const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
  1424. assert.equal(invalid.status, 400);
  1425. assert.equal(sessionCookieFrom(invalid), '');
  1426. const htmlInvalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`, {
  1427. redirect: 'manual',
  1428. headers: { Accept: 'text/html,application/xhtml+xml' }
  1429. });
  1430. assert.equal(htmlInvalid.status, 303);
  1431. assert.match(htmlInvalid.headers.get('location') || '', /^\/login\?error=/);
  1432. assert.equal(sessionCookieFrom(htmlInvalid), '');
  1433. const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1434. username: 'verifybrowser',
  1435. email: 'verifybrowser@example.com',
  1436. password: 'password123',
  1437. status: 'pending_email'
  1438. });
  1439. const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
  1440. redirect: 'manual',
  1441. headers: { Accept: 'text/html,application/xhtml+xml' }
  1442. });
  1443. assert.equal(browserResponse.status, 303);
  1444. assert.equal(
  1445. browserResponse.headers.get('location'),
  1446. `/login?message=${encodeURIComponent('邮箱验证成功,现在可以登录。')}`
  1447. );
  1448. assert.equal(sessionCookieFrom(browserResponse), '');
  1449. const browserLogin = await loginResponse(baseUrl, 'verifybrowser', 'password123');
  1450. assert.equal(browserLogin.status, 200);
  1451. assert.ok(sessionCookieFrom(browserLogin));
  1452. const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1453. assert.equal(response.status, 200);
  1454. assert.equal(sessionCookieFrom(response), '');
  1455. const body = await response.json();
  1456. assert.equal(body.user.id, created.user.id);
  1457. assert.equal(body.user.status, 'active');
  1458. assert.equal(body.message, '邮箱验证成功,现在可以登录。');
  1459. const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1460. assert.equal(reused.status, 400);
  1461. assert.equal(sessionCookieFrom(reused), '');
  1462. const verifiedLogin = await loginResponse(baseUrl, 'verifyme', 'password123');
  1463. assert.equal(verifiedLogin.status, 200);
  1464. assert.ok(sessionCookieFrom(verifiedLogin));
  1465. const alreadyPendingReview = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1466. username: 'alreadyreview',
  1467. email: 'alreadyreview@example.com',
  1468. password: 'password123',
  1469. status: 'pending_review'
  1470. });
  1471. const pendingReviewResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyPendingReview.token)}`);
  1472. assert.equal(pendingReviewResponse.status, 200);
  1473. const pendingReviewBody = await pendingReviewResponse.json();
  1474. assert.equal(pendingReviewBody.user.status, 'pending_review');
  1475. assert.equal(pendingReviewBody.message, '邮箱验证成功,请等待管理员审核。');
  1476. const disabled = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1477. username: 'disabledverify',
  1478. email: 'disabledverify@example.com',
  1479. password: 'password123',
  1480. status: 'disabled'
  1481. });
  1482. const disabledResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(disabled.token)}`);
  1483. assert.equal(disabledResponse.status, 400);
  1484. assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'disabledverify', 'email_verification'), 1);
  1485. } finally {
  1486. child.kill('SIGTERM');
  1487. await waitForExit(child, 1000);
  1488. }
  1489. });
  1490. test('admin registration approval setting persists and keeps verified users pending review', async () => {
  1491. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1492. try {
  1493. seedUsers(dataDir, sessionSecret, [
  1494. { username: 'settingsmember', email: 'settingsmember@example.com', password: 'password123', status: 'active' }
  1495. ]);
  1496. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1497. const memberCookie = await login(baseUrl, 'settingsmember', 'password123');
  1498. const defaultsResponse = await fetch(`${baseUrl}/api/admin/settings`, {
  1499. headers: { Cookie: adminCookie }
  1500. });
  1501. assert.equal(defaultsResponse.status, 200);
  1502. assert.equal((await defaultsResponse.json()).settings.registrationRequiresApproval, false);
  1503. const forbiddenRead = await fetch(`${baseUrl}/api/admin/settings`, {
  1504. headers: { Cookie: memberCookie }
  1505. });
  1506. assert.equal(forbiddenRead.status, 403);
  1507. const forbidden = await fetch(`${baseUrl}/api/admin/settings`, {
  1508. method: 'PATCH',
  1509. headers: {
  1510. 'Content-Type': 'application/json',
  1511. Cookie: memberCookie
  1512. },
  1513. body: JSON.stringify({ registrationRequiresApproval: true })
  1514. });
  1515. assert.equal(forbidden.status, 403);
  1516. const unchangedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
  1517. headers: { Cookie: adminCookie }
  1518. });
  1519. assert.equal((await unchangedResponse.json()).settings.registrationRequiresApproval, false);
  1520. const enabledResponse = await fetch(`${baseUrl}/api/admin/settings`, {
  1521. method: 'PATCH',
  1522. headers: {
  1523. 'Content-Type': 'application/json',
  1524. Cookie: adminCookie
  1525. },
  1526. body: JSON.stringify({ registrationRequiresApproval: true })
  1527. });
  1528. assert.equal(enabledResponse.status, 200);
  1529. assert.equal((await enabledResponse.json()).settings.registrationRequiresApproval, true);
  1530. const persistedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
  1531. headers: { Cookie: adminCookie }
  1532. });
  1533. assert.equal(persistedResponse.status, 200);
  1534. assert.equal((await persistedResponse.json()).settings.registrationRequiresApproval, true);
  1535. const register = await fetch(`${baseUrl}/api/register`, {
  1536. method: 'POST',
  1537. headers: { 'Content-Type': 'application/json' },
  1538. body: JSON.stringify({
  1539. username: 'reviewregistration',
  1540. email: 'reviewregistration@example.com',
  1541. password: 'password123'
  1542. })
  1543. });
  1544. assert.equal(register.status, 201);
  1545. const registerBody = await register.json();
  1546. assert.equal(registerBody.user.status, 'pending_email');
  1547. assert.doesNotMatch(registerBody.message, /管理员审核|即可登录/);
  1548. const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1549. username: 'reviewrequired',
  1550. email: 'reviewrequired@example.com',
  1551. password: 'password123',
  1552. status: 'pending_email'
  1553. });
  1554. const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1555. assert.equal(response.status, 200);
  1556. assert.equal(sessionCookieFrom(response), '');
  1557. const body = await response.json();
  1558. assert.equal(body.user.status, 'pending_review');
  1559. assert.equal(body.message, '邮箱验证成功,请等待管理员审核。');
  1560. const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1561. username: 'reviewbrowser',
  1562. email: 'reviewbrowser@example.com',
  1563. password: 'password123',
  1564. status: 'pending_email'
  1565. });
  1566. const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
  1567. redirect: 'manual',
  1568. headers: { Accept: 'text/html,application/xhtml+xml' }
  1569. });
  1570. assert.equal(browserResponse.status, 303);
  1571. assert.equal(
  1572. browserResponse.headers.get('location'),
  1573. `/login?message=${encodeURIComponent('邮箱验证成功,请等待管理员审核。')}`
  1574. );
  1575. assert.equal(sessionCookieFrom(browserResponse), '');
  1576. const alreadyActive = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1577. username: 'alreadyactive',
  1578. email: 'alreadyactive@example.com',
  1579. password: 'password123',
  1580. status: 'active'
  1581. });
  1582. const activeResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyActive.token)}`);
  1583. assert.equal(activeResponse.status, 200);
  1584. const activeBody = await activeResponse.json();
  1585. assert.equal(activeBody.user.status, 'active');
  1586. assert.equal(activeBody.message, '邮箱验证成功,现在可以登录。');
  1587. await assertLoginDeniedByStatus(baseUrl, 'reviewrequired', '账号正在等待管理员审核。');
  1588. await assertLoginDeniedByStatus(baseUrl, 'reviewbrowser', '账号正在等待管理员审核。');
  1589. } finally {
  1590. child.kill('SIGTERM');
  1591. await waitForExit(child, 1000);
  1592. }
  1593. });
  1594. test('admin users can approve pending review users with an audit log', async () => {
  1595. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1596. try {
  1597. seedUsers(dataDir, sessionSecret, [
  1598. { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
  1599. { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
  1600. { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
  1601. { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
  1602. ]);
  1603. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1604. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1605. headers: { Cookie: adminCookie }
  1606. });
  1607. assert.equal(usersResponse.status, 200);
  1608. const users = (await usersResponse.json()).users;
  1609. const target = users.find((user) => user.username === 'reviewme');
  1610. const pendingEmail = users.find((user) => user.username === 'emailonly');
  1611. const disabled = users.find((user) => user.username === 'disabledreview');
  1612. assert.ok(target);
  1613. assert.ok(pendingEmail);
  1614. assert.ok(disabled);
  1615. const memberCookie = await login(baseUrl, 'member', 'password123');
  1616. const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1617. method: 'POST',
  1618. headers: { Cookie: memberCookie }
  1619. });
  1620. assert.equal(nonAdmin.status, 403);
  1621. const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
  1622. method: 'POST',
  1623. headers: { Cookie: adminCookie }
  1624. });
  1625. assert.equal(missing.status, 404);
  1626. const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
  1627. method: 'POST',
  1628. headers: { Cookie: adminCookie }
  1629. });
  1630. assert.equal(pendingEmailResponse.status, 400);
  1631. assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
  1632. const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
  1633. method: 'POST',
  1634. headers: { Cookie: adminCookie }
  1635. });
  1636. assert.equal(disabledResponse.status, 400);
  1637. assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
  1638. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1639. method: 'POST',
  1640. headers: { Cookie: adminCookie }
  1641. });
  1642. assert.equal(response.status, 200);
  1643. const body = await response.json();
  1644. assert.equal(body.user.id, target.id);
  1645. assert.equal(body.user.status, 'active');
  1646. const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
  1647. assert.ok(approvedCookie);
  1648. const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
  1649. headers: { Cookie: adminCookie }
  1650. });
  1651. assert.equal(auditResponse.status, 200);
  1652. const [entry] = (await auditResponse.json()).logs;
  1653. assert.equal(entry.action, 'admin.approve_user');
  1654. assert.equal(entry.targetType, 'user');
  1655. assert.equal(entry.targetId, String(target.id));
  1656. assert.equal(entry.targetUserId, target.id);
  1657. assert.equal(entry.summary.username, 'reviewme');
  1658. assert.equal(entry.summary.status, 'active');
  1659. assert.equal(entry.summary.password, undefined);
  1660. assert.equal(entry.summary.token, undefined);
  1661. } finally {
  1662. child.kill('SIGTERM');
  1663. await waitForExit(child, 1000);
  1664. }
  1665. });
  1666. test('login returns account status restrictions only after password verification', async () => {
  1667. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1668. try {
  1669. seedUsers(dataDir, sessionSecret, [
  1670. { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
  1671. { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
  1672. { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
  1673. { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
  1674. ]);
  1675. await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
  1676. await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
  1677. await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
  1678. const active = await loginResponse(baseUrl, 'activeuser', 'password123');
  1679. assert.equal(active.status, 200);
  1680. assert.ok(sessionCookieFrom(active));
  1681. } finally {
  1682. child.kill('SIGTERM');
  1683. await waitForExit(child, 1000);
  1684. }
  1685. });
  1686. test('admin audit log actor filter rejects non-decimal user ids', async () => {
  1687. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1688. try {
  1689. seedAuditLogs(dataDir, sessionSecret);
  1690. const cookie = await login(baseUrl, 'admin', 'password123');
  1691. assert.deepEqual(
  1692. await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
  1693. ['audit.actor-one']
  1694. );
  1695. assert.deepEqual(
  1696. await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
  1697. ['audit.actor-one-hundred', 'audit.actor-one']
  1698. );
  1699. } finally {
  1700. child.kill('SIGTERM');
  1701. await waitForExit(child, 1000);
  1702. }
  1703. });
  1704. test('admin audit log date filter ignores invalid dates', async () => {
  1705. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1706. try {
  1707. seedAuditLogs(dataDir, sessionSecret);
  1708. const cookie = await login(baseUrl, 'admin', 'password123');
  1709. assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
  1710. assert.deepEqual(
  1711. await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
  1712. ['audit.actor-one-hundred', 'audit.actor-one']
  1713. );
  1714. } finally {
  1715. child.kill('SIGTERM');
  1716. await waitForExit(child, 1000);
  1717. }
  1718. });
  1719. async function startTestServer() {
  1720. const port = await freePort();
  1721. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
  1722. const sessionSecret = 'test-session-secret';
  1723. const child = spawn(process.execPath, ['src/server.js'], {
  1724. cwd: process.cwd(),
  1725. env: {
  1726. ...process.env,
  1727. PORT: String(port),
  1728. DATA_DIR: dataDir,
  1729. ADMIN_PASSWORD: 'password123',
  1730. SESSION_SECRET: sessionSecret,
  1731. REGISTRATION_REQUIRES_APPROVAL: 'false',
  1732. DNS_AUTO_CHECK_ENABLED: 'false',
  1733. SUBMISSION_ENABLED: 'false',
  1734. IMAP_ENABLED: 'false',
  1735. POP3_ENABLED: 'false'
  1736. },
  1737. stdio: ['ignore', 'pipe', 'pipe']
  1738. });
  1739. await waitForOutput(child, 'MailHub listening');
  1740. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  1741. }
  1742. async function login(baseUrl, username, password) {
  1743. const response = await loginResponse(baseUrl, username, password);
  1744. assert.equal(response.status, 200);
  1745. const cookie = sessionCookieFrom(response);
  1746. assert.ok(cookie);
  1747. return cookie;
  1748. }
  1749. function loginResponse(baseUrl, username, password) {
  1750. return fetch(`${baseUrl}/api/login`, {
  1751. method: 'POST',
  1752. headers: { 'Content-Type': 'application/json' },
  1753. body: JSON.stringify({ username, password })
  1754. });
  1755. }
  1756. async function assertLoginDeniedByStatus(baseUrl, username, message) {
  1757. const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
  1758. assert.equal(wrongPassword.status, 401);
  1759. assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
  1760. assert.equal(sessionCookieFrom(wrongPassword), '');
  1761. const correctPassword = await loginResponse(baseUrl, username, 'password123');
  1762. assert.equal(correctPassword.status, 403);
  1763. assert.equal((await correctPassword.json()).error, message);
  1764. assert.equal(sessionCookieFrom(correctPassword), '');
  1765. }
  1766. function sessionCookieFrom(response) {
  1767. return response.headers.get('set-cookie')?.split(';')[0] || '';
  1768. }
  1769. function seedUsers(dataDir, sessionSecret, users) {
  1770. const script = `
  1771. import { initDatabase, createUser } from './src/db.js';
  1772. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1773. for (const user of JSON.parse(process.env.SEED_USERS)) {
  1774. createUser(user);
  1775. }
  1776. `;
  1777. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1778. cwd: process.cwd(),
  1779. env: {
  1780. ...process.env,
  1781. DATA_DIR: dataDir,
  1782. SESSION_SECRET: sessionSecret,
  1783. SEED_USERS: JSON.stringify(users)
  1784. },
  1785. encoding: 'utf8'
  1786. });
  1787. assert.equal(result.status, 0, result.stderr || result.stdout);
  1788. }
  1789. function seedInboundMessage(dataDir, sessionSecret, address) {
  1790. const script = `
  1791. import {
  1792. createInboundMessage,
  1793. getInboundMailboxByAddress,
  1794. initDatabase
  1795. } from './src/db.js';
  1796. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1797. const mailbox = getInboundMailboxByAddress(process.env.INBOUND_ADDRESS);
  1798. const message = createInboundMessage(mailbox, {
  1799. sender: 'alice@example.net',
  1800. recipients: [process.env.INBOUND_ADDRESS],
  1801. subject: 'Inbound API message',
  1802. messageId: '<inbound-api@example.net>',
  1803. rawMessage: [
  1804. 'From: Alice <alice@example.net>',
  1805. 'To: Support <' + process.env.INBOUND_ADDRESS + '>',
  1806. 'Subject: Inbound API message',
  1807. '',
  1808. 'Hello from inbound API.'
  1809. ].join('\\r\\n'),
  1810. textBody: 'Hello from inbound API.'
  1811. });
  1812. console.log(String(message.id));
  1813. `;
  1814. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1815. cwd: process.cwd(),
  1816. env: {
  1817. ...process.env,
  1818. DATA_DIR: dataDir,
  1819. SESSION_SECRET: sessionSecret,
  1820. INBOUND_ADDRESS: address
  1821. },
  1822. encoding: 'utf8'
  1823. });
  1824. assert.equal(result.status, 0, result.stderr || result.stdout);
  1825. return Number(result.stdout.trim());
  1826. }
  1827. function seedTransferResources(dataDir, sessionSecret) {
  1828. const script = `
  1829. import {
  1830. initDatabase,
  1831. createApiToken,
  1832. createDomain,
  1833. createUser,
  1834. saveDnsCredential
  1835. } from './src/db.js';
  1836. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1837. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
  1838. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
  1839. const credential = saveDnsCredential(alice.id, {
  1840. name: 'Alice DNS',
  1841. provider: 'cloudflare',
  1842. zoneName: 'alice.example',
  1843. credentials: { apiToken: 'secret-token' }
  1844. });
  1845. const standaloneCredential = saveDnsCredential(alice.id, {
  1846. name: 'Standalone DNS',
  1847. provider: 'cloudflare',
  1848. zoneName: 'standalone.example',
  1849. credentials: { apiToken: 'standalone-secret-token' }
  1850. });
  1851. const domain = createDomain(alice.id, {
  1852. dnsCredentialId: credential.id,
  1853. domain: 'alice.example',
  1854. selector: 'mh202607',
  1855. verificationToken: 'token',
  1856. dkimPublic: 'public',
  1857. dkimPrivate: 'private',
  1858. senderHost: 'mail.alice.example',
  1859. sendingIp: '127.0.0.1',
  1860. spfExtra: '',
  1861. dmarcPolicy: 'none',
  1862. dmarcRua: ''
  1863. });
  1864. const apiToken = createApiToken(alice.id, 'primary');
  1865. console.log(JSON.stringify({
  1866. aliceId: alice.id,
  1867. bobId: bob.id,
  1868. domainId: domain.id,
  1869. credentialId: credential.id,
  1870. standaloneCredentialId: standaloneCredential.id,
  1871. apiTokenId: apiToken.id
  1872. }));
  1873. `;
  1874. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1875. cwd: process.cwd(),
  1876. env: {
  1877. ...process.env,
  1878. DATA_DIR: dataDir,
  1879. SESSION_SECRET: sessionSecret
  1880. },
  1881. encoding: 'utf8'
  1882. });
  1883. assert.equal(result.status, 0, result.stderr || result.stdout);
  1884. return JSON.parse(result.stdout);
  1885. }
  1886. function seedMergeResources(dataDir, sessionSecret) {
  1887. const script = `
  1888. import {
  1889. initDatabase,
  1890. createApiToken,
  1891. createDomain,
  1892. createUser,
  1893. logSendEvent,
  1894. saveDnsCredential
  1895. } from './src/db.js';
  1896. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1897. const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
  1898. const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
  1899. const credential = saveDnsCredential(source.id, {
  1900. name: 'Merge DNS',
  1901. provider: 'cloudflare',
  1902. zoneName: 'merge.example',
  1903. credentials: { apiToken: 'merge-secret-token' }
  1904. });
  1905. const domain = createDomain(source.id, {
  1906. dnsCredentialId: credential.id,
  1907. domain: 'merge.example',
  1908. selector: 'mh202607',
  1909. verificationToken: 'token',
  1910. dkimPublic: 'public',
  1911. dkimPrivate: 'private',
  1912. senderHost: 'mail.merge.example',
  1913. sendingIp: '127.0.0.1',
  1914. spfExtra: '',
  1915. dmarcPolicy: 'none',
  1916. dmarcRua: ''
  1917. });
  1918. createApiToken(source.id, 'primary');
  1919. logSendEvent({
  1920. userId: source.id,
  1921. domainId: domain.id,
  1922. sender: 'noreply@merge.example',
  1923. recipients: ['a@example.com'],
  1924. subject: 'Queued',
  1925. status: 'queued'
  1926. });
  1927. console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
  1928. `;
  1929. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1930. cwd: process.cwd(),
  1931. env: {
  1932. ...process.env,
  1933. DATA_DIR: dataDir,
  1934. SESSION_SECRET: sessionSecret
  1935. },
  1936. encoding: 'utf8'
  1937. });
  1938. assert.equal(result.status, 0, result.stderr || result.stdout);
  1939. return JSON.parse(result.stdout);
  1940. }
  1941. function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
  1942. const script = `
  1943. import { initDatabase, createUser, createAccountToken } from './src/db.js';
  1944. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1945. const user = createUser(JSON.parse(process.env.SEED_USER));
  1946. const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
  1947. console.log(JSON.stringify({ user, token: token.token }));
  1948. `;
  1949. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1950. cwd: process.cwd(),
  1951. env: {
  1952. ...process.env,
  1953. DATA_DIR: dataDir,
  1954. SESSION_SECRET: sessionSecret,
  1955. SEED_USER: JSON.stringify(user)
  1956. },
  1957. encoding: 'utf8'
  1958. });
  1959. assert.equal(result.status, 0, result.stderr || result.stdout);
  1960. return JSON.parse(result.stdout);
  1961. }
  1962. function createPasswordResetToken(dataDir, sessionSecret, username) {
  1963. const script = `
  1964. import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
  1965. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1966. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1967. const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
  1968. console.log(token.token);
  1969. `;
  1970. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1971. cwd: process.cwd(),
  1972. env: {
  1973. ...process.env,
  1974. DATA_DIR: dataDir,
  1975. SESSION_SECRET: sessionSecret,
  1976. TOKEN_USERNAME: username
  1977. },
  1978. encoding: 'utf8'
  1979. });
  1980. assert.equal(result.status, 0, result.stderr || result.stdout);
  1981. return result.stdout.trim();
  1982. }
  1983. function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1984. return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
  1985. }
  1986. function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1987. return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
  1988. }
  1989. function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
  1990. const script = `
  1991. import path from 'node:path';
  1992. import { DatabaseSync } from 'node:sqlite';
  1993. import { initDatabase, getUserByLogin } from './src/db.js';
  1994. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1995. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1996. const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1997. database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1998. const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
  1999. const row = user
  2000. ? database
  2001. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
  2002. .get(user.id, process.env.TOKEN_PURPOSE)
  2003. : { count: 0 };
  2004. console.log(String(row.count));
  2005. `;
  2006. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  2007. cwd: process.cwd(),
  2008. env: {
  2009. ...process.env,
  2010. DATA_DIR: dataDir,
  2011. SESSION_SECRET: sessionSecret,
  2012. TOKEN_USERNAME: username,
  2013. TOKEN_PURPOSE: purpose,
  2014. TOKEN_UNUSED_ONLY: String(unusedOnly)
  2015. },
  2016. encoding: 'utf8'
  2017. });
  2018. assert.equal(result.status, 0, result.stderr || result.stdout);
  2019. return Number(result.stdout.trim());
  2020. }
  2021. function seedAuditLogs(dataDir, sessionSecret) {
  2022. const script = `
  2023. import path from 'node:path';
  2024. import { DatabaseSync } from 'node:sqlite';
  2025. import { initDatabase, logAudit } from './src/db.js';
  2026. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  2027. const actorOneId = logAudit({
  2028. actorUserId: 1,
  2029. action: 'audit.actor-one',
  2030. targetType: 'system',
  2031. summary: { label: 'actor-one' }
  2032. });
  2033. const actorOneHundredId = logAudit({
  2034. actorUserId: 100,
  2035. action: 'audit.actor-one-hundred',
  2036. targetType: 'system',
  2037. summary: { label: 'actor-one-hundred' }
  2038. });
  2039. const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  2040. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  2041. const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
  2042. update.run('2026-02-01T00:00:00.000Z', actorOneId);
  2043. update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
  2044. `;
  2045. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  2046. cwd: process.cwd(),
  2047. env: {
  2048. ...process.env,
  2049. DATA_DIR: dataDir,
  2050. SESSION_SECRET: sessionSecret
  2051. },
  2052. encoding: 'utf8'
  2053. });
  2054. assert.equal(result.status, 0, result.stderr || result.stdout);
  2055. }
  2056. async function auditLogActions(baseUrl, cookie, query) {
  2057. const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
  2058. headers: { Cookie: cookie }
  2059. });
  2060. assert.equal(response.status, 200);
  2061. const body = await response.json();
  2062. return body.logs.map((log) => log.action);
  2063. }
  2064. async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
  2065. const response = await fetch(`${baseUrl}/api/admin/system-email`, {
  2066. method: 'PATCH',
  2067. headers: {
  2068. 'Content-Type': 'application/json',
  2069. Cookie: cookie
  2070. },
  2071. body: JSON.stringify({
  2072. host: '127.0.0.1',
  2073. port: smtpPort,
  2074. secure: false,
  2075. username: 'mailer@example.com',
  2076. password: 'smtp-password-123',
  2077. helo: 'mail.example.com',
  2078. fromEmail: 'notify@example.com',
  2079. fromName: 'MailHub Notify',
  2080. testRecipient: 'admin@example.com'
  2081. })
  2082. });
  2083. assert.equal(response.status, 200);
  2084. }
  2085. async function createSendingDomain(baseUrl, cookie, data = {}) {
  2086. const domain = data.domain || 'send.example';
  2087. const response = await fetch(`${baseUrl}/api/domains`, {
  2088. method: 'POST',
  2089. headers: {
  2090. 'Content-Type': 'application/json',
  2091. Cookie: cookie
  2092. },
  2093. body: JSON.stringify({
  2094. domain,
  2095. selector: data.selector || 'mh',
  2096. senderHost: data.senderHost || `mail.${domain}`,
  2097. sendingIp: data.sendingIp || '127.0.0.1',
  2098. smtpRelayId: data.smtpRelayId
  2099. })
  2100. });
  2101. assert.equal(response.status, 201);
  2102. return (await response.json()).domain;
  2103. }
  2104. async function createSmtpRelay(baseUrl, cookie, data = {}) {
  2105. const response = await fetch(`${baseUrl}/api/smtp-relays`, {
  2106. method: 'POST',
  2107. headers: {
  2108. 'Content-Type': 'application/json',
  2109. Cookie: cookie
  2110. },
  2111. body: JSON.stringify({
  2112. name: data.name || 'Relay',
  2113. host: data.host || '127.0.0.1',
  2114. port: data.port,
  2115. secure: data.secure || false,
  2116. username: data.username || '',
  2117. password: data.password || '',
  2118. helo: data.helo || '',
  2119. isDefault: data.isDefault || false
  2120. })
  2121. });
  2122. assert.equal(response.status, 201);
  2123. return (await response.json()).relay;
  2124. }
  2125. async function createSmtpCredential(baseUrl, cookie, data = {}) {
  2126. const response = await fetch(`${baseUrl}/api/smtp-credentials`, {
  2127. method: 'POST',
  2128. headers: {
  2129. 'Content-Type': 'application/json',
  2130. Cookie: cookie
  2131. },
  2132. body: JSON.stringify(data)
  2133. });
  2134. assert.equal(response.status, 201);
  2135. return (await response.json()).credential;
  2136. }
  2137. async function sendApiMail(baseUrl, cookie, data) {
  2138. const response = await fetch(`${baseUrl}/api/send`, {
  2139. method: 'POST',
  2140. headers: {
  2141. 'Content-Type': 'application/json',
  2142. Cookie: cookie
  2143. },
  2144. body: JSON.stringify({
  2145. from: data.from,
  2146. to: data.to,
  2147. subject: data.subject,
  2148. text: data.text || 'hello',
  2149. smtpRelayId: data.smtpRelayId
  2150. })
  2151. });
  2152. assert.equal(response.status, 202);
  2153. return response.json();
  2154. }
  2155. function assertRelayAuth(relayServer, username, password) {
  2156. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  2157. assert.ok(authCommand);
  2158. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
  2159. }
  2160. function decodeHtmlPart(rawMessage) {
  2161. const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
  2162. assert.ok(match, 'expected an HTML MIME part');
  2163. return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
  2164. }
  2165. function escapeRegExp(value) {
  2166. return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  2167. }
  2168. function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
  2169. const commands = [];
  2170. const messages = [];
  2171. const server = net.createServer((socket) => {
  2172. socket.setEncoding('utf8');
  2173. writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
  2174. let buffer = '';
  2175. let dataMode = false;
  2176. let messageLines = [];
  2177. socket.on('data', (chunk) => {
  2178. buffer += chunk;
  2179. let index;
  2180. while ((index = buffer.indexOf('\n')) !== -1) {
  2181. const line = buffer.slice(0, index).replace(/\r$/, '');
  2182. buffer = buffer.slice(index + 1);
  2183. if (dataMode) {
  2184. if (line === '.') {
  2185. dataMode = false;
  2186. messages.push(messageLines.join('\n'));
  2187. messageLines = [];
  2188. writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
  2189. } else {
  2190. messageLines.push(line);
  2191. }
  2192. continue;
  2193. }
  2194. commands.push(line);
  2195. if (line.startsWith('EHLO')) {
  2196. writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
  2197. } else if (line.startsWith('AUTH PLAIN')) {
  2198. writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
  2199. } else if (line.startsWith('MAIL FROM')) {
  2200. writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
  2201. } else if (line.startsWith('RCPT TO')) {
  2202. writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
  2203. } else if (line === 'DATA') {
  2204. dataMode = true;
  2205. writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
  2206. } else if (line === 'QUIT') {
  2207. writeSmtpResponse(socket, '221 bye', responseDelayMs);
  2208. socket.end();
  2209. }
  2210. }
  2211. });
  2212. });
  2213. return new Promise((resolve, reject) => {
  2214. server.once('error', reject);
  2215. server.listen(0, '127.0.0.1', () => {
  2216. server.off('error', reject);
  2217. resolve({
  2218. port: server.address().port,
  2219. commands,
  2220. messages,
  2221. close: () => new Promise((closeResolve) => server.close(closeResolve))
  2222. });
  2223. });
  2224. });
  2225. }
  2226. function writeSmtpResponse(socket, response, delayMs) {
  2227. const write = () => socket.write(`${response}\r\n`);
  2228. if (delayMs > 0) setTimeout(write, delayMs);
  2229. else write();
  2230. }
  2231. async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
  2232. const startedAt = Date.now();
  2233. while (Date.now() - startedAt < timeoutMs) {
  2234. if (predicate()) return;
  2235. await new Promise((resolve) => setTimeout(resolve, intervalMs));
  2236. }
  2237. assert.fail('Timed out waiting for condition.');
  2238. }
  2239. function freePort() {
  2240. return new Promise((resolve, reject) => {
  2241. const server = net.createServer();
  2242. server.listen(0, '127.0.0.1', () => {
  2243. const address = server.address();
  2244. server.close(() => {
  2245. if (address && typeof address === 'object') resolve(address.port);
  2246. else reject(new Error('Unable to allocate a test port.'));
  2247. });
  2248. });
  2249. });
  2250. }
  2251. function waitForOutput(child, text) {
  2252. return new Promise((resolve, reject) => {
  2253. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  2254. const chunks = [];
  2255. const onData = (chunk) => {
  2256. chunks.push(String(chunk));
  2257. if (chunks.join('').includes(text)) {
  2258. clearTimeout(timeout);
  2259. child.stdout.off('data', onData);
  2260. child.stderr.off('data', onData);
  2261. resolve();
  2262. }
  2263. };
  2264. child.stdout.on('data', onData);
  2265. child.stderr.on('data', onData);
  2266. child.once('exit', (code) => {
  2267. clearTimeout(timeout);
  2268. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  2269. });
  2270. });
  2271. }
  2272. function waitForExit(child, timeoutMs) {
  2273. if (child.exitCode !== null) return Promise.resolve(true);
  2274. return new Promise((resolve) => {
  2275. const timeout = setTimeout(() => {
  2276. child.off('exit', onExit);
  2277. resolve(false);
  2278. }, timeoutMs);
  2279. const onExit = () => {
  2280. clearTimeout(timeout);
  2281. resolve(true);
  2282. };
  2283. child.once('exit', onExit);
  2284. });
  2285. }