| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import { App as AntApp, ConfigProvider } from 'antd';
- import { act, render, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
- import { afterEach, 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 {
- AdminMailboxAccessEntry,
- AdminUser,
- InboundMailbox,
- MailboxAccessType,
- MailboxPermissions,
- RuntimeConfig,
- User,
- WebmailLogin
- } from '../../src/frontend/types';
- import Account from '../../src/pages/Account';
- import AdminPage from '../../src/pages/Admin';
- describe('Mailbox access UI', () => {
- afterEach(() => vi.restoreAllMocks());
- it('keeps the admin mailbox editor in the URL and saves normalized grants', async () => {
- const browser = userEvent.setup();
- const entry = accessEntry();
- vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
- vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
- const save = vi.spyOn(api, 'saveAdminMailboxAccess').mockImplementation(async (_id, grants) => ({
- mailbox: {
- ...entry,
- grants: grants.map((grant) => ({
- user: adminUsers.find((user) => user.id === grant.userId)!,
- permissions: grant,
- createdAt: '2026-07-18T00:00:00.000Z',
- updatedAt: '2026-07-18T00:00:00.000Z'
- }))
- }
- }));
- const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
- initialEntries: ['/admin/mailbox-access']
- });
- renderWithRouter(router, adminContext);
- const configure = await screen.findByRole('button', { name: /配置权限/ });
- expect(configure.style.minHeight).toBe('44px');
- await browser.click(configure);
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('10'));
- const drawer = await screen.findByRole('dialog');
- expect(within(drawer).getByText(/owner@example\.test/)).toBeTruthy();
- expect(within(drawer).getByText('所有者权限')).toBeTruthy();
- await browser.click(within(drawer).getByRole('checkbox', { name: '收取邮件' }));
- await browser.click(within(drawer).getByRole('checkbox', { name: '查看配置' }));
- await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
- expect(await screen.findByText('每个用户至少选择一项权限,或移除该授权行。')).toBeTruthy();
- expect(save).not.toHaveBeenCalled();
- await browser.click(within(drawer).getByRole('checkbox', { name: '发送邮件' }));
- expect((within(drawer).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement).checked).toBe(true);
- await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
- await waitFor(() => expect(save).toHaveBeenCalledWith(10, [{
- userId: 2,
- view: true,
- receive: false,
- send: true
- }]));
- expect(await screen.findByText('邮箱权限已保存')).toBeTruthy();
- await browser.click(within(drawer).getByRole('button', { name: 'Close' }));
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('mailboxId')).toBe(false));
- await waitFor(() => expect(document.activeElement).toBe(configure));
- });
- it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
- const browser = userEvent.setup();
- const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
- const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
- const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
- const list = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, viewOnly] });
- const router = createMemoryRouter([
- { path: '/account', element: <Account /> },
- { path: '/inbox', element: <LocationProbe /> }
- ], { initialEntries: ['/account'] });
- renderWithRouter(router, userContext);
- expect(await screen.findByRole('heading', { name: '账号与邮箱权限' })).toBeTruthy();
- expect(list).toHaveBeenCalledWith('effective');
- expect(screen.getAllByText('管理员分配').length).toBeGreaterThan(0);
- const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
- expect(viewOnlyCard).toBeTruthy();
- expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
- expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
- expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
- const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
- expect(assignedCard).toBeTruthy();
- await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
- expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
- });
- it('exchanges an assigned mailbox for a hidden Webmail POST without exposing the ticket', async () => {
- const browser = userEvent.setup();
- const owner = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
- const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
- const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
- const inactive = { ...mailboxFixture(4, 'inactive@example.test', 'assigned', { view: true, receive: true, send: false }, 1), status: 'disabled' };
- vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owner, assigned, viewOnly, inactive] });
- const webmailLogin: WebmailLogin = {
- action: 'https://mail.us.ss5.xyz/',
- ticket: 'mht_secret-ticket',
- expiresAt: '2026-07-18T01:00:00.000Z'
- };
- let resolveLogin!: (value: { webmailLogin: WebmailLogin }) => void;
- const login = vi.spyOn(api, 'createWebmailLogin').mockReturnValue(new Promise((resolve) => {
- resolveLogin = resolve;
- }));
- let submittedForm: HTMLFormElement | null = null;
- const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(function (this: HTMLFormElement) {
- submittedForm = this.cloneNode(true) as HTMLFormElement;
- });
- const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
- renderWithRouter(router, userContext);
- const assignedCard = (await screen.findByText(assigned.address)).closest('.ant-card') as HTMLElement;
- const ownerCard = screen.getByText(owner.address).closest('.ant-card') as HTMLElement;
- const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
- const inactiveCard = screen.getByText(inactive.address).closest('.ant-card') as HTMLElement;
- const assignedButton = within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ });
- const ownerButton = within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ });
- expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
- const inactiveButton = within(inactiveCard).getByRole('button', { name: /一键登录 Webmail/ }) as HTMLButtonElement;
- expect(inactiveButton.disabled).toBe(true);
- expect(inactiveButton.title).toContain('邮箱已停用');
- const setItem = vi.spyOn(Storage.prototype, 'setItem');
- await browser.click(assignedButton);
- await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
- expect(assignedButton.classList.contains('ant-btn-loading')).toBe(true);
- expect(ownerButton.classList.contains('ant-btn-loading')).toBe(false);
- await act(async () => resolveLogin({ webmailLogin }));
- await waitFor(() => expect(requestSubmit).toHaveBeenCalledTimes(1));
- expect(submittedForm).not.toBeNull();
- expect(submittedForm!.getAttribute('method')).toBe('POST');
- expect(submittedForm!.action).toBe(webmailLogin.action);
- expect(submittedForm!.target).toBe('_self');
- expect(Object.fromEntries(new FormData(submittedForm!))).toEqual({
- mailhub_ticket: webmailLogin.ticket,
- _task: 'mail',
- _mbox: 'INBOX'
- });
- expect(document.querySelector(`form[action="${webmailLogin.action}"]`)).toBeNull();
- expect(`${router.state.location.pathname}${router.state.location.search}`).toBe('/account');
- expect(window.location.href).not.toContain(webmailLogin.ticket);
- expect(setItem.mock.calls.flat().join(' ')).not.toContain(webmailLogin.ticket);
- });
- it('shows a Webmail login error without navigating away', async () => {
- const browser = userEvent.setup();
- const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
- vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
- vi.spyOn(api, 'createWebmailLogin').mockRejectedValue(new Error('Webmail 暂时不可用'));
- const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
- renderWithRouter(router, userContext);
- await browser.click(await screen.findByRole('button', { name: /一键登录 Webmail/ }));
- expect(await screen.findByText('Webmail 暂时不可用')).toBeTruthy();
- expect(router.state.location.pathname).toBe('/account');
- });
- it('hides the Webmail shortcut until the server explicitly enables SSO', async () => {
- const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
- vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
- const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
- renderWithRouter(router, {
- ...userContext,
- config: { ...config, webmailSsoEnabled: false }
- });
- expect(await screen.findByText(mailbox.address)).toBeTruthy();
- expect(screen.queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
- });
- });
- function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
- return render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={context}>
- <RouterProvider router={router} />
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- }
- function LocationProbe() {
- const location = useLocation();
- return <div data-testid="location">{location.pathname}{location.search}</div>;
- }
- function accessEntry(): AdminMailboxAccessEntry {
- const mailbox = mailboxFixture(10, 'support@example.test', 'owner', { view: true, receive: true, send: true }, 1);
- return {
- mailbox,
- owner: adminUsers[0],
- grants: [{
- user: adminUsers[1],
- permissions: { view: true, receive: true, send: false },
- createdAt: '2026-07-18T00:00:00.000Z',
- updatedAt: '2026-07-18T00:00:00.000Z'
- }]
- };
- }
- function mailboxFixture(
- id: number,
- address: string,
- type: MailboxAccessType,
- permissions: MailboxPermissions,
- ownerUserId: number
- ): InboundMailbox {
- const [, domain] = address.split('@');
- return {
- id,
- userId: ownerUserId,
- ownerUserId,
- domainId: id,
- domain,
- address,
- localPart: address.split('@')[0],
- displayName: '',
- aliases: [],
- forwardTo: [],
- keepForwarded: true,
- quotaMb: 1024,
- passwordSet: true,
- passwordRecoverable: false,
- status: 'active',
- messageCount: permissions.receive ? 3 : null,
- unreadCount: permissions.receive ? 1 : null,
- lastMessageAt: permissions.receive ? '2026-07-18T00:00:00.000Z' : null,
- access: { type, permissions },
- createdAt: '2026-07-18T00:00:00.000Z',
- updatedAt: '2026-07-18T00:00:00.000Z'
- };
- }
- const adminUsers: AdminUser[] = [
- { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
- { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() }
- ];
- function contextFor(user: User): AppContextValue {
- return {
- user,
- config,
- refreshBootstrap: vi.fn(async () => undefined),
- logout: vi.fn(async () => undefined)
- };
- }
- function resourceCounts() {
- return { domains: 0, dnsCredentials: 0, apiTokens: 0, inboundMailboxes: 0, inboundMessages: 0, sendEvents: 0, smtpCredential: 0 };
- }
- const config: RuntimeConfig = {
- appBaseUrl: 'https://mail.example.test',
- webmailSsoEnabled: true,
- 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
- };
- const adminContext: AppContextValue = contextFor(adminUsers[0]);
- const userContext: AppContextValue = contextFor({ id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active' });
|