|
|
@@ -0,0 +1,703 @@
|
|
|
+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('mailbox grants constrain admin, session, token, and send API access', async (t) => {
|
|
|
+ const smtp = await startFakeSmtpServer();
|
|
|
+ const readerSmtp = await startFakeSmtpServer();
|
|
|
+ const fixture = await startTestServer(smtp.port);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port);
|
|
|
+ const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
|
|
|
+ const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123');
|
|
|
+ const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123');
|
|
|
+ const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123');
|
|
|
+
|
|
|
+ await t.test('only administrators can list and replace mailbox grants', async () => {
|
|
|
+ const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
|
|
|
+ cookie: readerCookie
|
|
|
+ });
|
|
|
+ assert.equal(ordinaryList.status, 403);
|
|
|
+
|
|
|
+ const ordinaryReplace = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
|
|
|
+ {
|
|
|
+ method: 'PUT',
|
|
|
+ cookie: readerCookie,
|
|
|
+ body: { grants: [] }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ assert.equal(ordinaryReplace.status, 403);
|
|
|
+
|
|
|
+ const malformed = await fetch(
|
|
|
+ `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
|
|
|
+ {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ Cookie: adminCookie
|
|
|
+ },
|
|
|
+ body: '{"grants":'
|
|
|
+ }
|
|
|
+ );
|
|
|
+ assert.equal(malformed.status, 400);
|
|
|
+ assert.equal(typeof (await malformed.json()).error, 'string');
|
|
|
+
|
|
|
+ const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
|
|
|
+ cookie: adminCookie
|
|
|
+ });
|
|
|
+ assert.equal(initial.status, 200);
|
|
|
+ const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId);
|
|
|
+ assert.equal(initialShared.owner.id, seeded.ownerUserId);
|
|
|
+ assert.deepEqual(initialShared.grants, []);
|
|
|
+
|
|
|
+ const replaced = await replaceGrants(
|
|
|
+ fixture.baseUrl,
|
|
|
+ adminCookie,
|
|
|
+ seeded.sharedMailboxId,
|
|
|
+ [
|
|
|
+ { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
|
|
|
+ { userId: seeded.viewerUserId, permissions: ['view'] }
|
|
|
+ ]
|
|
|
+ );
|
|
|
+ assert.equal(replaced.mailbox.id, seeded.sharedMailboxId);
|
|
|
+ assert.equal(replaced.owner.id, seeded.ownerUserId);
|
|
|
+ assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), {
|
|
|
+ view: true,
|
|
|
+ receive: true,
|
|
|
+ send: true
|
|
|
+ });
|
|
|
+ assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), {
|
|
|
+ view: true,
|
|
|
+ receive: false,
|
|
|
+ send: false
|
|
|
+ });
|
|
|
+
|
|
|
+ const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
|
|
|
+ cookie: adminCookie
|
|
|
+ });
|
|
|
+ const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId);
|
|
|
+ assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), {
|
|
|
+ view: true,
|
|
|
+ receive: true,
|
|
|
+ send: true
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
|
|
|
+ assert.deepEqual(
|
|
|
+ await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
|
|
|
+ ['other@access-owner.example', 'shared@access-owner.example']
|
|
|
+ );
|
|
|
+ assert.deepEqual(
|
|
|
+ await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie),
|
|
|
+ ['own@access-reader.example']
|
|
|
+ );
|
|
|
+ assert.deepEqual(
|
|
|
+ await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie),
|
|
|
+ ['own@access-reader.example', 'shared@access-owner.example']
|
|
|
+ );
|
|
|
+ assert.deepEqual(
|
|
|
+ await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie),
|
|
|
+ ['shared@access-owner.example']
|
|
|
+ );
|
|
|
+
|
|
|
+ const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
|
|
|
+ cookie: readerCookie
|
|
|
+ });
|
|
|
+ assert.equal(ordinaryAll.status, 403);
|
|
|
+
|
|
|
+ const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
|
|
|
+ cookie: adminCookie
|
|
|
+ });
|
|
|
+ assert.equal(adminAll.status, 200);
|
|
|
+ assert.deepEqual(
|
|
|
+ adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(),
|
|
|
+ [
|
|
|
+ 'other@access-owner.example',
|
|
|
+ 'own@access-reader.example',
|
|
|
+ 'shared@access-owner.example'
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', {
|
|
|
+ cookie: adminCookie
|
|
|
+ });
|
|
|
+ assert.equal(invalid.status, 400);
|
|
|
+ });
|
|
|
+
|
|
|
+ await t.test('receive grants allow message reads while view-only grants do not', async () => {
|
|
|
+ const list = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`,
|
|
|
+ { cookie: readerCookie }
|
|
|
+ );
|
|
|
+ assert.equal(list.status, 200);
|
|
|
+ assert.equal(list.body.total, 1);
|
|
|
+ assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
|
|
|
+
|
|
|
+ const detail = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ { cookie: readerCookie }
|
|
|
+ );
|
|
|
+ assert.equal(detail.status, 200);
|
|
|
+ assert.equal(detail.body.message.subject, 'Shared grant message');
|
|
|
+
|
|
|
+ const marked = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ {
|
|
|
+ method: 'PATCH',
|
|
|
+ cookie: readerCookie,
|
|
|
+ body: { read: true }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ assert.equal(marked.status, 200);
|
|
|
+ assert.equal(marked.body.message.read, true);
|
|
|
+
|
|
|
+ const viewerList = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
|
|
|
+ { cookie: viewerCookie }
|
|
|
+ );
|
|
|
+ assert.equal(viewerList.status, 200);
|
|
|
+ assert.equal(viewerList.body.total, 0);
|
|
|
+
|
|
|
+ const viewerDetail = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ { cookie: viewerCookie }
|
|
|
+ );
|
|
|
+ assert.equal(viewerDetail.status, 404);
|
|
|
+
|
|
|
+ const viewerMark = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ {
|
|
|
+ method: 'PATCH',
|
|
|
+ cookie: viewerCookie,
|
|
|
+ body: { read: false }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ assert.equal(viewerMark.status, 404);
|
|
|
+ });
|
|
|
+
|
|
|
+ await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => {
|
|
|
+ const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
|
|
|
+ name: 'grant owner scope',
|
|
|
+ scopes: ['mailboxes:read', 'messages:read'],
|
|
|
+ mailboxAccess: 'owner'
|
|
|
+ });
|
|
|
+ const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
|
|
|
+ bearer: ownerToken.token
|
|
|
+ });
|
|
|
+ assert.equal(ownerMessages.status, 200);
|
|
|
+ assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]);
|
|
|
+ const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
|
|
|
+ bearer: ownerToken.token
|
|
|
+ });
|
|
|
+ assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]);
|
|
|
+
|
|
|
+ const selectedToken = await createToken(fixture.baseUrl, readerCookie, {
|
|
|
+ name: 'grant selected scope',
|
|
|
+ scopes: ['mailboxes:read', 'messages:read'],
|
|
|
+ mailboxAccess: 'selected',
|
|
|
+ mailboxIds: [seeded.sharedMailboxId]
|
|
|
+ });
|
|
|
+ const selectedMessages = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
|
|
|
+ { bearer: selectedToken.token }
|
|
|
+ );
|
|
|
+ assert.equal(selectedMessages.status, 200);
|
|
|
+ assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
|
|
|
+ const selectedDetail = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ { bearer: selectedToken.token }
|
|
|
+ );
|
|
|
+ assert.equal(selectedDetail.status, 200);
|
|
|
+
|
|
|
+ await replaceGrants(
|
|
|
+ fixture.baseUrl,
|
|
|
+ adminCookie,
|
|
|
+ seeded.sharedMailboxId,
|
|
|
+ [{ userId: seeded.viewerUserId, permissions: ['view'] }]
|
|
|
+ );
|
|
|
+
|
|
|
+ const afterRevoke = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
|
|
|
+ { bearer: selectedToken.token }
|
|
|
+ );
|
|
|
+ assert.equal(afterRevoke.status, 200);
|
|
|
+ assert.equal(afterRevoke.body.total, 0);
|
|
|
+ const detailAfterRevoke = await requestJson(
|
|
|
+ fixture.baseUrl,
|
|
|
+ `/api/inbound-messages/${seeded.sharedMessageId}`,
|
|
|
+ { bearer: selectedToken.token }
|
|
|
+ );
|
|
|
+ assert.equal(detailAfterRevoke.status, 404);
|
|
|
+ });
|
|
|
+
|
|
|
+ await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => {
|
|
|
+ await replaceGrants(
|
|
|
+ fixture.baseUrl,
|
|
|
+ adminCookie,
|
|
|
+ seeded.sharedMailboxId,
|
|
|
+ [{ userId: seeded.readerUserId, permissions: ['send'] }]
|
|
|
+ );
|
|
|
+ const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
|
|
|
+ name: 'send owner scope',
|
|
|
+ scopes: ['send'],
|
|
|
+ mailboxAccess: 'owner'
|
|
|
+ });
|
|
|
+ const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, {
|
|
|
+ name: 'send selected shared mailbox',
|
|
|
+ scopes: ['send'],
|
|
|
+ mailboxAccess: 'selected',
|
|
|
+ mailboxIds: [seeded.sharedMailboxId]
|
|
|
+ });
|
|
|
+ const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, {
|
|
|
+ name: 'send selected owned mailbox',
|
|
|
+ scopes: ['send'],
|
|
|
+ mailboxAccess: 'selected',
|
|
|
+ mailboxIds: [seeded.readerMailboxId]
|
|
|
+ });
|
|
|
+ const mailFromCount = smtpMailFromCommands(smtp).length;
|
|
|
+
|
|
|
+ await assertSendDenied(fixture.baseUrl, {
|
|
|
+ bearer: ownerToken.token,
|
|
|
+ from: 'shared@access-owner.example'
|
|
|
+ });
|
|
|
+ await assertSendDenied(fixture.baseUrl, {
|
|
|
+ bearer: selectedOwnToken.token,
|
|
|
+ from: 'shared@access-owner.example'
|
|
|
+ });
|
|
|
+
|
|
|
+ const selectedSend = await sendApiMessage(fixture.baseUrl, {
|
|
|
+ bearer: selectedSharedToken.token,
|
|
|
+ from: 'shared@access-owner.example',
|
|
|
+ subject: 'Selected token mailbox grant send'
|
|
|
+ });
|
|
|
+ assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body));
|
|
|
+ assert.equal(selectedSend.body.queued, true);
|
|
|
+ assert.deepEqual(
|
|
|
+ smtpMailFromCommands(smtp).slice(mailFromCount),
|
|
|
+ ['MAIL FROM:<shared@access-owner.example>']
|
|
|
+ );
|
|
|
+
|
|
|
+ await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []);
|
|
|
+ await assertSendDenied(fixture.baseUrl, {
|
|
|
+ bearer: selectedSharedToken.token,
|
|
|
+ from: 'shared@access-owner.example'
|
|
|
+ });
|
|
|
+ assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1);
|
|
|
+ });
|
|
|
+
|
|
|
+ await t.test('send grants authorize only the exact mailbox address and aliases', async () => {
|
|
|
+ await replaceGrants(
|
|
|
+ fixture.baseUrl,
|
|
|
+ adminCookie,
|
|
|
+ seeded.sharedMailboxId,
|
|
|
+ [
|
|
|
+ { userId: seeded.readerUserId, permissions: ['send'] },
|
|
|
+ { userId: seeded.viewerUserId, permissions: ['view'] }
|
|
|
+ ]
|
|
|
+ );
|
|
|
+ const mailFromCount = smtpMailFromCommands(smtp).length;
|
|
|
+ const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length;
|
|
|
+
|
|
|
+ const forbiddenRelay = await sendApiMessage(fixture.baseUrl, {
|
|
|
+ cookie: readerCookie,
|
|
|
+ from: 'shared@access-owner.example',
|
|
|
+ subject: 'Shared mailbox cannot use grantee relay',
|
|
|
+ smtpRelayId: seeded.readerRelayId
|
|
|
+ });
|
|
|
+ assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body));
|
|
|
+ assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/);
|
|
|
+ assert.equal(smtpMailFromCommands(smtp).length, mailFromCount);
|
|
|
+ assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount);
|
|
|
+
|
|
|
+ for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) {
|
|
|
+ const sent = await sendApiMessage(fixture.baseUrl, {
|
|
|
+ cookie: readerCookie,
|
|
|
+ from,
|
|
|
+ subject: `Mailbox grant send from ${from}`
|
|
|
+ });
|
|
|
+ assert.equal(sent.status, 202, JSON.stringify(sent.body));
|
|
|
+ assert.equal(sent.body.queued, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ await assertSendDenied(fixture.baseUrl, {
|
|
|
+ cookie: readerCookie,
|
|
|
+ from: 'other@access-owner.example'
|
|
|
+ });
|
|
|
+
|
|
|
+ assert.deepEqual(
|
|
|
+ smtpMailFromCommands(smtp).slice(mailFromCount),
|
|
|
+ [
|
|
|
+ 'MAIL FROM:<shared@access-owner.example>',
|
|
|
+ 'MAIL FROM:<shared-alias@access-owner.example>'
|
|
|
+ ]
|
|
|
+ );
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ fixture.child.kill('SIGTERM');
|
|
|
+ await waitForExit(fixture.child, 1000);
|
|
|
+ await smtp.close();
|
|
|
+ await readerSmtp.close();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
|
|
|
+ const script = `
|
|
|
+ import {
|
|
|
+ createDomain,
|
|
|
+ createInboundMailbox,
|
|
|
+ createInboundMessage,
|
|
|
+ createUser,
|
|
|
+ initDatabase,
|
|
|
+ saveSmtpRelay
|
|
|
+ } from './src/db.js';
|
|
|
+ import { createDkimKeyPair } from './src/dkim.js';
|
|
|
+
|
|
|
+ initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
|
|
|
+ const owner = createUser({
|
|
|
+ username: 'access-owner',
|
|
|
+ email: 'access-owner@example.test',
|
|
|
+ password: 'password123',
|
|
|
+ status: 'active'
|
|
|
+ });
|
|
|
+ const reader = createUser({
|
|
|
+ username: 'access-reader',
|
|
|
+ email: 'access-reader@example.test',
|
|
|
+ password: 'password123',
|
|
|
+ status: 'active'
|
|
|
+ });
|
|
|
+ const viewer = createUser({
|
|
|
+ username: 'access-viewer',
|
|
|
+ email: 'access-viewer@example.test',
|
|
|
+ password: 'password123',
|
|
|
+ status: 'active'
|
|
|
+ });
|
|
|
+ const readerRelay = saveSmtpRelay(reader.id, {
|
|
|
+ name: 'Reader private relay',
|
|
|
+ host: '127.0.0.1',
|
|
|
+ port: Number(process.env.READER_RELAY_PORT),
|
|
|
+ secure: false,
|
|
|
+ username: '',
|
|
|
+ password: '',
|
|
|
+ helo: 'mail.access-reader.example'
|
|
|
+ });
|
|
|
+ const keys = createDkimKeyPair();
|
|
|
+ const createUserDomain = (user, name) => createDomain(user.id, {
|
|
|
+ domain: name,
|
|
|
+ selector: 'mh',
|
|
|
+ verificationToken: 'verify-' + name,
|
|
|
+ dkimPublic: keys.publicKey,
|
|
|
+ dkimPrivate: keys.privateKey,
|
|
|
+ senderHost: 'mail.' + name,
|
|
|
+ sendingIp: '127.0.0.1',
|
|
|
+ spfExtra: '',
|
|
|
+ dmarcPolicy: 'none',
|
|
|
+ dmarcRua: ''
|
|
|
+ });
|
|
|
+ createUserDomain(owner, 'access-owner.example');
|
|
|
+ createUserDomain(reader, 'access-reader.example');
|
|
|
+
|
|
|
+ const sharedMailbox = createInboundMailbox(owner.id, {
|
|
|
+ address: 'shared@access-owner.example',
|
|
|
+ password: 'mailbox-password',
|
|
|
+ aliases: ['shared-alias']
|
|
|
+ });
|
|
|
+ const otherMailbox = createInboundMailbox(owner.id, {
|
|
|
+ address: 'other@access-owner.example',
|
|
|
+ password: 'mailbox-password'
|
|
|
+ });
|
|
|
+ const readerMailbox = createInboundMailbox(reader.id, {
|
|
|
+ address: 'own@access-reader.example',
|
|
|
+ password: 'mailbox-password'
|
|
|
+ });
|
|
|
+ const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
|
|
|
+ sender: 'sender@example.net',
|
|
|
+ recipients: [mailbox.address],
|
|
|
+ subject,
|
|
|
+ messageId: '<mailbox-access-' + sequence + '@example.net>',
|
|
|
+ rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
|
|
|
+ textBody: subject,
|
|
|
+ receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z'
|
|
|
+ });
|
|
|
+ const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1);
|
|
|
+ createMessage(otherMailbox, 'Other owner message', 2);
|
|
|
+ const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3);
|
|
|
+
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ ownerUserId: owner.id,
|
|
|
+ readerUserId: reader.id,
|
|
|
+ viewerUserId: viewer.id,
|
|
|
+ sharedMailboxId: sharedMailbox.id,
|
|
|
+ otherMailboxId: otherMailbox.id,
|
|
|
+ readerMailboxId: readerMailbox.id,
|
|
|
+ readerRelayId: readerRelay.id,
|
|
|
+ sharedMessageId: sharedMessage.id,
|
|
|
+ readerMessageId: readerMessage.id
|
|
|
+ }));
|
|
|
+ `;
|
|
|
+ const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
|
+ cwd: process.cwd(),
|
|
|
+ env: {
|
|
|
+ ...process.env,
|
|
|
+ DATA_DIR: dataDir,
|
|
|
+ SESSION_SECRET: sessionSecret,
|
|
|
+ READER_RELAY_PORT: String(readerRelayPort)
|
|
|
+ },
|
|
|
+ encoding: 'utf8'
|
|
|
+ });
|
|
|
+ assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
|
+ return JSON.parse(result.stdout);
|
|
|
+}
|
|
|
+
|
|
|
+async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
|
|
|
+ const response = await requestJson(
|
|
|
+ baseUrl,
|
|
|
+ `/api/admin/inbound-mailboxes/${mailboxId}/access`,
|
|
|
+ {
|
|
|
+ method: 'PUT',
|
|
|
+ cookie: adminCookie,
|
|
|
+ body: { grants }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ assert.equal(response.status, 200, JSON.stringify(response.body));
|
|
|
+ return response.body.mailbox;
|
|
|
+}
|
|
|
+
|
|
|
+function findAdminMailbox(mailboxes, mailboxId) {
|
|
|
+ const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
|
|
|
+ assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);
|
|
|
+ return mailbox;
|
|
|
+}
|
|
|
+
|
|
|
+function grantPermissions(mailboxAccess, userId) {
|
|
|
+ const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId);
|
|
|
+ assert.ok(grant, `expected grant for user ${userId}`);
|
|
|
+ return grant.permissions;
|
|
|
+}
|
|
|
+
|
|
|
+async function mailboxAddresses(baseUrl, pathname, cookie) {
|
|
|
+ const response = await requestJson(baseUrl, pathname, { cookie });
|
|
|
+ assert.equal(response.status, 200, JSON.stringify(response.body));
|
|
|
+ return response.body.mailboxes.map((mailbox) => mailbox.address).sort();
|
|
|
+}
|
|
|
+
|
|
|
+async function createToken(baseUrl, cookie, input) {
|
|
|
+ const response = await requestJson(baseUrl, '/api/api-tokens', {
|
|
|
+ method: 'POST',
|
|
|
+ cookie,
|
|
|
+ body: input
|
|
|
+ });
|
|
|
+ assert.equal(response.status, 201, JSON.stringify(response.body));
|
|
|
+ assert.ok(response.body.token.token);
|
|
|
+ return response.body.token;
|
|
|
+}
|
|
|
+
|
|
|
+function sendApiMessage(baseUrl, {
|
|
|
+ cookie = '',
|
|
|
+ bearer = '',
|
|
|
+ from,
|
|
|
+ subject = 'Mailbox grant send',
|
|
|
+ smtpRelayId = null
|
|
|
+}) {
|
|
|
+ return requestJson(baseUrl, '/api/send', {
|
|
|
+ method: 'POST',
|
|
|
+ cookie,
|
|
|
+ bearer,
|
|
|
+ body: {
|
|
|
+ from,
|
|
|
+ to: 'recipient@example.net',
|
|
|
+ subject,
|
|
|
+ text: 'mailbox grant send test',
|
|
|
+ ...(smtpRelayId ? { smtpRelayId } : {})
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function assertSendDenied(baseUrl, input) {
|
|
|
+ const response = await sendApiMessage(baseUrl, {
|
|
|
+ ...input,
|
|
|
+ subject: 'Mailbox grant denial'
|
|
|
+ });
|
|
|
+ assert.equal(response.status, 403, JSON.stringify(response.body));
|
|
|
+ assert.match(response.body.error, /未获得发信权限/);
|
|
|
+}
|
|
|
+
|
|
|
+function smtpMailFromCommands(smtp) {
|
|
|
+ return smtp.commands.filter((command) => command.startsWith('MAIL FROM:'));
|
|
|
+}
|
|
|
+
|
|
|
+async function requestJson(baseUrl, pathname, {
|
|
|
+ method = 'GET',
|
|
|
+ cookie = '',
|
|
|
+ bearer = '',
|
|
|
+ body
|
|
|
+} = {}) {
|
|
|
+ const headers = {};
|
|
|
+ if (cookie) headers.Cookie = cookie;
|
|
|
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
|
+ 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 {
|
|
|
+ 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(smtpPort) {
|
|
|
+ const port = await freePort();
|
|
|
+ const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-'));
|
|
|
+ const sessionSecret = 'mailbox-access-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',
|
|
|
+ SMTP_HOST: '127.0.0.1',
|
|
|
+ SMTP_PORT: String(smtpPort),
|
|
|
+ SMTP_SECURE: 'false',
|
|
|
+ SMTP_HELO: 'mailhub-access.test'
|
|
|
+ },
|
|
|
+ stdio: ['ignore', 'pipe', 'pipe']
|
|
|
+ });
|
|
|
+ await waitForOutput(child, 'MailHub listening');
|
|
|
+ return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
|
|
|
+}
|
|
|
+
|
|
|
+function startFakeSmtpServer() {
|
|
|
+ const commands = [];
|
|
|
+ const server = net.createServer((socket) => {
|
|
|
+ socket.setEncoding('utf8');
|
|
|
+ socket.write('220 relay.test ESMTP ready\r\n');
|
|
|
+ let buffer = '';
|
|
|
+ let dataMode = false;
|
|
|
+ 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;
|
|
|
+ socket.write('250 2.0.0 queued as ACCESS123\r\n');
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ commands.push(line);
|
|
|
+ if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
|
|
|
+ else if (line.startsWith('MAIL FROM:')) socket.write('250 2.1.0 ok\r\n');
|
|
|
+ else if (line.startsWith('RCPT TO:')) socket.write('250 2.1.5 ok\r\n');
|
|
|
+ else if (line === 'DATA') {
|
|
|
+ dataMode = true;
|
|
|
+ socket.write('354 end data\r\n');
|
|
|
+ } else if (line === 'QUIT') {
|
|
|
+ socket.write('221 bye\r\n');
|
|
|
+ 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,
|
|
|
+ close: () => new Promise((closeResolve) => server.close(closeResolve))
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+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.'));
|
|
|
+ });
|
|
|
+ });
|
|
|
+ server.on('error', reject);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function waitForOutput(child, text, timeoutMs = 8000) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ let output = '';
|
|
|
+ const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs);
|
|
|
+ const onData = (chunk) => {
|
|
|
+ output += chunk.toString();
|
|
|
+ if (!output.includes(text)) return;
|
|
|
+ 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 before startup with code ${code}\n${output}`));
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+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);
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|