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: }], { 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: }, { path: '/inbox', element: } ], { 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: }], { 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: }], { 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: }], { 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, context: AppContextValue) { return render( ); } function LocationProbe() { const location = useLocation(); return
{location.pathname}{location.search}
; } 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' });