mailbox-access.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { act, render, screen, waitFor, within } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
  5. import { afterEach, describe, expect, it, vi } from 'vitest';
  6. import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
  7. import { I18nProvider } from '../../src/frontend/i18n/react';
  8. import { api } from '../../src/frontend/services/api';
  9. import { mailhubTheme } from '../../src/frontend/theme';
  10. import type {
  11. AdminMailboxAccessEntry,
  12. AdminUser,
  13. InboundMailbox,
  14. MailboxAccessType,
  15. MailboxPermissions,
  16. RuntimeConfig,
  17. User,
  18. WebmailLogin
  19. } from '../../src/frontend/types';
  20. import Account from '../../src/pages/Account';
  21. import AdminPage from '../../src/pages/Admin';
  22. describe('Mailbox access UI', () => {
  23. afterEach(() => vi.restoreAllMocks());
  24. it('keeps the admin mailbox editor in the URL and saves normalized grants', async () => {
  25. const browser = userEvent.setup();
  26. const entry = accessEntry();
  27. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  28. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
  29. const save = vi.spyOn(api, 'saveAdminMailboxAccess').mockImplementation(async (_id, grants) => ({
  30. mailbox: {
  31. ...entry,
  32. grants: grants.map((grant) => ({
  33. user: adminUsers.find((user) => user.id === grant.userId)!,
  34. permissions: grant,
  35. createdAt: '2026-07-18T00:00:00.000Z',
  36. updatedAt: '2026-07-18T00:00:00.000Z'
  37. }))
  38. }
  39. }));
  40. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  41. initialEntries: ['/admin/mailbox-access']
  42. });
  43. renderWithRouter(router, adminContext);
  44. const configure = await screen.findByRole('button', { name: /配置权限/ });
  45. expect(configure.style.minHeight).toBe('44px');
  46. await browser.click(configure);
  47. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('10'));
  48. const drawer = await screen.findByRole('dialog');
  49. expect(within(drawer).getByText(/owner@example\.test/)).toBeTruthy();
  50. expect(within(drawer).getByText('所有者权限')).toBeTruthy();
  51. await browser.click(within(drawer).getByRole('checkbox', { name: '收取邮件' }));
  52. await browser.click(within(drawer).getByRole('checkbox', { name: '查看配置' }));
  53. await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
  54. expect(await screen.findByText('每个用户至少选择一项权限,或移除该授权行。')).toBeTruthy();
  55. expect(save).not.toHaveBeenCalled();
  56. await browser.click(within(drawer).getByRole('checkbox', { name: '发送邮件' }));
  57. expect((within(drawer).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement).checked).toBe(true);
  58. await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
  59. await waitFor(() => expect(save).toHaveBeenCalledWith(10, [{
  60. userId: 2,
  61. view: true,
  62. receive: false,
  63. send: true
  64. }]));
  65. expect(await screen.findByText('邮箱权限已保存')).toBeTruthy();
  66. await browser.click(within(drawer).getByRole('button', { name: 'Close' }));
  67. await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('mailboxId')).toBe(false));
  68. await waitFor(() => expect(document.activeElement).toBe(configure));
  69. });
  70. it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
  71. const browser = userEvent.setup();
  72. const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  73. const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
  74. const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
  75. const list = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, viewOnly] });
  76. const router = createMemoryRouter([
  77. { path: '/account', element: <Account /> },
  78. { path: '/inbox', element: <LocationProbe /> }
  79. ], { initialEntries: ['/account'] });
  80. renderWithRouter(router, userContext);
  81. expect(await screen.findByRole('heading', { name: '账号与邮箱权限' })).toBeTruthy();
  82. expect(list).toHaveBeenCalledWith('effective');
  83. expect(screen.getAllByText('管理员分配').length).toBeGreaterThan(0);
  84. const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
  85. expect(viewOnlyCard).toBeTruthy();
  86. expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
  87. expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  88. expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
  89. const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
  90. expect(assignedCard).toBeTruthy();
  91. await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
  92. expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
  93. });
  94. it('exchanges an assigned mailbox for a hidden Webmail POST without exposing the ticket', async () => {
  95. const browser = userEvent.setup();
  96. const owner = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  97. const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
  98. const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
  99. const inactive = { ...mailboxFixture(4, 'inactive@example.test', 'assigned', { view: true, receive: true, send: false }, 1), status: 'disabled' };
  100. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owner, assigned, viewOnly, inactive] });
  101. const webmailLogin: WebmailLogin = {
  102. action: 'https://mail.us.ss5.xyz/',
  103. ticket: 'mht_secret-ticket',
  104. expiresAt: '2026-07-18T01:00:00.000Z'
  105. };
  106. let resolveLogin!: (value: { webmailLogin: WebmailLogin }) => void;
  107. const login = vi.spyOn(api, 'createWebmailLogin').mockReturnValue(new Promise((resolve) => {
  108. resolveLogin = resolve;
  109. }));
  110. let submittedForm: HTMLFormElement | null = null;
  111. const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(function (this: HTMLFormElement) {
  112. submittedForm = this.cloneNode(true) as HTMLFormElement;
  113. });
  114. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  115. renderWithRouter(router, userContext);
  116. const assignedCard = (await screen.findByText(assigned.address)).closest('.ant-card') as HTMLElement;
  117. const ownerCard = screen.getByText(owner.address).closest('.ant-card') as HTMLElement;
  118. const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
  119. const inactiveCard = screen.getByText(inactive.address).closest('.ant-card') as HTMLElement;
  120. const assignedButton = within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ });
  121. const ownerButton = within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ });
  122. expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  123. const inactiveButton = within(inactiveCard).getByRole('button', { name: /一键登录 Webmail/ }) as HTMLButtonElement;
  124. expect(inactiveButton.disabled).toBe(true);
  125. expect(inactiveButton.title).toContain('邮箱已停用');
  126. const setItem = vi.spyOn(Storage.prototype, 'setItem');
  127. await browser.click(assignedButton);
  128. await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
  129. expect(assignedButton.classList.contains('ant-btn-loading')).toBe(true);
  130. expect(ownerButton.classList.contains('ant-btn-loading')).toBe(false);
  131. await act(async () => resolveLogin({ webmailLogin }));
  132. await waitFor(() => expect(requestSubmit).toHaveBeenCalledTimes(1));
  133. expect(submittedForm).not.toBeNull();
  134. expect(submittedForm!.getAttribute('method')).toBe('POST');
  135. expect(submittedForm!.action).toBe(webmailLogin.action);
  136. expect(submittedForm!.target).toBe('_self');
  137. expect(Object.fromEntries(new FormData(submittedForm!))).toEqual({
  138. mailhub_ticket: webmailLogin.ticket,
  139. _task: 'mail',
  140. _mbox: 'INBOX'
  141. });
  142. expect(document.querySelector(`form[action="${webmailLogin.action}"]`)).toBeNull();
  143. expect(`${router.state.location.pathname}${router.state.location.search}`).toBe('/account');
  144. expect(window.location.href).not.toContain(webmailLogin.ticket);
  145. expect(setItem.mock.calls.flat().join(' ')).not.toContain(webmailLogin.ticket);
  146. });
  147. it('shows a Webmail login error without navigating away', async () => {
  148. const browser = userEvent.setup();
  149. const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  150. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  151. vi.spyOn(api, 'createWebmailLogin').mockRejectedValue(new Error('Webmail 暂时不可用'));
  152. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  153. renderWithRouter(router, userContext);
  154. await browser.click(await screen.findByRole('button', { name: /一键登录 Webmail/ }));
  155. expect(await screen.findByText('Webmail 暂时不可用')).toBeTruthy();
  156. expect(router.state.location.pathname).toBe('/account');
  157. });
  158. it('hides the Webmail shortcut until the server explicitly enables SSO', async () => {
  159. const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  160. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  161. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  162. renderWithRouter(router, {
  163. ...userContext,
  164. config: { ...config, webmailSsoEnabled: false }
  165. });
  166. expect(await screen.findByText(mailbox.address)).toBeTruthy();
  167. expect(screen.queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  168. });
  169. });
  170. function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
  171. return render(
  172. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  173. <AntApp>
  174. <I18nProvider>
  175. <AppContext.Provider value={context}>
  176. <RouterProvider router={router} />
  177. </AppContext.Provider>
  178. </I18nProvider>
  179. </AntApp>
  180. </ConfigProvider>
  181. );
  182. }
  183. function LocationProbe() {
  184. const location = useLocation();
  185. return <div data-testid="location">{location.pathname}{location.search}</div>;
  186. }
  187. function accessEntry(): AdminMailboxAccessEntry {
  188. const mailbox = mailboxFixture(10, 'support@example.test', 'owner', { view: true, receive: true, send: true }, 1);
  189. return {
  190. mailbox,
  191. owner: adminUsers[0],
  192. grants: [{
  193. user: adminUsers[1],
  194. permissions: { view: true, receive: true, send: false },
  195. createdAt: '2026-07-18T00:00:00.000Z',
  196. updatedAt: '2026-07-18T00:00:00.000Z'
  197. }]
  198. };
  199. }
  200. function mailboxFixture(
  201. id: number,
  202. address: string,
  203. type: MailboxAccessType,
  204. permissions: MailboxPermissions,
  205. ownerUserId: number
  206. ): InboundMailbox {
  207. const [, domain] = address.split('@');
  208. return {
  209. id,
  210. userId: ownerUserId,
  211. ownerUserId,
  212. domainId: id,
  213. domain,
  214. address,
  215. localPart: address.split('@')[0],
  216. displayName: '',
  217. aliases: [],
  218. forwardTo: [],
  219. keepForwarded: true,
  220. quotaMb: 1024,
  221. passwordSet: true,
  222. passwordRecoverable: false,
  223. status: 'active',
  224. messageCount: permissions.receive ? 3 : null,
  225. unreadCount: permissions.receive ? 1 : null,
  226. lastMessageAt: permissions.receive ? '2026-07-18T00:00:00.000Z' : null,
  227. access: { type, permissions },
  228. createdAt: '2026-07-18T00:00:00.000Z',
  229. updatedAt: '2026-07-18T00:00:00.000Z'
  230. };
  231. }
  232. const adminUsers: AdminUser[] = [
  233. { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
  234. { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() }
  235. ];
  236. function contextFor(user: User): AppContextValue {
  237. return {
  238. user,
  239. config,
  240. refreshBootstrap: vi.fn(async () => undefined),
  241. logout: vi.fn(async () => undefined)
  242. };
  243. }
  244. function resourceCounts() {
  245. return { domains: 0, dnsCredentials: 0, apiTokens: 0, inboundMailboxes: 0, inboundMessages: 0, sendEvents: 0, smtpCredential: 0 };
  246. }
  247. const config: RuntimeConfig = {
  248. appBaseUrl: 'https://mail.example.test',
  249. webmailSsoEnabled: true,
  250. mailHostname: 'mail.example.test',
  251. sendingIp: '192.0.2.10',
  252. defaultSpfMechanisms: '',
  253. dmarcPolicy: 'none',
  254. dmarcRua: '',
  255. registrationRequiresApproval: false,
  256. sendRequiresVerified: true,
  257. engagementTrackingEnabled: true,
  258. listUnsubscribeMailto: '',
  259. listUnsubscribeUrl: '',
  260. listUnsubscribePostEnabled: false,
  261. feedbackIdEnabled: false,
  262. reportAbuseTo: '',
  263. csaComplaintsTo: '',
  264. bounceAddress: '',
  265. bounceEnvelopeEnabled: false
  266. };
  267. const adminContext: AppContextValue = contextFor(adminUsers[0]);
  268. const userContext: AppContextValue = contextFor({ id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active' });