import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { authenticateApiToken, createApiToken, createDomain, createInboundMailbox, createInboundMessage, createUser, getAdminResourceInventory, getApiToken, getInboundMessage, initDatabase, listApiTokens, listInboundMailboxFolders, listInboundMailboxes, revokeApiToken, rotateApiToken, searchInboundMessages, updateUser } from '../src/db.js'; test('API tokens are recoverable only for explicit owner views and rotate without weakening authentication', () => { const database = initDatabase(tempDataDir(), 'api-token-recovery-secret'); const user = createUser({ username: 'token-owner', email: 'token-owner@example.com', password: 'password123' }); const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); const created = createApiToken(user.id, 'Receive worker', { scopes: ['send', 'messages:read'], expiresAt, mailboxAccess: 'owner' }); assert.match(created.token, /^mh_/); assert.equal(created.tokenRecoverable, true); assert.equal(created.mailboxAccess, 'owner'); assert.deepEqual(created.mailboxIds, []); const defaultListItem = listApiTokens(user.id)[0]; assert.equal('token' in defaultListItem, false); assert.equal(defaultListItem.tokenRecoverable, true); assert.equal(listApiTokens(user.id, { includeToken: true })[0].token, created.token); assert.equal('token' in authenticateApiToken(created.token).token, false); assert.equal('token' in getAdminResourceInventory().users[0].apiTokens[0], false); authenticateApiToken(created.token); assert.ok(getApiToken(created.id, user.id).lastUsedAt); database.prepare('UPDATE api_tokens SET token_secret = ? WHERE id = ?').run('', created.id); const historical = getApiToken(created.id, user.id, { includeToken: true }); assert.equal(historical.tokenRecoverable, false); assert.equal('token' in historical, false); assert.equal(authenticateApiToken(created.token).user.id, user.id); const rotated = rotateApiToken(created.id, user.id); assert.match(rotated.token, /^mh_/); assert.notEqual(rotated.token, created.token); assert.deepEqual(rotated.scopes, ['send', 'messages:read']); assert.equal(rotated.mailboxAccess, 'owner'); assert.equal(rotated.expiresAt, expiresAt); assert.equal(rotated.lastUsedAt, null); assert.equal(authenticateApiToken(created.token), null); assert.equal(authenticateApiToken(rotated.token).user.id, user.id); assert.equal(revokeApiToken(created.id, user.id), true); assert.throws(() => rotateApiToken(created.id, user.id), /已撤销/); }); test('API token mailbox scopes validate ownership and keep message queries fail closed', () => { const database = initDatabase(tempDataDir(), 'api-token-mailbox-secret'); const admin = createUser({ username: 'scope-admin', email: 'scope-admin@example.com', password: 'password123', role: 'admin' }); const alice = createUser({ username: 'scope-alice', email: 'scope-alice@example.com', password: 'password123' }); const bob = createUser({ username: 'scope-bob', email: 'scope-bob@example.com', password: 'password123' }); createDomain(alice.id, domainFixture('scope-alice.example')); createDomain(bob.id, domainFixture('scope-bob.example')); const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@scope-alice.example' }); const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@scope-bob.example' }); const aliceMessage = createInboundMessage(aliceMailbox, messageFixture('Alice message')); const bobMessage = createInboundMessage(bobMailbox, messageFixture('Bob message')); assert.throws( () => createApiToken(alice.id, 'Empty selection', { scopes: ['messages:read'], mailboxAccess: 'selected', mailboxIds: [] }), /至少一个/ ); assert.throws( () => createApiToken(alice.id, 'Cross-user selection', { scopes: ['messages:read'], mailboxAccess: 'selected', mailboxIds: [bobMailbox.id] }), /不存在或无权访问/ ); assert.throws( () => createApiToken(alice.id, 'All mailboxes', { scopes: ['messages:read'], mailboxAccess: 'all' }), /只有管理员/ ); const aliceSelected = createApiToken(alice.id, 'Alice selected', { scopes: ['messages:read'], mailboxAccess: 'selected', mailboxIds: [aliceMailbox.id, aliceMailbox.id] }); assert.deepEqual(aliceSelected.mailboxIds, [aliceMailbox.id]); assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: aliceSelected.mailboxIds }).total, 1); assert.equal( searchInboundMessages( alice.id, { mailboxId: bobMailbox.id }, { mailboxIds: aliceSelected.mailboxIds } ).total, 0 ); assert.equal(getInboundMessage(alice.id, bobMessage.id, { mailboxIds: aliceSelected.mailboxIds }), null); assert.equal(listInboundMailboxFolders(alice.id, bobMailbox.id, { mailboxIds: aliceSelected.mailboxIds }), null); const adminSelected = createApiToken(admin.id, 'Admin selected', { scopes: ['messages:read'], mailboxAccess: 'selected', mailboxIds: [bobMailbox.id] }); const selectedAccess = { includeAllUsers: true, mailboxIds: adminSelected.mailboxIds }; const selectedMessages = searchInboundMessages(admin.id, {}, selectedAccess); assert.equal(selectedMessages.total, 1); assert.equal(selectedMessages.messages[0].id, bobMessage.id); assert.equal(getInboundMessage(admin.id, aliceMessage.id, selectedAccess), null); assert.equal(getInboundMessage(admin.id, bobMessage.id, selectedAccess).subject, 'Bob message'); assert.equal(listInboundMailboxFolders(admin.id, aliceMailbox.id, selectedAccess), null); assert.equal( listInboundMailboxFolders(admin.id, bobMailbox.id, selectedAccess) .find((folder) => folder.name === 'INBOX').messageCount, 1 ); const adminAll = createApiToken(admin.id, 'Admin all', { scopes: ['messages:read'], mailboxAccess: 'all' }); assert.equal(adminAll.mailboxAccess, 'all'); assert.equal(searchInboundMessages(admin.id, {}, { includeAllUsers: true }).total, 2); assert.equal(listInboundMailboxes(admin.id, { includeAllUsers: true }).length, 2); assert.throws(() => updateUser(admin.id, { role: 'user' }), /至少保留一个/); createUser({ username: 'scope-backup-admin', email: 'scope-backup-admin@example.com', password: 'password123', role: 'admin' }); assert.equal(updateUser(admin.id, { role: 'user' }).role, 'user'); assert.equal(authenticateApiToken(adminAll.token).user.role, 'user'); database .prepare('UPDATE api_tokens SET mailbox_access_json = ? WHERE id = ?') .run('{"mode":"selected","mailboxIds":"broken"}', aliceSelected.id); const corrupted = getApiToken(aliceSelected.id, alice.id); assert.equal(corrupted.mailboxAccess, 'selected'); assert.deepEqual(corrupted.mailboxIds, []); assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: corrupted.mailboxIds }).total, 0); }); function tempDataDir() { return mkdtempSync(path.join(tmpdir(), 'mailhub-api-token-db-')); } function domainFixture(domain) { return { domain, selector: 'mh', verificationToken: `verify-${domain}`, dkimPublic: 'public', dkimPrivate: 'private', senderHost: `mail.${domain}`, sendingIp: '192.0.2.44', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }; } function messageFixture(subject) { return { sender: 'sender@example.net', recipients: ['inbox@example.net'], subject, messageId: `<${subject.toLowerCase().replaceAll(' ', '-')}@example.net>`, rawMessage: `Subject: ${subject}\r\n\r\n${subject}`, textBody: subject }; }