import { App as AntApp, ConfigProvider } from 'antd';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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(/新 Token 会加密保存并支持完整复制/);
await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
const editor = await screen.findByRole('dialog');
await user.type(within(editor).getByLabelText('名称'), 'CI sender');
await user.click(within(editor).getByRole('button', { name: /创建 Token/ }));
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 Token 已创建' });
await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).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('progressively requires selected mailboxes for 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(true));
await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
const editor = await screen.findByRole('dialog');
expect(within(editor).queryByText('邮件读取范围')).toBeNull();
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: /创建 Token/ }));
await waitFor(() => expect(createToken).toHaveBeenCalledWith({
name: 'Message reader',
scopes: ['send', 'messages: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 Token 已重新生成')).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,
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,
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: '',
sendRequiresVerified: true,
engagementTrackingEnabled: true,
listUnsubscribeMailto: '',
listUnsubscribeUrl: '',
listUnsubscribePostEnabled: false,
feedbackIdEnabled: false,
reportAbuseTo: '',
csaComplaintsTo: '',
bounceAddress: '',
bounceEnvelopeEnabled: false
};