| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383 |
- import assert from 'node:assert/strict';
- import { spawn, spawnSync } from 'node:child_process';
- import { mkdtempSync, readdirSync } from 'node:fs';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import process from 'node:process';
- import { test } from 'node:test';
- import net from 'node:net';
- test('admin API routes respond once and keep the server alive', async () => {
- const port = await freePort();
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
- ADMIN_PASSWORD: 'password123',
- SUBMISSION_ENABLED: 'false',
- IMAP_ENABLED: 'false',
- POP3_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- try {
- await waitForOutput(child, 'MailHub listening');
- const baseUrl = `http://127.0.0.1:${port}`;
- const login = await fetch(`${baseUrl}/api/login`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username: 'admin', password: 'password123' })
- });
- assert.equal(login.status, 200);
- const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
- assert.ok(cookie);
- const settings = await fetch(`${baseUrl}/api/admin/settings`, {
- headers: { Cookie: cookie }
- });
- assert.equal(settings.status, 200);
- const settingsPayload = await settings.json();
- assert.equal(settingsPayload.settings.mailHostname, 'mailhub.local');
- assert.equal(settingsPayload.settings.systemChecks.ptr.key, 'ptr');
- const exited = await waitForExit(child, 300);
- assert.equal(exited, false);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('built auth assets are served before authentication', async () => {
- const assetName = readdirSync(path.join(process.cwd(), 'public', 'assets')).find((name) => /\.(js|css)$/.test(name));
- assert.ok(assetName, 'expected at least one built frontend asset');
- const port = await freePort();
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
- ADMIN_PASSWORD: 'password123',
- SUBMISSION_ENABLED: 'false',
- IMAP_ENABLED: 'false',
- POP3_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- try {
- await waitForOutput(child, 'MailHub listening');
- const baseUrl = `http://127.0.0.1:${port}`;
- const login = await fetch(`${baseUrl}/login`);
- assert.equal(login.status, 200);
- const asset = await fetch(`${baseUrl}/assets/${assetName}`, { redirect: 'manual' });
- assert.equal(asset.status, 200);
- assert.notEqual(asset.headers.get('location'), '/login');
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('auth pages preserve query messages instead of redirecting them away', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- for (const pathname of ['/login?error=hello', '/reset-password?token=abc123']) {
- const response = await fetch(`${baseUrl}${pathname}`, { redirect: 'manual' });
- assert.equal(response.status, 200);
- assert.equal(response.headers.get('location'), null);
- assert.match(await response.text(), /auth-root/);
- }
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('users can manage multiple smtp login credentials', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- const first = await createSmtpCredential(baseUrl, cookie, {
- username: 'admin-smtp-main',
- password: 'main-secret'
- });
- const second = await createSmtpCredential(baseUrl, cookie, {
- username: 'admin-smtp-app',
- password: 'app-secret'
- });
- assert.equal(first.username, 'admin-smtp-main');
- assert.equal(first.passwordSet, true);
- assert.equal('password' in first, false);
- assert.equal(second.username, 'admin-smtp-app');
- assert.equal(second.passwordSet, true);
- assert.equal('password' in second, false);
- const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
- assert.equal(list.status, 200);
- const listPayload = await list.json();
- assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
- assert.equal(listPayload.credentials[0].passwordSet, true);
- assert.equal('password' in listPayload.credentials[0], false);
- const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
- });
- assert.equal(update.status, 200);
- const updatedCredential = (await update.json()).credential;
- assert.equal(updatedCredential.passwordSet, true);
- assert.equal('password' in updatedCredential, false);
- const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
- method: 'DELETE',
- headers: { Cookie: cookie }
- });
- assert.equal(deleted.status, 200);
- assert.equal((await deleted.json()).deleted, true);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('users can manage inbound mailboxes and read inbound messages', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- await createSendingDomain(baseUrl, cookie, { domain: 'inbound-api.example' });
- const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- address: 'Support@inbound-api.example',
- displayName: 'Support',
- password: 'mailbox-pass-123',
- forwardTo: 'archive@example.net',
- keepForwarded: true
- })
- });
- assert.equal(createMailbox.status, 201);
- const createMailboxBody = await createMailbox.json();
- const mailbox = createMailboxBody.mailbox;
- assert.equal(mailbox.address, 'support@inbound-api.example');
- assert.equal(mailbox.displayName, 'Support');
- assert.equal(mailbox.passwordSet, true);
- assert.deepEqual(mailbox.forwardTo, ['archive@example.net']);
- assert.equal(mailbox.unreadCount, 0);
- assert.equal(createMailboxBody.clientConfig.username, 'support@inbound-api.example');
- assert.equal(createMailboxBody.clientConfig.password, 'mailbox-pass-123');
- assert.equal(createMailboxBody.clientConfig.outgoing.authMethod, 'Normal password');
- const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } });
- assert.equal(mailboxes.status, 200);
- const mailboxesBody = await mailboxes.json();
- assert.deepEqual(mailboxesBody.mailboxes.map((entry) => entry.address), ['support@inbound-api.example']);
- const messageId = seedInboundMessage(dataDir, sessionSecret, 'support@inbound-api.example');
- const messages = await fetch(`${baseUrl}/api/inbound-messages?mailboxId=${mailbox.id}`, { headers: { Cookie: cookie } });
- assert.equal(messages.status, 200);
- const messagesBody = await messages.json();
- assert.equal(messagesBody.messages.length, 1);
- assert.equal(messagesBody.messages[0].id, messageId);
- assert.equal(messagesBody.messages[0].subject, 'Inbound API message');
- assert.equal(messagesBody.messages[0].textBody, undefined);
- const detail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
- assert.equal(detail.status, 200);
- const detailBody = await detail.json();
- assert.equal(detailBody.message.textBody, 'Hello from inbound API.');
- assert.equal(detailBody.message.rawMessage.includes('Inbound API message'), true);
- const markRead = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({ read: true })
- });
- assert.equal(markRead.status, 200);
- assert.equal((await markRead.json()).message.read, true);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('shared domains are available for mailbox creation but not domain management', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const ownerCookie = await login(baseUrl, 'admin', 'password123');
- const sharedDomain = await createSendingDomain(baseUrl, ownerCookie, { domain: 'shared-mailbox.example' });
- const share = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: ownerCookie
- },
- body: JSON.stringify({ mailboxSignupEnabled: true })
- });
- assert.equal(share.status, 200);
- assert.equal((await share.json()).domain.mailboxSignupEnabled, true);
- seedUsers(dataDir, sessionSecret, [
- { username: 'shared-user', email: 'shared-user@example.com', password: 'password123', status: 'active' }
- ]);
- const userCookie = await login(baseUrl, 'shared-user', 'password123');
- const manageableDomains = await fetch(`${baseUrl}/api/domains`, { headers: { Cookie: userCookie } });
- assert.equal(manageableDomains.status, 200);
- assert.deepEqual((await manageableDomains.json()).domains, []);
- const mailboxDomains = await fetch(`${baseUrl}/api/inbound-mailbox-domains`, { headers: { Cookie: userCookie } });
- assert.equal(mailboxDomains.status, 200);
- const mailboxDomainRows = (await mailboxDomains.json()).domains;
- assert.deepEqual(mailboxDomainRows.map((domain) => domain.domain), ['shared-mailbox.example']);
- assert.equal('dkimPublic' in mailboxDomainRows[0], false);
- const ownerMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: ownerCookie
- },
- body: JSON.stringify({
- address: 'owner@shared-mailbox.example',
- password: 'mailbox-pass-123',
- aliases: ['claimed']
- })
- });
- assert.equal(ownerMailbox.status, 201);
- const addressConflict = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: userCookie
- },
- body: JSON.stringify({
- address: 'claimed@shared-mailbox.example',
- password: 'mailbox-pass-123'
- })
- });
- assert.equal(addressConflict.status, 409);
- assert.match((await addressConflict.json()).error, /claimed@shared-mailbox\.example 已被其他邮箱占用/);
- const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: userCookie
- },
- body: JSON.stringify({
- address: 'user@shared-mailbox.example',
- password: 'mailbox-pass-123',
- aliases: ['user-alias']
- })
- });
- assert.equal(createMailbox.status, 201);
- const createdMailbox = (await createMailbox.json()).mailbox;
- assert.equal(createdMailbox.address, 'user@shared-mailbox.example');
- const aliasConflict = await fetch(`${baseUrl}/api/inbound-mailboxes/${createdMailbox.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: userCookie
- },
- body: JSON.stringify({ aliases: ['owner'] })
- });
- assert.equal(aliasConflict.status, 409);
- assert.match((await aliasConflict.json()).error, /owner@shared-mailbox\.example 已被其他邮箱占用/);
- const blockedDelete = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
- method: 'DELETE',
- headers: { Cookie: ownerCookie }
- });
- assert.equal(blockedDelete.status, 409);
- assert.match((await blockedDelete.json()).error, /仍有关联收信邮箱/);
- const blockedPatch = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: userCookie
- },
- body: JSON.stringify({ catchAllAddress: 'user@shared-mailbox.example' })
- });
- assert.equal(blockedPatch.status, 404);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('scoped API tokens create persistent and temporary mailboxes', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- await createSendingDomain(baseUrl, cookie, { domain: 'mailbox-token-api.example' });
- const sendOnlyResponse = await fetch(`${baseUrl}/api/api-tokens`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ name: 'send only', scopes: ['send'] })
- });
- assert.equal(sendOnlyResponse.status, 201);
- const sendOnly = (await sendOnlyResponse.json()).token;
- const denied = await fetch(`${baseUrl}/api/mailboxes`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${sendOnly.token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
- });
- assert.equal(denied.status, 403);
- const tokenResponse = await fetch(`${baseUrl}/api/api-tokens`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ name: 'mailboxes', scopes: ['mailboxes:read', 'mailboxes:write'] })
- });
- assert.equal(tokenResponse.status, 201);
- const mailboxToken = (await tokenResponse.json()).token;
- const temporary = await fetch(`${baseUrl}/api/mailboxes`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${mailboxToken.token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
- });
- assert.equal(temporary.status, 201);
- const temporaryBody = await temporary.json();
- assert.match(temporaryBody.mailbox.address, /^tmp-[a-f0-9]+@mailbox-token-api\.example$/);
- assert.equal(temporaryBody.mailbox.temporary, true);
- assert.ok(temporaryBody.mailbox.expiresAt);
- assert.equal(temporaryBody.password.length >= 8, true);
- assert.equal(temporaryBody.clientConfig.incoming.protocol, 'IMAP');
- const permanent = await fetch(`${baseUrl}/api/mailboxes`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${mailboxToken.token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- mode: 'permanent',
- address: 'support@mailbox-token-api.example',
- password: 'mailbox-pass-123'
- })
- });
- assert.equal(permanent.status, 201);
- const permanentBody = await permanent.json();
- assert.equal(permanentBody.mailbox.temporary, false);
- assert.equal(permanentBody.mailbox.expiresAt, null);
- assert.equal(permanentBody.clientConfig.outgoing.protocol, 'SMTP');
- const listed = await fetch(`${baseUrl}/api/mailboxes`, {
- headers: { Authorization: `Bearer ${mailboxToken.token}` }
- });
- assert.equal(listed.status, 200);
- assert.equal((await listed.json()).mailboxes.length, 2);
- const sendDenied = await fetch(`${baseUrl}/api/send`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${mailboxToken.token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ from: 'noreply@mailbox-token-api.example', to: 'user@example.com', text: 'blocked' })
- });
- assert.equal(sendDenied.status, 403);
- const revoke = await fetch(`${baseUrl}/api/api-tokens/${mailboxToken.id}`, {
- method: 'DELETE',
- headers: { Cookie: cookie }
- });
- assert.equal(revoke.status, 200);
- assert.equal((await revoke.json()).token.status, 'revoked');
- const rejectedAfterRevoke = await fetch(`${baseUrl}/api/mailboxes`, {
- headers: { Authorization: `Bearer ${mailboxToken.token}` }
- });
- assert.equal(rejectedAfterRevoke.status, 401);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
- const relayServer = await startFakeSmtpServer();
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- const domainResponse = await fetch(`${baseUrl}/api/domains`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- domain: 'relay.example',
- selector: 'mh',
- senderHost: 'mail.relay.example',
- sendingIp: '127.0.0.1'
- })
- });
- assert.equal(domainResponse.status, 201);
- const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- name: 'Primary outbound',
- host: '127.0.0.1',
- port: relayServer.port,
- secure: false,
- username: 'relay-user',
- password: 'relay-password',
- helo: 'helo.relay.example',
- isDefault: true
- })
- });
- assert.equal(createRelay.status, 201);
- const created = await createRelay.json();
- assert.equal(created.relay.passwordSet, true);
- assert.equal('password' in created.relay, false);
- const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } });
- assert.equal(list.status, 200);
- const listed = await list.json();
- assert.equal(listed.relays.length, 1);
- assert.equal('password' in listed.relays[0], false);
- const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
- assert.equal(detail.status, 200);
- const detailBody = await detail.json();
- assert.equal(detailBody.relay.passwordSet, true);
- assert.equal('password' in detailBody.relay, false);
- assert.equal('passwordSecret' in detailBody.relay, false);
- const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- name: 'Missing relay',
- host: '127.0.0.1',
- port: relayServer.port,
- secure: false,
- username: 'missing-user',
- password: 'missing-password'
- })
- });
- assert.equal(missingPatch.status, 404);
- const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- name: 'Primary outbound renamed',
- host: '127.0.0.1',
- port: relayServer.port,
- secure: false,
- username: 'relay-user'
- })
- });
- assert.equal(updateWithoutPassword.status, 200);
- const updatedRelay = (await updateWithoutPassword.json()).relay;
- assert.equal(updatedRelay.isDefault, true);
- assert.equal(updatedRelay.passwordSet, true);
- assert.equal('password' in updatedRelay, false);
- const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
- const detailAfterPatchBody = await detailAfterPatch.json();
- assert.equal(detailAfterPatchBody.relay.passwordSet, true);
- assert.equal('password' in detailAfterPatchBody.relay, false);
- const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- from: 'noreply@relay.example',
- to: 'user@example.com',
- subject: 'Invalid relay',
- text: 'hello',
- smtpRelayId: 999999
- })
- });
- assert.equal(invalidRelaySend.status, 400);
- const send = await fetch(`${baseUrl}/api/send`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- from: 'noreply@relay.example',
- to: 'user@example.com',
- subject: 'Relay send',
- text: 'hello',
- smtpRelayId: created.relay.id
- })
- });
- assert.equal(send.status, 202);
- assert.equal((await send.json()).smtpRelayId, created.relay.id);
- await waitForCondition(() => relayServer.messages.length === 1);
- const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
- assert.ok(authCommand);
- assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password');
- const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
- assert.equal(events.status, 200);
- const eventsBody = await events.json();
- assert.equal(eventsBody.events[0].smtpRelayId, created.relay.id);
- const eventDetail = await fetch(`${baseUrl}/api/events/${eventsBody.events[0].id}`, {
- headers: { Cookie: cookie }
- });
- assert.equal(eventDetail.status, 200);
- const eventDetailBody = await eventDetail.json();
- assert.equal(eventDetailBody.event.id, eventsBody.events[0].id);
- assert.equal(eventDetailBody.event.smtpRelayId, created.relay.id);
- assert.equal(Array.isArray(eventDetailBody.event.webhookDeliveries), true);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await relayServer.close();
- }
- });
- test('smtp relay selection prefers request relay then domain relay then user default relay', async () => {
- const requestRelayServer = await startFakeSmtpServer();
- const domainRelayServer = await startFakeSmtpServer();
- const defaultRelayServer = await startFakeSmtpServer();
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- const settings = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- appBaseUrl: baseUrl,
- engagementTrackingEnabled: true
- })
- });
- assert.equal(settings.status, 200);
- const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
- name: 'Default relay',
- host: '127.0.0.1',
- port: defaultRelayServer.port,
- username: 'default-user',
- password: 'default-password',
- isDefault: true
- });
- const domainRelay = await createSmtpRelay(baseUrl, cookie, {
- name: 'Domain relay',
- host: '127.0.0.1',
- port: domainRelayServer.port,
- username: 'domain-user',
- password: 'domain-password'
- });
- const requestRelay = await createSmtpRelay(baseUrl, cookie, {
- name: 'Request relay',
- host: '127.0.0.1',
- port: requestRelayServer.port,
- username: 'request-user',
- password: 'request-password'
- });
- const domain = await createSendingDomain(baseUrl, cookie, {
- domain: 'relay-order.example',
- smtpRelayId: domainRelay.id
- });
- assert.equal(domain.smtpRelayId, domainRelay.id);
- const domainSend = await sendApiMail(baseUrl, cookie, {
- from: 'noreply@relay-order.example',
- to: 'domain@example.com',
- subject: 'Domain relay'
- });
- assert.equal(domainSend.smtpRelayId, domainRelay.id);
- await waitForCondition(() => domainRelayServer.messages.length === 1);
- assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password');
- const requestSend = await sendApiMail(baseUrl, cookie, {
- from: 'noreply@relay-order.example',
- to: 'request@example.com',
- subject: 'Request relay',
- smtpRelayId: requestRelay.id
- });
- assert.equal(requestSend.smtpRelayId, requestRelay.id);
- await waitForCondition(() => requestRelayServer.messages.length === 1);
- assertRelayAuth(requestRelayServer, 'request-user', 'request-password');
- const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- to: 'test-send@example.com',
- subject: 'Selected relay test send',
- text: 'Open the HTML version to verify tracking.',
- html: '<html><body><p>MailHub tracking test.</p><a href="https://example.net/tracked">Tracked link</a></body></html>',
- smtpRelayId: requestRelay.id
- })
- });
- assert.equal(testSend.status, 202);
- const testSendBody = await testSend.json();
- assert.equal(testSendBody.smtpRelayId, requestRelay.id);
- assert.deepEqual(testSendBody.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
- await waitForCondition(() => requestRelayServer.messages.length === 2);
- const testHtml = decodeHtmlPart(requestRelayServer.messages[1]);
- assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
- assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
- assert.equal(testHtml.includes('https://example.net/tracked'), false);
- const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- to: 'invalid-test-send@example.com',
- smtpRelayId: 999999
- })
- });
- assert.equal(invalidTestSend.status, 400);
- const defaultDomain = await createSendingDomain(baseUrl, cookie, {
- domain: 'default-relay.example'
- });
- assert.equal(defaultDomain.smtpRelayId, null);
- const defaultSend = await sendApiMail(baseUrl, cookie, {
- from: 'noreply@default-relay.example',
- to: 'default@example.com',
- subject: 'Default relay'
- });
- assert.equal(defaultSend.smtpRelayId, defaultRelay.id);
- await waitForCondition(() => defaultRelayServer.messages.length === 1);
- assertRelayAuth(defaultRelayServer, 'default-user', 'default-password');
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await requestRelayServer.close();
- await domainRelayServer.close();
- await defaultRelayServer.close();
- }
- });
- test('admin users can list audit logs', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
- headers: { Cookie: cookie }
- });
- assert.equal(response.status, 200);
- assert.deepEqual(await response.json(), { logs: [] });
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can list resource inventory', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'alice',
- email: 'alice@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const userCookie = await login(baseUrl, 'alice', 'password123');
- const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
- headers: { Cookie: userCookie }
- });
- assert.equal(forbidden.status, 403);
- const response = await fetch(`${baseUrl}/api/admin/resources`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(response.status, 200);
- const body = await response.json();
- assert.ok(Array.isArray(body.inventory.users));
- assert.ok(Array.isArray(body.inventory.warnings));
- assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can transfer individual resources', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const seeded = seedTransferResources(dataDir, sessionSecret);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const aliceCookie = await login(baseUrl, 'alice', 'password123');
- const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({ targetUserId: seeded.bobId })
- });
- assert.equal(forbidden.status, 403);
- const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- targetUserId: seeded.bobId,
- dnsCredentialMode: 'with_dns_credential'
- })
- });
- assert.equal(domain.status, 200);
- const domainBody = await domain.json();
- assert.equal(domainBody.domain.userId, seeded.bobId);
- assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
- const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({ targetUserId: seeded.bobId })
- });
- assert.equal(dns.status, 200);
- assert.equal((await dns.json()).credential.userId, seeded.bobId);
- const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- targetUserId: seeded.bobId,
- tokenIds: [seeded.apiTokenId]
- })
- });
- assert.equal(tokens.status, 200);
- const tokensBody = await tokens.json();
- assert.equal(tokensBody.tokens.length, 1);
- assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
- const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
- headers: { Cookie: adminCookie }
- });
- const actions = (await audit.json()).logs.map((entry) => entry.action);
- assert.ok(actions.includes('admin.transfer_domain'));
- assert.ok(actions.includes('admin.transfer_dns_credential'));
- assert.ok(actions.includes('admin.transfer_api_tokens'));
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can preview and execute user merge', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const seeded = seedMergeResources(dataDir, sessionSecret);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- sourceUserId: seeded.sourceId,
- targetUserId: seeded.targetId
- })
- });
- assert.equal(previewResponse.status, 200);
- const preview = (await previewResponse.json()).preview;
- assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
- assert.equal(preview.counts.domains, 1);
- const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- sourceUserId: seeded.sourceId,
- targetUserId: seeded.targetId,
- confirmation: 'wrong'
- })
- });
- assert.equal(invalid.status, 400);
- const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- sourceUserId: seeded.sourceId,
- targetUserId: seeded.targetId,
- confirmation: preview.confirmationText
- })
- });
- assert.equal(execute.status, 200);
- const result = (await execute.json()).result;
- assert.equal(result.counts.domains, 1);
- assert.equal(result.sourceUser.status, 'disabled');
- const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(audit.status, 200);
- assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can manage system email settings without exposing password', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'alice',
- email: 'alice@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const userCookie = await login(baseUrl, 'alice', 'password123');
- const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
- headers: { Cookie: userCookie }
- });
- assert.equal(forbidden.status, 403);
- const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(empty.status, 200);
- assert.equal((await empty.json()).settings.passwordSet, false);
- const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- host: 'smtp.example.com',
- port: 587,
- secure: false,
- username: 'mailer@example.com',
- password: 'smtp-password-123',
- helo: 'mail.example.com',
- fromEmail: 'notify@example.com',
- fromName: 'MailHub Notify',
- testRecipient: 'admin@example.com'
- })
- });
- assert.equal(saved.status, 200);
- const savedBody = await saved.json();
- assert.equal(savedBody.settings.host, 'smtp.example.com');
- assert.equal(savedBody.settings.port, 587);
- assert.equal(savedBody.settings.secure, false);
- assert.equal(savedBody.settings.passwordSet, true);
- assert.equal('password' in savedBody.settings, false);
- assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
- const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({
- host: 'smtp2.example.com',
- password: ''
- })
- });
- assert.equal(preserved.status, 200);
- const preservedBody = await preserved.json();
- assert.equal(preservedBody.settings.host, 'smtp2.example.com');
- assert.equal(preservedBody.settings.passwordSet, true);
- assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
- const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(audit.status, 200);
- const [entry] = (await audit.json()).logs;
- assert.equal(entry.action, 'admin.update_system_email');
- assert.equal(entry.targetType, 'system_email');
- assert.equal(entry.summary.host, 'smtp2.example.com');
- assert.equal(entry.summary.password, undefined);
- assert.equal(entry.summary.passwordSet, true);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('registration and verification resend use configured system email', async () => {
- const smtp = await startFakeSmtpServer();
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
- { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
- ]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
- const register = await fetch(`${baseUrl}/api/register`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username: 'mailuser',
- email: 'mailuser@example.com',
- password: 'password123'
- })
- });
- assert.equal(register.status, 201);
- const registerBody = await register.json();
- assert.equal(registerBody.user.status, 'pending_email');
- assert.equal(registerBody.verificationEmailSent, true);
- assert.equal(registerBody.message, '注册成功,验证邮件已发送,请先验证邮箱。');
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
- const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: 'publicpending@example.com' })
- });
- assert.equal(publicResend.status, 202);
- const publicResendBody = await publicResend.json();
- assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
- assert.equal('verificationEmailSent' in publicResendBody, false);
- assert.equal('result' in publicResendBody, false);
- await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
- const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
- headers: { Cookie: adminCookie }
- });
- const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
- assert.ok(adminPending);
- const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(adminResend.status, 202);
- assert.equal((await adminResend.json()).verificationEmailSent, true);
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
- assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
- assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
- assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
- assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
- assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await smtp.close();
- }
- });
- test('public verification resend is generic and does not create tokens without mail config', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'pendingnomail',
- email: 'pendingnomail@example.com',
- password: 'password123',
- status: 'pending_email'
- }]);
- const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: 'pendingnomail@example.com' })
- });
- assert.equal(response.status, 202);
- assert.deepEqual(await response.json(), {
- message: '如果账号需要验证,我们会发送验证邮件。'
- });
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('registration reports pending email when system email is not configured', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const register = await fetch(`${baseUrl}/api/register`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username: 'nomailuser',
- email: 'nomailuser@example.com',
- password: 'password123'
- })
- });
- assert.equal(register.status, 201);
- const body = await register.json();
- assert.equal(body.user.status, 'pending_email');
- assert.equal(body.verificationEmailSent, false);
- assert.match(body.message, /验证邮件暂未发送/);
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can send system email test messages', async () => {
- const smtp = await startFakeSmtpServer();
- const { child, baseUrl } = await startTestServer();
- try {
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
- const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({ to: 'operator@example.com' })
- });
- assert.equal(response.status, 202);
- const body = await response.json();
- assert.equal(body.result.ok, true);
- assert.equal(body.result.queueId, 'SYS123');
- assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
- assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
- const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(audit.status, 200);
- const [entry] = (await audit.json()).logs;
- assert.equal(entry.targetType, 'system_email');
- assert.equal(entry.summary.to, 'operator@example.com');
- assert.equal(entry.summary.ok, true);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await smtp.close();
- }
- });
- test('public forgot password is generic and sends reset email when configured', async () => {
- const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'resetme',
- email: 'resetme@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
- const startedAt = Date.now();
- const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: 'resetme@example.com' })
- });
- const elapsedMs = Date.now() - startedAt;
- assert.equal(existing.status, 202);
- assert.equal(elapsedMs < 500, true);
- assert.deepEqual(await existing.json(), {
- message: '如果邮箱存在,我们会发送密码重置邮件。'
- });
- await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
- const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: 'missing@example.com' })
- });
- assert.equal(missing.status, 202);
- assert.deepEqual(await missing.json(), {
- message: '如果邮箱存在,我们会发送密码重置邮件。'
- });
- await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await smtp.close();
- }
- });
- test('public reset password consumes token and updates password', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'tokenreset',
- email: 'tokenreset@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
- const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- token,
- password: 'new-password-123'
- })
- });
- assert.equal(response.status, 200);
- assert.deepEqual(await response.json(), {
- message: '密码已重置,请使用新密码登录。'
- });
- const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
- assert.equal(oldLogin.status, 401);
- const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
- assert.equal(newLogin.status, 200);
- const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- token,
- password: 'another-password-123'
- })
- });
- assert.equal(reused.status, 400);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can trigger password reset email and set temporary password', async () => {
- const smtp = await startFakeSmtpServer();
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
- { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
- ]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const memberCookie = await login(baseUrl, 'member2', 'password123');
- await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
- const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
- headers: { Cookie: adminCookie }
- });
- const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
- assert.ok(target);
- const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
- method: 'POST',
- headers: { Cookie: memberCookie }
- });
- assert.equal(forbiddenReset.status, 403);
- const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(reset.status, 202);
- assert.equal((await reset.json()).result.ok, true);
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
- assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
- const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: memberCookie
- },
- body: JSON.stringify({ password: 'temporary-123' })
- });
- assert.equal(forbiddenTemporary.status, 403);
- const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({ password: 'temporary-123' })
- });
- assert.equal(temporary.status, 200);
- assert.equal((await temporary.json()).user.id, target.id);
- assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
- const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
- assert.equal(oldLogin.status, 401);
- const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
- assert.equal(tempLogin.status, 200);
- const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(audit.status, 200);
- const logs = (await audit.json()).logs;
- assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
- const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
- assert.ok(temporaryLog);
- assert.equal(temporaryLog.summary.username, 'targetuser');
- assert.equal(temporaryLog.summary.password, undefined);
- assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await smtp.close();
- }
- });
- test('non-admin users cannot list audit logs', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'alice',
- email: 'alice@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const cookie = await login(baseUrl, 'alice', 'password123');
- const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
- headers: { Cookie: cookie }
- });
- assert.equal(response.status, 403);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin user patch rejects invalid status with a bad request', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [{
- username: 'badstatus',
- email: 'badstatus@example.com',
- password: 'password123',
- status: 'active'
- }]);
- const cookie = await login(baseUrl, 'admin', 'password123');
- const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
- headers: { Cookie: cookie }
- });
- assert.equal(usersResponse.status, 200);
- const usersBody = await usersResponse.json();
- const target = usersBody.users.find((user) => user.username === 'badstatus');
- assert.ok(target);
- const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({ status: 'archived' })
- });
- assert.equal(response.status, 400);
- assert.equal((await response.json()).error, '用户状态不正确。');
- const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({ password: 'short' })
- });
- assert.equal(shortPassword.status, 400);
- assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('self registration creates a pending email user and verification token without a session', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const register = await fetch(`${baseUrl}/api/register`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username: 'newuser',
- email: 'newuser@example.com',
- password: 'password123'
- })
- });
- assert.equal(register.status, 201);
- assert.equal(sessionCookieFrom(register), '');
- const text = await register.text();
- assert.doesNotMatch(text, /token/i);
- const body = JSON.parse(text);
- assert.equal('token' in body, false);
- assert.equal('token' in body.user, false);
- assert.equal('tokenHash' in body.user, false);
- assert.equal(body.user.status, 'pending_email');
- assert.match(body.message, /验证邮箱/);
- assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
- const login = await loginResponse(baseUrl, 'newuser', 'password123');
- assert.equal(login.status, 403);
- assert.equal(sessionCookieFrom(login), '');
- assert.equal((await login.json()).error, '请先验证邮箱。');
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('email verification activates users by default and redirects browsers with an exact success message', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'verifyme',
- email: 'verifyme@example.com',
- password: 'password123',
- status: 'pending_email'
- });
- const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
- assert.equal(missing.status, 400);
- assert.equal(sessionCookieFrom(missing), '');
- const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
- assert.equal(invalid.status, 400);
- assert.equal(sessionCookieFrom(invalid), '');
- const htmlInvalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`, {
- redirect: 'manual',
- headers: { Accept: 'text/html,application/xhtml+xml' }
- });
- assert.equal(htmlInvalid.status, 303);
- assert.match(htmlInvalid.headers.get('location') || '', /^\/login\?error=/);
- assert.equal(sessionCookieFrom(htmlInvalid), '');
- const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'verifybrowser',
- email: 'verifybrowser@example.com',
- password: 'password123',
- status: 'pending_email'
- });
- const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
- redirect: 'manual',
- headers: { Accept: 'text/html,application/xhtml+xml' }
- });
- assert.equal(browserResponse.status, 303);
- assert.equal(
- browserResponse.headers.get('location'),
- `/login?message=${encodeURIComponent('邮箱验证成功,现在可以登录。')}`
- );
- assert.equal(sessionCookieFrom(browserResponse), '');
- const browserLogin = await loginResponse(baseUrl, 'verifybrowser', 'password123');
- assert.equal(browserLogin.status, 200);
- assert.ok(sessionCookieFrom(browserLogin));
- const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
- assert.equal(response.status, 200);
- assert.equal(sessionCookieFrom(response), '');
- const body = await response.json();
- assert.equal(body.user.id, created.user.id);
- assert.equal(body.user.status, 'active');
- assert.equal(body.message, '邮箱验证成功,现在可以登录。');
- const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
- assert.equal(reused.status, 400);
- assert.equal(sessionCookieFrom(reused), '');
- const verifiedLogin = await loginResponse(baseUrl, 'verifyme', 'password123');
- assert.equal(verifiedLogin.status, 200);
- assert.ok(sessionCookieFrom(verifiedLogin));
- const alreadyPendingReview = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'alreadyreview',
- email: 'alreadyreview@example.com',
- password: 'password123',
- status: 'pending_review'
- });
- const pendingReviewResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyPendingReview.token)}`);
- assert.equal(pendingReviewResponse.status, 200);
- const pendingReviewBody = await pendingReviewResponse.json();
- assert.equal(pendingReviewBody.user.status, 'pending_review');
- assert.equal(pendingReviewBody.message, '邮箱验证成功,请等待管理员审核。');
- const disabled = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'disabledverify',
- email: 'disabledverify@example.com',
- password: 'password123',
- status: 'disabled'
- });
- const disabledResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(disabled.token)}`);
- assert.equal(disabledResponse.status, 400);
- assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'disabledverify', 'email_verification'), 1);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin registration approval setting persists and keeps verified users pending review', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'settingsmember', email: 'settingsmember@example.com', password: 'password123', status: 'active' }
- ]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const memberCookie = await login(baseUrl, 'settingsmember', 'password123');
- const defaultsResponse = await fetch(`${baseUrl}/api/admin/settings`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(defaultsResponse.status, 200);
- assert.equal((await defaultsResponse.json()).settings.registrationRequiresApproval, false);
- const forbiddenRead = await fetch(`${baseUrl}/api/admin/settings`, {
- headers: { Cookie: memberCookie }
- });
- assert.equal(forbiddenRead.status, 403);
- const forbidden = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: memberCookie
- },
- body: JSON.stringify({ registrationRequiresApproval: true })
- });
- assert.equal(forbidden.status, 403);
- const unchangedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal((await unchangedResponse.json()).settings.registrationRequiresApproval, false);
- const enabledResponse = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: adminCookie
- },
- body: JSON.stringify({ registrationRequiresApproval: true })
- });
- assert.equal(enabledResponse.status, 200);
- assert.equal((await enabledResponse.json()).settings.registrationRequiresApproval, true);
- const persistedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(persistedResponse.status, 200);
- assert.equal((await persistedResponse.json()).settings.registrationRequiresApproval, true);
- const register = await fetch(`${baseUrl}/api/register`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username: 'reviewregistration',
- email: 'reviewregistration@example.com',
- password: 'password123'
- })
- });
- assert.equal(register.status, 201);
- const registerBody = await register.json();
- assert.equal(registerBody.user.status, 'pending_email');
- assert.doesNotMatch(registerBody.message, /管理员审核|即可登录/);
- const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'reviewrequired',
- email: 'reviewrequired@example.com',
- password: 'password123',
- status: 'pending_email'
- });
- const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
- assert.equal(response.status, 200);
- assert.equal(sessionCookieFrom(response), '');
- const body = await response.json();
- assert.equal(body.user.status, 'pending_review');
- assert.equal(body.message, '邮箱验证成功,请等待管理员审核。');
- const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'reviewbrowser',
- email: 'reviewbrowser@example.com',
- password: 'password123',
- status: 'pending_email'
- });
- const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
- redirect: 'manual',
- headers: { Accept: 'text/html,application/xhtml+xml' }
- });
- assert.equal(browserResponse.status, 303);
- assert.equal(
- browserResponse.headers.get('location'),
- `/login?message=${encodeURIComponent('邮箱验证成功,请等待管理员审核。')}`
- );
- assert.equal(sessionCookieFrom(browserResponse), '');
- const alreadyActive = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
- username: 'alreadyactive',
- email: 'alreadyactive@example.com',
- password: 'password123',
- status: 'active'
- });
- const activeResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyActive.token)}`);
- assert.equal(activeResponse.status, 200);
- const activeBody = await activeResponse.json();
- assert.equal(activeBody.user.status, 'active');
- assert.equal(activeBody.message, '邮箱验证成功,现在可以登录。');
- await assertLoginDeniedByStatus(baseUrl, 'reviewrequired', '账号正在等待管理员审核。');
- await assertLoginDeniedByStatus(baseUrl, 'reviewbrowser', '账号正在等待管理员审核。');
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin users can approve pending review users with an audit log', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
- { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
- { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
- { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
- ]);
- const adminCookie = await login(baseUrl, 'admin', 'password123');
- const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(usersResponse.status, 200);
- const users = (await usersResponse.json()).users;
- const target = users.find((user) => user.username === 'reviewme');
- const pendingEmail = users.find((user) => user.username === 'emailonly');
- const disabled = users.find((user) => user.username === 'disabledreview');
- assert.ok(target);
- assert.ok(pendingEmail);
- assert.ok(disabled);
- const memberCookie = await login(baseUrl, 'member', 'password123');
- const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
- method: 'POST',
- headers: { Cookie: memberCookie }
- });
- assert.equal(nonAdmin.status, 403);
- const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(missing.status, 404);
- const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(pendingEmailResponse.status, 400);
- assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
- const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(disabledResponse.status, 400);
- assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
- const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
- method: 'POST',
- headers: { Cookie: adminCookie }
- });
- assert.equal(response.status, 200);
- const body = await response.json();
- assert.equal(body.user.id, target.id);
- assert.equal(body.user.status, 'active');
- const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
- assert.ok(approvedCookie);
- const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
- headers: { Cookie: adminCookie }
- });
- assert.equal(auditResponse.status, 200);
- const [entry] = (await auditResponse.json()).logs;
- assert.equal(entry.action, 'admin.approve_user');
- assert.equal(entry.targetType, 'user');
- assert.equal(entry.targetId, String(target.id));
- assert.equal(entry.targetUserId, target.id);
- assert.equal(entry.summary.username, 'reviewme');
- assert.equal(entry.summary.status, 'active');
- assert.equal(entry.summary.password, undefined);
- assert.equal(entry.summary.token, undefined);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('login returns account status restrictions only after password verification', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
- { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
- { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
- { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
- ]);
- await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
- await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
- await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
- const active = await loginResponse(baseUrl, 'activeuser', 'password123');
- assert.equal(active.status, 200);
- assert.ok(sessionCookieFrom(active));
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin audit log actor filter rejects non-decimal user ids', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedAuditLogs(dataDir, sessionSecret);
- const cookie = await login(baseUrl, 'admin', 'password123');
- assert.deepEqual(
- await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
- ['audit.actor-one']
- );
- assert.deepEqual(
- await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
- ['audit.actor-one-hundred', 'audit.actor-one']
- );
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('admin audit log date filter ignores invalid dates', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedAuditLogs(dataDir, sessionSecret);
- const cookie = await login(baseUrl, 'admin', 'password123');
- assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
- assert.deepEqual(
- await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
- ['audit.actor-one-hundred', 'audit.actor-one']
- );
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- async function startTestServer() {
- const port = await freePort();
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
- const sessionSecret = 'test-session-secret';
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- ADMIN_PASSWORD: 'password123',
- SESSION_SECRET: sessionSecret,
- REGISTRATION_REQUIRES_APPROVAL: 'false',
- DNS_AUTO_CHECK_ENABLED: 'false',
- SUBMISSION_ENABLED: 'false',
- IMAP_ENABLED: 'false',
- POP3_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- await waitForOutput(child, 'MailHub listening');
- return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
- }
- async function login(baseUrl, username, password) {
- const response = await loginResponse(baseUrl, username, password);
- assert.equal(response.status, 200);
- const cookie = sessionCookieFrom(response);
- assert.ok(cookie);
- return cookie;
- }
- function loginResponse(baseUrl, username, password) {
- return fetch(`${baseUrl}/api/login`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, password })
- });
- }
- async function assertLoginDeniedByStatus(baseUrl, username, message) {
- const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
- assert.equal(wrongPassword.status, 401);
- assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
- assert.equal(sessionCookieFrom(wrongPassword), '');
- const correctPassword = await loginResponse(baseUrl, username, 'password123');
- assert.equal(correctPassword.status, 403);
- assert.equal((await correctPassword.json()).error, message);
- assert.equal(sessionCookieFrom(correctPassword), '');
- }
- function sessionCookieFrom(response) {
- return response.headers.get('set-cookie')?.split(';')[0] || '';
- }
- function seedUsers(dataDir, sessionSecret, users) {
- const script = `
- import { initDatabase, createUser } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- for (const user of JSON.parse(process.env.SEED_USERS)) {
- createUser(user);
- }
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- SEED_USERS: JSON.stringify(users)
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- }
- function seedInboundMessage(dataDir, sessionSecret, address) {
- const script = `
- import {
- createInboundMessage,
- getInboundMailboxByAddress,
- initDatabase
- } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const mailbox = getInboundMailboxByAddress(process.env.INBOUND_ADDRESS);
- const message = createInboundMessage(mailbox, {
- sender: 'alice@example.net',
- recipients: [process.env.INBOUND_ADDRESS],
- subject: 'Inbound API message',
- messageId: '<inbound-api@example.net>',
- rawMessage: [
- 'From: Alice <alice@example.net>',
- 'To: Support <' + process.env.INBOUND_ADDRESS + '>',
- 'Subject: Inbound API message',
- '',
- 'Hello from inbound API.'
- ].join('\\r\\n'),
- textBody: 'Hello from inbound API.'
- });
- console.log(String(message.id));
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- INBOUND_ADDRESS: address
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return Number(result.stdout.trim());
- }
- function seedTransferResources(dataDir, sessionSecret) {
- const script = `
- import {
- initDatabase,
- createApiToken,
- createDomain,
- createUser,
- saveDnsCredential
- } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
- const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
- const credential = saveDnsCredential(alice.id, {
- name: 'Alice DNS',
- provider: 'cloudflare',
- zoneName: 'alice.example',
- credentials: { apiToken: 'secret-token' }
- });
- const standaloneCredential = saveDnsCredential(alice.id, {
- name: 'Standalone DNS',
- provider: 'cloudflare',
- zoneName: 'standalone.example',
- credentials: { apiToken: 'standalone-secret-token' }
- });
- const domain = createDomain(alice.id, {
- dnsCredentialId: credential.id,
- domain: 'alice.example',
- selector: 'mh202607',
- verificationToken: 'token',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.alice.example',
- sendingIp: '127.0.0.1',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const apiToken = createApiToken(alice.id, 'primary');
- console.log(JSON.stringify({
- aliceId: alice.id,
- bobId: bob.id,
- domainId: domain.id,
- credentialId: credential.id,
- standaloneCredentialId: standaloneCredential.id,
- apiTokenId: apiToken.id
- }));
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return JSON.parse(result.stdout);
- }
- function seedMergeResources(dataDir, sessionSecret) {
- const script = `
- import {
- initDatabase,
- createApiToken,
- createDomain,
- createUser,
- logSendEvent,
- saveDnsCredential
- } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
- const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
- const credential = saveDnsCredential(source.id, {
- name: 'Merge DNS',
- provider: 'cloudflare',
- zoneName: 'merge.example',
- credentials: { apiToken: 'merge-secret-token' }
- });
- const domain = createDomain(source.id, {
- dnsCredentialId: credential.id,
- domain: 'merge.example',
- selector: 'mh202607',
- verificationToken: 'token',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.merge.example',
- sendingIp: '127.0.0.1',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createApiToken(source.id, 'primary');
- logSendEvent({
- userId: source.id,
- domainId: domain.id,
- sender: 'noreply@merge.example',
- recipients: ['a@example.com'],
- subject: 'Queued',
- status: 'queued'
- });
- console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return JSON.parse(result.stdout);
- }
- function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
- const script = `
- import { initDatabase, createUser, createAccountToken } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const user = createUser(JSON.parse(process.env.SEED_USER));
- const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
- console.log(JSON.stringify({ user, token: token.token }));
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- SEED_USER: JSON.stringify(user)
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return JSON.parse(result.stdout);
- }
- function createPasswordResetToken(dataDir, sessionSecret, username) {
- const script = `
- import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const user = getUserByLogin(process.env.TOKEN_USERNAME);
- const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
- console.log(token.token);
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- TOKEN_USERNAME: username
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return result.stdout.trim();
- }
- function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
- return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
- }
- function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
- return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
- }
- function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
- const script = `
- import path from 'node:path';
- import { DatabaseSync } from 'node:sqlite';
- import { initDatabase, getUserByLogin } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const user = getUserByLogin(process.env.TOKEN_USERNAME);
- const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
- database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
- const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
- const row = user
- ? database
- .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
- .get(user.id, process.env.TOKEN_PURPOSE)
- : { count: 0 };
- console.log(String(row.count));
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- TOKEN_USERNAME: username,
- TOKEN_PURPOSE: purpose,
- TOKEN_UNUSED_ONLY: String(unusedOnly)
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- return Number(result.stdout.trim());
- }
- function seedAuditLogs(dataDir, sessionSecret) {
- const script = `
- import path from 'node:path';
- import { DatabaseSync } from 'node:sqlite';
- import { initDatabase, logAudit } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- const actorOneId = logAudit({
- actorUserId: 1,
- action: 'audit.actor-one',
- targetType: 'system',
- summary: { label: 'actor-one' }
- });
- const actorOneHundredId = logAudit({
- actorUserId: 100,
- action: 'audit.actor-one-hundred',
- targetType: 'system',
- summary: { label: 'actor-one-hundred' }
- });
- const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
- db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
- const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
- update.run('2026-02-01T00:00:00.000Z', actorOneId);
- update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- }
- async function auditLogActions(baseUrl, cookie, query) {
- const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
- headers: { Cookie: cookie }
- });
- assert.equal(response.status, 200);
- const body = await response.json();
- return body.logs.map((log) => log.action);
- }
- async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
- const response = await fetch(`${baseUrl}/api/admin/system-email`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- host: '127.0.0.1',
- port: smtpPort,
- secure: false,
- username: 'mailer@example.com',
- password: 'smtp-password-123',
- helo: 'mail.example.com',
- fromEmail: 'notify@example.com',
- fromName: 'MailHub Notify',
- testRecipient: 'admin@example.com'
- })
- });
- assert.equal(response.status, 200);
- }
- async function createSendingDomain(baseUrl, cookie, data = {}) {
- const domain = data.domain || 'send.example';
- const response = await fetch(`${baseUrl}/api/domains`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- domain,
- selector: data.selector || 'mh',
- senderHost: data.senderHost || `mail.${domain}`,
- sendingIp: data.sendingIp || '127.0.0.1',
- smtpRelayId: data.smtpRelayId
- })
- });
- assert.equal(response.status, 201);
- return (await response.json()).domain;
- }
- async function createSmtpRelay(baseUrl, cookie, data = {}) {
- const response = await fetch(`${baseUrl}/api/smtp-relays`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- name: data.name || 'Relay',
- host: data.host || '127.0.0.1',
- port: data.port,
- secure: data.secure || false,
- username: data.username || '',
- password: data.password || '',
- helo: data.helo || '',
- isDefault: data.isDefault || false
- })
- });
- assert.equal(response.status, 201);
- return (await response.json()).relay;
- }
- async function createSmtpCredential(baseUrl, cookie, data = {}) {
- const response = await fetch(`${baseUrl}/api/smtp-credentials`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify(data)
- });
- assert.equal(response.status, 201);
- return (await response.json()).credential;
- }
- async function sendApiMail(baseUrl, cookie, data) {
- const response = await fetch(`${baseUrl}/api/send`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- from: data.from,
- to: data.to,
- subject: data.subject,
- text: data.text || 'hello',
- smtpRelayId: data.smtpRelayId
- })
- });
- assert.equal(response.status, 202);
- return response.json();
- }
- function assertRelayAuth(relayServer, username, password) {
- const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
- assert.ok(authCommand);
- assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
- }
- function decodeHtmlPart(rawMessage) {
- const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
- assert.ok(match, 'expected an HTML MIME part');
- return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
- }
- function escapeRegExp(value) {
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- }
- function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
- const commands = [];
- const messages = [];
- const server = net.createServer((socket) => {
- socket.setEncoding('utf8');
- writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
- let buffer = '';
- let dataMode = false;
- let messageLines = [];
- socket.on('data', (chunk) => {
- buffer += chunk;
- let index;
- while ((index = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, index).replace(/\r$/, '');
- buffer = buffer.slice(index + 1);
- if (dataMode) {
- if (line === '.') {
- dataMode = false;
- messages.push(messageLines.join('\n'));
- messageLines = [];
- writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
- } else {
- messageLines.push(line);
- }
- continue;
- }
- commands.push(line);
- if (line.startsWith('EHLO')) {
- writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
- } else if (line.startsWith('AUTH PLAIN')) {
- writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
- } else if (line.startsWith('MAIL FROM')) {
- writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
- } else if (line.startsWith('RCPT TO')) {
- writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
- } else if (line === 'DATA') {
- dataMode = true;
- writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
- } else if (line === 'QUIT') {
- writeSmtpResponse(socket, '221 bye', responseDelayMs);
- socket.end();
- }
- }
- });
- });
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(0, '127.0.0.1', () => {
- server.off('error', reject);
- resolve({
- port: server.address().port,
- commands,
- messages,
- close: () => new Promise((closeResolve) => server.close(closeResolve))
- });
- });
- });
- }
- function writeSmtpResponse(socket, response, delayMs) {
- const write = () => socket.write(`${response}\r\n`);
- if (delayMs > 0) setTimeout(write, delayMs);
- else write();
- }
- async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
- const startedAt = Date.now();
- while (Date.now() - startedAt < timeoutMs) {
- if (predicate()) return;
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
- }
- assert.fail('Timed out waiting for condition.');
- }
- function freePort() {
- return new Promise((resolve, reject) => {
- const server = net.createServer();
- server.listen(0, '127.0.0.1', () => {
- const address = server.address();
- server.close(() => {
- if (address && typeof address === 'object') resolve(address.port);
- else reject(new Error('Unable to allocate a test port.'));
- });
- });
- });
- }
- function waitForOutput(child, text) {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
- const chunks = [];
- const onData = (chunk) => {
- chunks.push(String(chunk));
- if (chunks.join('').includes(text)) {
- clearTimeout(timeout);
- child.stdout.off('data', onData);
- child.stderr.off('data', onData);
- resolve();
- }
- };
- child.stdout.on('data', onData);
- child.stderr.on('data', onData);
- child.once('exit', (code) => {
- clearTimeout(timeout);
- reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
- });
- });
- }
- function waitForExit(child, timeoutMs) {
- if (child.exitCode !== null) return Promise.resolve(true);
- return new Promise((resolve) => {
- const timeout = setTimeout(() => {
- child.off('exit', onExit);
- resolve(false);
- }, timeoutMs);
- const onExit = () => {
- clearTimeout(timeout);
- resolve(true);
- };
- child.once('exit', onExit);
- });
- }
|