import { App as AntApp, ConfigProvider } from 'antd';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
import { I18nProvider } from '../../src/frontend/i18n/react';
import { api } from '../../src/frontend/services/api';
import { mailhubTheme } from '../../src/frontend/theme';
import type { ApiToken, InboundMailbox, RuntimeConfig, UserRole } from '../../src/frontend/types';
import ApiTokens from '../../src/pages/ApiTokens';
describe('API token secrets and message access', () => {
it('keeps a newly created full token copyable after acknowledgement', async () => {
const user = userEvent.setup();
const fullToken = 'mh_12345678.full-secret-value';
const summary = tokenFixture({ token: fullToken, tokenRecoverable: true });
vi.spyOn(api, 'apiTokens')
.mockResolvedValueOnce({ tokens: [] })
.mockResolvedValue({ tokens: [summary] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: summary });
renderPage();
await screen.findByText(/新 API 密钥会加密保存并支持完整复制/);
await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
const editor = await screen.findByRole('dialog');
await user.type(within(editor).getByLabelText('名称'), 'CI sender');
await user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
await waitFor(() => expect(createToken).toHaveBeenCalledWith({
name: 'CI sender',
scopes: ['send'],
expiresAt: null,
mailboxAccess: 'owner',
mailboxIds: []
}));
expect((await screen.findAllByText(fullToken)).length).toBeGreaterThan(0);
const reveal = screen.getByRole('dialog', { name: 'API 密钥已创建' });
await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API 密钥已创建' })).toBeNull());
await waitFor(() => expect(screen.getAllByText(fullToken)).toHaveLength(1));
const copyButtons = screen.getAllByRole('button', { name: '复制完整 Token CI sender' });
expect(copyButtons).toHaveLength(1);
await user.click(copyButtons[0]);
expect(screen.queryByRole('dialog', { name: 'CI sender' })).toBeNull();
});
it('requires selected mailboxes for send or messages:read and loads all choices for admins', async () => {
const user = userEvent.setup();
const mailbox = mailboxFixture();
const created = tokenFixture({
token: 'mh_selected.full-secret',
tokenRecoverable: true,
scopes: ['send', 'messages:read'],
mailboxAccess: 'selected',
mailboxIds: [mailbox.id]
});
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
renderPage('admin');
await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('all'));
await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
const editor = await screen.findByRole('dialog');
expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
await user.type(within(editor).getByLabelText('名称'), 'Message reader');
await user.click(within(editor).getByRole('checkbox', { name: 'messages:read' }));
expect(await within(editor).findByText('邮箱访问范围')).not.toBeNull();
await user.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
await user.click(within(editor).getByLabelText('授权邮箱'));
await user.click(await screen.findByText(new RegExp(mailbox.address)));
await user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
await waitFor(() => expect(createToken).toHaveBeenCalledWith({
name: 'Message reader',
scopes: ['send', 'messages:read'],
expiresAt: null,
mailboxAccess: 'selected',
mailboxIds: [mailbox.id]
}));
});
it('allows a send-only token to select an assigned mailbox with send access', async () => {
const browser = userEvent.setup();
const base = mailboxFixture();
const owned = {
...base,
id: 1,
userId: 1,
ownerUserId: 1,
address: 'owned@example.test',
access: { type: 'owner' as const, permissions: { view: true, receive: true, send: true } }
};
const assigned = {
...base,
id: 2,
address: 'assigned@example.test',
access: { type: 'assigned' as const, permissions: { view: true, receive: true, send: false } }
};
const sendOnly = {
...base,
id: 3,
address: 'send-only@example.test',
access: { type: 'assigned' as const, permissions: { view: true, receive: false, send: true } }
};
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, sendOnly] });
const created = tokenFixture({
name: 'Shared sender',
token: 'mh_shared.full-secret',
tokenRecoverable: true,
scopes: ['send'],
mailboxAccess: 'selected',
mailboxIds: [sendOnly.id]
});
const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
renderPage('user');
await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
await browser.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
const editor = await screen.findByRole('dialog');
await browser.type(within(editor).getByLabelText('名称'), 'Shared sender');
await browser.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
await browser.click(within(editor).getByLabelText('授权邮箱'));
expect(await screen.findByText(owned.address)).toBeTruthy();
expect(screen.getByText(new RegExp(assigned.address))).toBeTruthy();
expect(screen.getByText(new RegExp(sendOnly.address))).toBeTruthy();
await browser.click(screen.getByText(new RegExp(sendOnly.address)));
await browser.click(within(editor).getByRole('button', { name: /创建密钥/ }));
await waitFor(() => expect(createToken).toHaveBeenCalledWith({
name: 'Shared sender',
scopes: ['send'],
expiresAt: null,
mailboxAccess: 'selected',
mailboxIds: [sendOnly.id]
}));
});
it('shows selected mailbox access in the detail drawer for a send-only token', async () => {
const browser = userEvent.setup();
const mailbox = mailboxFixture();
const token = tokenFixture({
scopes: ['send'],
mailboxAccess: 'selected',
mailboxIds: [mailbox.id]
});
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
renderPage('user');
await browser.click(await screen.findByText(token.name));
const detail = await screen.findByRole('dialog');
expect(within(detail).getByText('指定邮箱 · 1')).toBeTruthy();
expect(within(detail).getByText(mailbox.address)).toBeTruthy();
});
it('preserves selected mailbox access when editing a mailboxes:read token', async () => {
const browser = userEvent.setup();
const mailbox = mailboxFixture();
const token = tokenFixture({
name: 'Mailbox reader',
scopes: ['mailboxes:read'],
mailboxAccess: 'selected',
mailboxIds: [mailbox.id]
});
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
const updateToken = vi.spyOn(api, 'updateApiToken').mockResolvedValue({ token });
renderPage('user');
await browser.click(await screen.findByRole('button', { name: /编辑.*Mailbox reader/ }));
const editor = await screen.findByRole('dialog');
expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
expect(within(editor).getByText(mailbox.address)).toBeTruthy();
await browser.click(within(editor).getByRole('button', { name: /保\s*存/ }));
await waitFor(() => expect(updateToken).toHaveBeenCalledWith(token.id, {
name: token.name,
scopes: ['mailboxes:read'],
expiresAt: null,
mailboxAccess: 'selected',
mailboxIds: [mailbox.id]
}));
});
it('regenerates an unrecoverable legacy token only after destructive confirmation', async () => {
const user = userEvent.setup();
const legacy = tokenFixture({ tokenRecoverable: false, token: undefined, name: 'Legacy worker' });
const rotated = tokenFixture({ tokenRecoverable: true, token: 'mh_rotated.new-secret', name: 'Legacy worker' });
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [legacy] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
const rotate = vi.spyOn(api, 'rotateApiToken').mockResolvedValue({ token: rotated });
renderPage();
await user.click(await screen.findByRole('button', { name: '重新生成 Legacy worker' }));
expect(screen.getByText('旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。')).not.toBeNull();
const confirmations = screen.getAllByRole('button', { name: '重新生成' });
await user.click(confirmations[confirmations.length - 1]);
await waitFor(() => expect(rotate).toHaveBeenCalledWith(9));
expect(await screen.findByText('API 密钥已重新生成')).not.toBeNull();
expect((await screen.findAllByText('mh_rotated.new-secret')).length).toBeGreaterThan(0);
});
});
function renderPage(role: UserRole = 'admin') {
const context: AppContextValue = {
user: { id: 1, username: 'operator', email: 'operator@example.test', role, status: 'active' },
config,
refreshBootstrap: vi.fn(async () => undefined),
logout: vi.fn(async () => undefined)
};
return render(
);
}
function tokenFixture(overrides: Partial = {}): ApiToken {
return {
id: 9,
name: 'CI sender',
tokenPrefix: 'mh_12345678',
tokenRecoverable: false,
scopes: ['send'],
mailboxAccess: 'owner',
mailboxIds: [],
status: 'active',
createdAt: '2026-07-14T00:00:00.000Z',
...overrides
};
}
function mailboxFixture(): InboundMailbox {
return {
id: 42,
userId: 2,
ownerUserId: 2,
domainId: 5,
domain: 'example.test',
address: 'billing@example.test',
localPart: 'billing',
displayName: 'Billing',
aliases: [],
forwardTo: [],
keepForwarded: true,
quotaMb: 1024,
passwordSet: true,
passwordRecoverable: false,
status: 'active',
messageCount: 1,
unreadCount: 1,
access: { type: 'admin', permissions: { view: true, receive: true, send: true } },
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z'
};
}
const config: RuntimeConfig = {
appBaseUrl: 'https://mail.example.test',
mailHostname: 'mail.example.test',
sendingIp: '192.0.2.10',
defaultSpfMechanisms: '',
dmarcPolicy: 'none',
dmarcRua: '',
registrationRequiresApproval: false,
sendRequiresVerified: true,
engagementTrackingEnabled: true,
listUnsubscribeMailto: '',
listUnsubscribeUrl: '',
listUnsubscribePostEnabled: false,
feedbackIdEnabled: false,
reportAbuseTo: '',
csaComplaintsTo: '',
bounceAddress: '',
bounceEnvelopeEnabled: false
};