| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- 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(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={context}>
- <MemoryRouter initialEntries={['/integrations/api-keys']}>
- <ApiTokens />
- </MemoryRouter>
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- }
- function tokenFixture(overrides: Partial<ApiToken> = {}): 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
- };
|