import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { test } from 'node:test'; test('API tokens enforce inbound mailbox scope and keep token management session-only', async (t) => { const fixture = await startTestServer(); try { const seeded = seedInboundAccessFixtures(fixture.dataDir, fixture.sessionSecret); const adminCookie = await login(fixture.baseUrl, 'admin', 'password123'); const securityAdminCookie = await login(fixture.baseUrl, 'token-security-admin', 'password123'); const aliceCookie = await login(fixture.baseUrl, 'token-alice', 'password123'); const bobCookie = await login(fixture.baseUrl, 'token-bob', 'password123'); const sendOnly = await createToken(fixture.baseUrl, aliceCookie, { name: 'alice send only', scopes: ['send'] }); const aliceOwner = await createToken(fixture.baseUrl, aliceCookie, { name: 'alice owner messages', scopes: ['messages:read'], mailboxAccess: 'owner' }); const aliceSelected = await createToken(fixture.baseUrl, aliceCookie, { name: 'alice selected messages', scopes: ['messages:read', 'mailboxes:read'], mailboxAccess: 'selected', mailboxIds: [seeded.alicePrimaryMailboxId] }); const adminSelected = await createToken(fixture.baseUrl, adminCookie, { name: 'admin selected bob', scopes: ['messages:read', 'mailboxes:read'], mailboxAccess: 'selected', mailboxIds: [seeded.bobMailboxId] }); const adminAll = await createToken(fixture.baseUrl, adminCookie, { name: 'admin all messages', scopes: ['messages:read', 'mailboxes:read'], mailboxAccess: 'all' }); await t.test('Bearer message reads are scope-limited, isolated, and read-only', async () => { const deniedByScope = await requestJson(fixture.baseUrl, '/api/inbound-messages', { bearer: sendOnly.token }); assert.equal(deniedByScope.status, 403); assert.match(deniedByScope.body.error, /messages:read/); const ownerList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', { bearer: aliceOwner.token }); assert.equal(ownerList.status, 200); assert.equal(ownerList.body.total, 2); assert.deepEqual( new Set(ownerList.body.messages.map((message) => message.mailboxId)), new Set([seeded.alicePrimaryMailboxId, seeded.aliceSecondaryMailboxId]) ); assert.equal(ownerList.body.messages.some((message) => message.mailboxId === seeded.bobMailboxId), false); const ownerCrossUserFilter = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.bobMailboxId}`, { bearer: aliceOwner.token } ); assert.equal(ownerCrossUserFilter.status, 200); assert.equal(ownerCrossUserFilter.body.total, 0); assert.deepEqual(ownerCrossUserFilter.body.messages, []); const selectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', { bearer: aliceSelected.token }); assert.equal(selectedList.status, 200); assert.equal(selectedList.body.total, 1); assert.deepEqual(selectedList.body.messages.map((message) => message.id), [seeded.alicePrimaryMessageId]); const selectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', { bearer: aliceSelected.token }); assert.equal(selectedMailboxes.status, 200); assert.deepEqual(selectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.alicePrimaryMailboxId]); const selectedUnlistedFilter = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceSecondaryMailboxId}`, { bearer: aliceSelected.token } ); assert.equal(selectedUnlistedFilter.status, 200); assert.equal(selectedUnlistedFilter.body.total, 0); const selectedAuthorizedDetail = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.alicePrimaryMessageId}`, { bearer: aliceSelected.token } ); assert.equal(selectedAuthorizedDetail.status, 200); assert.equal(selectedAuthorizedDetail.body.message.subject, 'Alice primary message'); for (const [pathname, bearer] of [ [`/api/inbound-messages/${seeded.bobMessageId}`, aliceOwner.token], [`/api/inbound-messages/${seeded.aliceSecondaryMessageId}`, aliceSelected.token], [`/api/inbound-mailboxes/${seeded.bobMailboxId}/folders`, aliceOwner.token], [`/api/inbound-mailboxes/${seeded.aliceSecondaryMailboxId}/folders`, aliceSelected.token] ]) { const hidden = await requestJson(fixture.baseUrl, pathname, { bearer }); assert.equal(hidden.status, 404, pathname); } const adminSelectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', { bearer: adminSelected.token }); assert.equal(adminSelectedList.status, 200); assert.equal(adminSelectedList.body.total, 1); assert.deepEqual(adminSelectedList.body.messages.map((message) => message.id), [seeded.bobMessageId]); const adminSelectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', { bearer: adminSelected.token }); assert.equal(adminSelectedMailboxes.status, 200); assert.deepEqual(adminSelectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.bobMailboxId]); const adminSelectedDetail = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.bobMessageId}`, { bearer: adminSelected.token } ); assert.equal(adminSelectedDetail.status, 200); assert.equal(adminSelectedDetail.body.message.subject, 'Bob private message'); const adminAllList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', { bearer: adminAll.token }); assert.equal(adminAllList.status, 200); assert.equal(adminAllList.body.total, 4); assert.equal(adminAllList.body.messages.length, 4); const adminAllMailboxList = await requestJson(fixture.baseUrl, '/api/mailboxes', { bearer: adminAll.token }); assert.equal(adminAllMailboxList.status, 200); assert.equal(adminAllMailboxList.body.mailboxes.length, 4); const deniedMutation = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.alicePrimaryMessageId}`, { method: 'PATCH', bearer: aliceSelected.token, body: { read: true } } ); assert.equal(deniedMutation.status, 401); const unchanged = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.alicePrimaryMessageId}`, { cookie: aliceCookie } ); assert.equal(unchanged.status, 200); assert.equal(unchanged.body.message.read, false); }); await t.test('session token management reveals recoverable values and enforces admin-only all-mailbox access', async () => { const aliceTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie }); assert.equal(aliceTokens.status, 200); assert.match(aliceTokens.response.headers.get('cache-control') || '', /no-store/i); for (const created of [sendOnly, aliceOwner, aliceSelected]) { const listed = aliceTokens.body.tokens.find((token) => token.id === created.id); assert.ok(listed, created.name); assert.equal(listed.tokenRecoverable, true); assert.equal(listed.token, created.token); } const adminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: adminCookie }); assert.equal(adminTokens.status, 200); assert.match(adminTokens.response.headers.get('cache-control') || '', /no-store/i); const historical = adminTokens.body.tokens.find((token) => token.id === seeded.historicalTokenId); assert.ok(historical); assert.equal(historical.name, 'historical unrecoverable'); assert.equal(historical.tokenRecoverable, false); assert.equal(Object.hasOwn(historical, 'token'), false); const basicAdminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { basic: ['admin', 'password123'] }); assert.equal(basicAdminTokens.status, 200); assert.equal(basicAdminTokens.body.tokens.every((token) => !Object.hasOwn(token, 'token')), true); const ordinaryAll = await requestJson(fixture.baseUrl, '/api/api-tokens', { method: 'POST', cookie: aliceCookie, body: { name: 'ordinary all denied', scopes: ['messages:read'], mailboxAccess: 'all' } }); assert.equal(ordinaryAll.status, 400); assert.match(ordinaryAll.body.error, /管理员/); const ordinaryCrossUserSelected = await requestJson(fixture.baseUrl, '/api/api-tokens', { method: 'POST', cookie: aliceCookie, body: { name: 'ordinary cross-user selected denied', scopes: ['messages:read'], mailboxAccess: 'selected', mailboxIds: [seeded.bobMailboxId] } }); assert.equal(ordinaryCrossUserSelected.status, 400); assert.match(ordinaryCrossUserSelected.body.error, /不存在|无权访问/); const adminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', { cookie: adminCookie }); assert.equal(adminAllMailboxes.status, 200); assert.equal(adminAllMailboxes.body.mailboxes.length, 4); assert.deepEqual( new Set(adminAllMailboxes.body.mailboxes.map((mailbox) => mailbox.id)), new Set([ seeded.adminMailboxId, seeded.alicePrimaryMailboxId, seeded.aliceSecondaryMailboxId, seeded.bobMailboxId ]) ); const ordinaryAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', { cookie: bobCookie }); assert.equal(ordinaryAllMailboxes.status, 403); const basicAdminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', { basic: ['admin', 'password123'] }); assert.equal(basicAdminAllMailboxes.status, 403); const rotated = await requestJson(fixture.baseUrl, `/api/api-tokens/${aliceOwner.id}/rotate`, { method: 'POST', cookie: aliceCookie }); assert.equal(rotated.status, 200); assert.match(rotated.response.headers.get('cache-control') || '', /no-store/i); assert.equal(rotated.body.token.id, aliceOwner.id); assert.equal(rotated.body.token.tokenRecoverable, true); assert.ok(rotated.body.token.token); assert.notEqual(rotated.body.token.token, aliceOwner.token); const oldTokenRejected = await requestJson(fixture.baseUrl, '/api/inbound-messages', { bearer: aliceOwner.token }); assert.equal(oldTokenRejected.status, 401); const newTokenAccepted = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', { bearer: rotated.body.token.token }); assert.equal(newTokenAccepted.status, 200); assert.equal(newTokenAccepted.body.total, 2); const listAfterRotate = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie }); const rotatedSummary = listAfterRotate.body.tokens.find((token) => token.id === aliceOwner.id); assert.equal(rotatedSummary.token, rotated.body.token.token); assert.notEqual(rotatedSummary.token, aliceOwner.token); const demoted = await requestJson(fixture.baseUrl, `/api/admin/users/${seeded.adminUserId}`, { method: 'PATCH', cookie: securityAdminCookie, body: { role: 'user' } }); assert.equal(demoted.status, 200); assert.equal(demoted.body.user.role, 'user'); const allMessagesAfterDemotion = await requestJson(fixture.baseUrl, '/api/inbound-messages', { bearer: adminAll.token }); assert.equal(allMessagesAfterDemotion.status, 200); assert.equal(allMessagesAfterDemotion.body.total, 0); const allMailboxesAfterDemotion = await requestJson(fixture.baseUrl, '/api/mailboxes', { bearer: adminAll.token }); assert.equal(allMailboxesAfterDemotion.status, 200); assert.deepEqual(allMailboxesAfterDemotion.body.mailboxes, []); }); } finally { fixture.child.kill('SIGTERM'); await waitForExit(fixture.child, 1000); } }); async function createToken(baseUrl, cookie, input) { const result = await requestJson(baseUrl, '/api/api-tokens', { method: 'POST', cookie, body: input }); assert.equal(result.status, 201, JSON.stringify(result.body)); assert.match(result.response.headers.get('cache-control') || '', /no-store/i); assert.ok(result.body.token.token); return result.body.token; } function seedInboundAccessFixtures(dataDir, sessionSecret) { const script = ` import { DatabaseSync } from 'node:sqlite'; import path from 'node:path'; import { createApiToken, createDomain, createInboundMailbox, createInboundMessage, createUser, getUserByLogin, initDatabase } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const admin = getUserByLogin('admin'); const alice = createUser({ username: 'token-alice', email: 'token-alice@example.test', password: 'password123', status: 'active' }); const bob = createUser({ username: 'token-bob', email: 'token-bob@example.test', password: 'password123', status: 'active' }); createUser({ username: 'token-security-admin', email: 'token-security-admin@example.test', password: 'password123', role: 'admin', status: 'active' }); const createUserDomain = (user, name) => createDomain(user.id, { domain: name, selector: 'mh', verificationToken: 'verify-' + name, dkimPublic: 'public-' + name, dkimPrivate: 'private-' + name, senderHost: 'mail.' + name, sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); createUserDomain(admin, 'token-admin.example'); createUserDomain(alice, 'token-alice.example'); createUserDomain(bob, 'token-bob.example'); const adminMailbox = createInboundMailbox(admin.id, { address: 'inbox@token-admin.example', password: 'mailbox-password' }); const alicePrimaryMailbox = createInboundMailbox(alice.id, { address: 'primary@token-alice.example', password: 'mailbox-password' }); const aliceSecondaryMailbox = createInboundMailbox(alice.id, { address: 'secondary@token-alice.example', password: 'mailbox-password' }); const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@token-bob.example', password: 'mailbox-password' }); const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, { sender: 'sender@example.net', recipients: [mailbox.address], subject, messageId: '', rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject, textBody: subject, receivedAt: '2026-07-14T0' + sequence + ':00:00.000Z' }); const adminMessage = createMessage(adminMailbox, 'Admin private message', 1); const alicePrimaryMessage = createMessage(alicePrimaryMailbox, 'Alice primary message', 2); const aliceSecondaryMessage = createMessage(aliceSecondaryMailbox, 'Alice secondary message', 3); const bobMessage = createMessage(bobMailbox, 'Bob private message', 4); const historical = createApiToken(admin.id, 'historical unrecoverable', { scopes: ['messages:read'], mailboxAccess: 'owner' }); const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite')); database.prepare("UPDATE api_tokens SET token_secret = '' WHERE id = ?").run(historical.id); database.close(); console.log(JSON.stringify({ adminUserId: admin.id, adminMailboxId: adminMailbox.id, alicePrimaryMailboxId: alicePrimaryMailbox.id, aliceSecondaryMailboxId: aliceSecondaryMailbox.id, bobMailboxId: bobMailbox.id, adminMessageId: adminMessage.id, alicePrimaryMessageId: alicePrimaryMessage.id, aliceSecondaryMessageId: aliceSecondaryMessage.id, bobMessageId: bobMessage.id, historicalTokenId: historical.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); } async function requestJson(baseUrl, pathname, { method = 'GET', cookie = '', bearer = '', basic = null, body } = {}) { const headers = {}; if (cookie) headers.Cookie = cookie; if (bearer) headers.Authorization = `Bearer ${bearer}`; if (basic) headers.Authorization = `Basic ${Buffer.from(basic.join(':')).toString('base64')}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; const response = await fetch(`${baseUrl}${pathname}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' }); const text = await response.text(); return { response, status: response.status, body: text ? JSON.parse(text) : null }; } async function login(baseUrl, username, password) { const response = await fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); assert.equal(response.status, 200); const cookie = response.headers.get('set-cookie')?.split(';')[0] || ''; assert.ok(cookie); return cookie; } async function startTestServer() { const port = await freePort(); const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-token-inbound-auth-')); const sessionSecret = 'token-inbound-auth-session-secret'; const child = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, ADMIN_USER: 'admin', ADMIN_EMAIL: 'admin@example.test', ADMIN_PASSWORD: 'password123', DNS_AUTO_CHECK_ENABLED: 'false', DELIVERY_TRACKING_ENABLED: 'false', WEBHOOK_WORKER_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 }; } 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); let output = ''; const onData = (chunk) => { output += chunk.toString(); if (!output.includes(text)) return; clearTimeout(timeout); child.stdout.off('data', onData); resolve(); }; child.stdout.on('data', onData); child.once('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited before startup with code ${code}`)); }); }); } function waitForExit(child, timeoutMs) { if (child.exitCode !== null) return Promise.resolve(child.exitCode); return new Promise((resolve) => { const timeout = setTimeout(() => resolve(null), timeoutMs); child.once('exit', (code) => { clearTimeout(timeout); resolve(code); }); }); }