|
|
@@ -0,0 +1,532 @@
|
|
|
+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: '<token-inbound-' + sequence + '@example.net>',
|
|
|
+ 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);
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|