mailbox-access.test.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { act, fireEvent, 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('replaces only the selected mailbox-user grants in bulk and reports detailed results', async () => {
  71. const browser = userEvent.setup();
  72. const entries = [
  73. accessEntryFixture(11, 'support-1@example.test'),
  74. accessEntryFixture(12, 'support-2@example.test')
  75. ];
  76. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  77. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: entries });
  78. const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess').mockResolvedValue({
  79. mailboxes: entries,
  80. summary: {
  81. mailboxCount: 2,
  82. userCount: 2,
  83. changedGrantCount: 2,
  84. skippedOwnerCount: 2
  85. }
  86. });
  87. renderMailboxAccessRoute();
  88. const search = await screen.findByRole('searchbox', { name: '搜索邮箱、所有者或邮箱地址' });
  89. await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entries[0].mailbox.address}` }));
  90. await browser.click(screen.getByRole('checkbox', { name: `选择邮箱 · ${entries[1].mailbox.address}` }));
  91. expect(screen.getByText('已选择邮箱 2')).toBeTruthy();
  92. await browser.click(screen.getByRole('button', { name: '批量授权 (2)' }));
  93. const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
  94. expect(within(dialog).getByText(entries[0].mailbox.address)).toBeTruthy();
  95. expect(within(dialog).getByText(entries[1].mailbox.address)).toBeTruthy();
  96. expect(within(dialog).getByRole('radiogroup', { name: '批量操作' }).getAttribute('aria-required')).toBe('true');
  97. expect(within(dialog).getByText(/现有权限替换为下方权限/)).toBeTruthy();
  98. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  99. await browser.click(await screen.findByText('owner · owner@example.test', { selector: '.ant-select-item-option-content' }));
  100. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  101. await browser.click(await screen.findByText('reader · reader@example.test', { selector: '.ant-select-item-option-content' }));
  102. expect(within(dialog).getByText('2 个邮箱 × 2 个用户 = 4 个组合')).toBeTruthy();
  103. expect(within(dialog).getByText('预计跳过邮箱所有者组合 2')).toBeTruthy();
  104. await browser.click(within(dialog).getByRole('checkbox', { name: '收取邮件' }));
  105. const viewPermission = within(dialog).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement;
  106. expect(viewPermission.checked).toBe(true);
  107. expect(viewPermission.disabled).toBe(true);
  108. expect(within(dialog).getByText(/收取邮件或发送邮件会自动包含查看配置权限/)).toBeTruthy();
  109. await browser.click(within(dialog).getByRole('button', { name: '应用权限' }));
  110. await waitFor(() => expect(bulkUpdate).toHaveBeenCalledWith({
  111. mailboxIds: [11, 12],
  112. userIds: [1, 2],
  113. operation: 'upsert',
  114. permissions: { view: true, receive: true, send: false }
  115. }));
  116. expect(await screen.findByText('批量邮箱权限已更新: 已更改 2 · 未变化 0 · 跳过所有者 2')).toBeTruthy();
  117. expect(screen.queryByRole('dialog', { name: '批量配置邮箱权限' })).toBeNull();
  118. expect((screen.getByRole('button', { name: '批量授权' }) as HTMLButtonElement).disabled).toBe(true);
  119. await waitFor(() => expect(document.activeElement).toBe(search));
  120. });
  121. it('offers disabled users only for removal and drops them before switching back to grant mode', async () => {
  122. const browser = userEvent.setup();
  123. const entry = accessEntryFixture(13, 'disabled-removal@example.test');
  124. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  125. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
  126. const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess');
  127. renderMailboxAccessRoute();
  128. await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entry.mailbox.address}` }));
  129. await browser.click(screen.getByRole('button', { name: '批量授权 (1)' }));
  130. const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
  131. const disabledUserLabel = 'disabled · disabled@example.test · 已禁用';
  132. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  133. expect(screen.getByText('pending · pending@example.test', { selector: '.ant-select-item-option-content' })).toBeTruthy();
  134. expect(screen.queryByText(disabledUserLabel, { selector: '.ant-select-item-option-content' })).toBeNull();
  135. await browser.keyboard('{Escape}');
  136. await browser.click(within(dialog).getByText('移除授权'));
  137. expect(within(dialog).getByText(/移除授权时可选择已禁用用户/)).toBeTruthy();
  138. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  139. await browser.click(await screen.findByText(disabledUserLabel, { selector: '.ant-select-item-option-content' }));
  140. await browser.keyboard('{Escape}');
  141. expect((within(dialog).getByRole('button', { name: '移除授权' }) as HTMLButtonElement).disabled).toBe(false);
  142. await browser.click(within(dialog).getByText('设置或替换权限'));
  143. expect((within(dialog).getByRole('button', { name: '应用权限' }) as HTMLButtonElement).disabled).toBe(true);
  144. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  145. expect(screen.queryByText(disabledUserLabel, { selector: '.ant-select-item-option-content' })).toBeNull();
  146. expect(bulkUpdate).not.toHaveBeenCalled();
  147. });
  148. it('previews only five selected mailboxes, clears hidden selections, and prunes removed IDs after reload', async () => {
  149. const browser = userEvent.setup();
  150. const entries = Array.from({ length: 6 }, (_item, index) => (
  151. accessEntryFixture(20 + index, `mailbox-${index + 1}@example.test`)
  152. ));
  153. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  154. const list = vi.spyOn(api, 'adminMailboxAccess')
  155. .mockResolvedValueOnce({ mailboxes: entries })
  156. .mockResolvedValueOnce({ mailboxes: entries.slice(0, 5) });
  157. renderMailboxAccessRoute();
  158. await browser.click(await screen.findByRole('checkbox', { name: '选择全部筛选结果 (6)' }));
  159. expect(screen.getByText('已选择邮箱 6')).toBeTruthy();
  160. await browser.click(screen.getByRole('button', { name: '批量授权 (6)' }));
  161. const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
  162. for (const entry of entries.slice(0, 5)) {
  163. expect(within(dialog).getByText(entry.mailbox.address)).toBeTruthy();
  164. }
  165. expect(within(dialog).queryByText(entries[5].mailbox.address)).toBeNull();
  166. expect(within(dialog).getByText('+1')).toBeTruthy();
  167. await browser.click(within(dialog).getByRole('button', { name: '取消' }));
  168. await waitFor(() => expect(screen.queryByRole('dialog', { name: '批量配置邮箱权限' })).toBeNull());
  169. const search = screen.getByRole('searchbox', { name: '搜索邮箱、所有者或邮箱地址' });
  170. await browser.type(search, 'mailbox-6');
  171. expect(await screen.findByText('当前筛选中 1')).toBeTruthy();
  172. await browser.click(screen.getByRole('button', { name: '清空选择' }));
  173. expect(screen.queryByText('已选择邮箱 6')).toBeNull();
  174. await browser.click(screen.getByRole('checkbox', { name: `选择邮箱 · ${entries[5].mailbox.address}` }));
  175. expect(screen.getByText('已选择邮箱 1')).toBeTruthy();
  176. await browser.click(screen.getByRole('button', { name: /刷新/ }));
  177. await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
  178. await waitFor(() => expect(screen.queryByText('已选择邮箱 1')).toBeNull());
  179. expect((screen.getByRole('button', { name: '批量授权' }) as HTMLButtonElement).disabled).toBe(true);
  180. });
  181. it('requires a second confirmation for bulk removal and preserves the form after failure', async () => {
  182. const browser = userEvent.setup();
  183. const entry = accessEntryFixture(31, 'remove@example.test');
  184. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  185. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
  186. let rejectBulk!: (reason?: unknown) => void;
  187. const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess').mockReturnValue(new Promise((_resolve, reject) => {
  188. rejectBulk = reject;
  189. }));
  190. renderMailboxAccessRoute();
  191. await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entry.mailbox.address}` }));
  192. await browser.click(screen.getByRole('button', { name: '批量授权 (1)' }));
  193. const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
  194. await browser.click(within(dialog).getByText('移除授权'));
  195. await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
  196. await browser.click(await screen.findByText('reader · reader@example.test', { selector: '.ant-select-item-option-content' }));
  197. await waitFor(() => expect(within(dialog).getByText('1 个邮箱 × 1 个用户 = 1 个组合')).toBeTruthy());
  198. await browser.click(within(dialog).getByRole('button', { name: '移除授权' }));
  199. expect(bulkUpdate).not.toHaveBeenCalled();
  200. const confirmationTitle = await screen.findByText(
  201. '确认批量移除授权',
  202. { selector: '.ant-modal-confirm-title' }
  203. );
  204. const confirmation = confirmationTitle.closest('.ant-modal') as HTMLElement;
  205. expect(confirmation).toBeTruthy();
  206. expect(within(confirmation).getByText('1 个邮箱 × 1 个用户 = 1 个组合')).toBeTruthy();
  207. await browser.click(within(confirmation).getByRole('button', { name: '移除授权' }));
  208. await waitFor(() => expect(bulkUpdate).toHaveBeenCalledWith({
  209. mailboxIds: [31],
  210. userIds: [2],
  211. operation: 'remove'
  212. }));
  213. expect((within(dialog).getByRole('combobox', { name: '授权用户' }) as HTMLInputElement).disabled).toBe(true);
  214. expect((within(dialog).getByRole('button', { name: '取消' }) as HTMLButtonElement).disabled).toBe(true);
  215. expect((within(confirmation).getByRole('button', { name: '取消' }) as HTMLButtonElement).disabled).toBe(true);
  216. fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
  217. expect(document.body.contains(dialog)).toBe(true);
  218. await act(async () => {
  219. rejectBulk(new Error('批量授权暂时不可用'));
  220. await Promise.resolve();
  221. });
  222. await waitFor(() => expect(screen.queryByText(
  223. '确认批量移除授权',
  224. { selector: '.ant-modal-confirm-title' }
  225. )).toBeNull());
  226. const retainedDialog = screen.getByRole('dialog', { name: '批量配置邮箱权限' });
  227. expect(within(retainedDialog).getByText('批量授权暂时不可用')).toBeTruthy();
  228. expect((within(retainedDialog).getByRole('radio', { name: '移除授权' }) as HTMLInputElement).checked).toBe(true);
  229. await browser.click(within(retainedDialog).getByRole('combobox', { name: '授权用户' }));
  230. const retainedUserOption = await screen.findByText(
  231. 'reader · reader@example.test',
  232. { selector: '.ant-select-item-option-content' }
  233. );
  234. expect(retainedUserOption.closest('.ant-select-item-option')?.classList.contains(
  235. 'ant-select-item-option-selected'
  236. )).toBe(true);
  237. expect(within(retainedDialog).getByText('已选择邮箱 1')).toBeTruthy();
  238. });
  239. it('blocks a selection larger than the mailbox batch limit', async () => {
  240. const browser = userEvent.setup();
  241. vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 768px)', query));
  242. const entries = Array.from({ length: 501 }, (_item, index) => (
  243. accessEntryFixture(1000 + index, `limit-${index + 1}@example.test`)
  244. ));
  245. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  246. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: entries });
  247. const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess');
  248. renderMailboxAccessRoute();
  249. await browser.click(await screen.findByRole('checkbox', { name: '选择全部筛选结果 (501)' }));
  250. expect(await screen.findByText('单次最多选择邮箱数: 500')).toBeTruthy();
  251. expect((screen.getByRole('button', { name: '批量授权 (501)' }) as HTMLButtonElement).disabled).toBe(true);
  252. expect(bulkUpdate).not.toHaveBeenCalled();
  253. });
  254. it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
  255. const browser = userEvent.setup();
  256. const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  257. const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
  258. const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
  259. const list = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, viewOnly] });
  260. const router = createMemoryRouter([
  261. { path: '/account', element: <Account /> },
  262. { path: '/inbox', element: <LocationProbe /> }
  263. ], { initialEntries: ['/account'] });
  264. renderWithRouter(router, userContext);
  265. expect(await screen.findByRole('heading', { name: '账号与邮箱权限' })).toBeTruthy();
  266. expect(list).toHaveBeenCalledWith('effective');
  267. expect(screen.getAllByText('管理员分配').length).toBeGreaterThan(0);
  268. const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
  269. expect(viewOnlyCard).toBeTruthy();
  270. expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
  271. expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  272. expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
  273. const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
  274. expect(assignedCard).toBeTruthy();
  275. await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
  276. expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
  277. });
  278. it('exchanges an assigned mailbox for a hidden Webmail POST without exposing the ticket', async () => {
  279. const browser = userEvent.setup();
  280. const owner = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  281. const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
  282. const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
  283. const inactive = { ...mailboxFixture(4, 'inactive@example.test', 'assigned', { view: true, receive: true, send: false }, 1), status: 'disabled' };
  284. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owner, assigned, viewOnly, inactive] });
  285. const webmailLogin: WebmailLogin = {
  286. action: 'https://mail.us.ss5.xyz/',
  287. ticket: 'mht_secret-ticket',
  288. expiresAt: '2026-07-18T01:00:00.000Z'
  289. };
  290. let resolveLogin!: (value: { webmailLogin: WebmailLogin }) => void;
  291. const login = vi.spyOn(api, 'createWebmailLogin').mockReturnValue(new Promise((resolve) => {
  292. resolveLogin = resolve;
  293. }));
  294. let submittedForm: HTMLFormElement | null = null;
  295. const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(function (this: HTMLFormElement) {
  296. submittedForm = this.cloneNode(true) as HTMLFormElement;
  297. });
  298. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  299. renderWithRouter(router, userContext);
  300. const assignedCard = (await screen.findByText(assigned.address)).closest('.ant-card') as HTMLElement;
  301. const ownerCard = screen.getByText(owner.address).closest('.ant-card') as HTMLElement;
  302. const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
  303. const inactiveCard = screen.getByText(inactive.address).closest('.ant-card') as HTMLElement;
  304. const assignedButton = within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ });
  305. const ownerButton = within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ });
  306. expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  307. const inactiveButton = within(inactiveCard).getByRole('button', { name: /一键登录 Webmail/ }) as HTMLButtonElement;
  308. expect(inactiveButton.disabled).toBe(true);
  309. expect(inactiveButton.title).toContain('邮箱已停用');
  310. const setItem = vi.spyOn(Storage.prototype, 'setItem');
  311. await browser.click(assignedButton);
  312. await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
  313. expect(assignedButton.classList.contains('ant-btn-loading')).toBe(true);
  314. expect(ownerButton.classList.contains('ant-btn-loading')).toBe(false);
  315. await act(async () => resolveLogin({ webmailLogin }));
  316. await waitFor(() => expect(requestSubmit).toHaveBeenCalledTimes(1));
  317. expect(submittedForm).not.toBeNull();
  318. expect(submittedForm!.getAttribute('method')).toBe('POST');
  319. expect(submittedForm!.action).toBe(webmailLogin.action);
  320. expect(submittedForm!.target).toBe('_self');
  321. expect(Object.fromEntries(new FormData(submittedForm!))).toEqual({
  322. mailhub_ticket: webmailLogin.ticket,
  323. _task: 'mail',
  324. _mbox: 'INBOX'
  325. });
  326. expect(document.querySelector(`form[action="${webmailLogin.action}"]`)).toBeNull();
  327. expect(`${router.state.location.pathname}${router.state.location.search}`).toBe('/account');
  328. expect(window.location.href).not.toContain(webmailLogin.ticket);
  329. expect(setItem.mock.calls.flat().join(' ')).not.toContain(webmailLogin.ticket);
  330. });
  331. it('shows a Webmail login error without navigating away', async () => {
  332. const browser = userEvent.setup();
  333. const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  334. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  335. vi.spyOn(api, 'createWebmailLogin').mockRejectedValue(new Error('Webmail 暂时不可用'));
  336. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  337. renderWithRouter(router, userContext);
  338. await browser.click(await screen.findByRole('button', { name: /一键登录 Webmail/ }));
  339. expect(await screen.findByText('Webmail 暂时不可用')).toBeTruthy();
  340. expect(router.state.location.pathname).toBe('/account');
  341. });
  342. it('hides the Webmail shortcut until the server explicitly enables SSO', async () => {
  343. const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  344. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  345. const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
  346. renderWithRouter(router, {
  347. ...userContext,
  348. config: { ...config, webmailSsoEnabled: false }
  349. });
  350. expect(await screen.findByText(mailbox.address)).toBeTruthy();
  351. expect(screen.queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
  352. });
  353. });
  354. function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
  355. return render(
  356. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  357. <AntApp>
  358. <I18nProvider>
  359. <AppContext.Provider value={context}>
  360. <RouterProvider router={router} />
  361. </AppContext.Provider>
  362. </I18nProvider>
  363. </AntApp>
  364. </ConfigProvider>
  365. );
  366. }
  367. function LocationProbe() {
  368. const location = useLocation();
  369. return <div data-testid="location">{location.pathname}{location.search}</div>;
  370. }
  371. function accessEntry(): AdminMailboxAccessEntry {
  372. const mailbox = mailboxFixture(10, 'support@example.test', 'owner', { view: true, receive: true, send: true }, 1);
  373. return {
  374. mailbox,
  375. owner: adminUsers[0],
  376. grants: [{
  377. user: adminUsers[1],
  378. permissions: { view: true, receive: true, send: false },
  379. createdAt: '2026-07-18T00:00:00.000Z',
  380. updatedAt: '2026-07-18T00:00:00.000Z'
  381. }]
  382. };
  383. }
  384. function accessEntryFixture(id: number, address: string): AdminMailboxAccessEntry {
  385. const mailbox = mailboxFixture(
  386. id,
  387. address,
  388. 'owner',
  389. { view: true, receive: true, send: true },
  390. adminUsers[0].id
  391. );
  392. return {
  393. mailbox,
  394. owner: adminUsers[0],
  395. grants: [{
  396. user: adminUsers[2],
  397. permissions: { view: true, receive: false, send: true },
  398. createdAt: '2026-07-18T00:00:00.000Z',
  399. updatedAt: '2026-07-18T00:00:00.000Z'
  400. }]
  401. };
  402. }
  403. function renderMailboxAccessRoute() {
  404. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  405. initialEntries: ['/admin/mailbox-access']
  406. });
  407. renderWithRouter(router, adminContext);
  408. return router;
  409. }
  410. function mediaQueryList(matches: boolean, media: string): MediaQueryList {
  411. return {
  412. matches,
  413. media,
  414. onchange: null,
  415. addListener: () => undefined,
  416. removeListener: () => undefined,
  417. addEventListener: () => undefined,
  418. removeEventListener: () => undefined,
  419. dispatchEvent: () => false
  420. };
  421. }
  422. function mailboxFixture(
  423. id: number,
  424. address: string,
  425. type: MailboxAccessType,
  426. permissions: MailboxPermissions,
  427. ownerUserId: number
  428. ): InboundMailbox {
  429. const [, domain] = address.split('@');
  430. return {
  431. id,
  432. userId: ownerUserId,
  433. ownerUserId,
  434. domainId: id,
  435. domain,
  436. address,
  437. localPart: address.split('@')[0],
  438. displayName: '',
  439. aliases: [],
  440. forwardTo: [],
  441. keepForwarded: true,
  442. quotaMb: 1024,
  443. passwordSet: true,
  444. passwordRecoverable: false,
  445. status: 'active',
  446. messageCount: permissions.receive ? 3 : null,
  447. unreadCount: permissions.receive ? 1 : null,
  448. lastMessageAt: permissions.receive ? '2026-07-18T00:00:00.000Z' : null,
  449. access: { type, permissions },
  450. createdAt: '2026-07-18T00:00:00.000Z',
  451. updatedAt: '2026-07-18T00:00:00.000Z'
  452. };
  453. }
  454. const adminUsers: AdminUser[] = [
  455. { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
  456. { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() },
  457. { id: 3, username: 'sender', email: 'sender@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() },
  458. { id: 4, username: 'disabled', email: 'disabled@example.test', role: 'user', status: 'disabled', resourceCounts: resourceCounts() },
  459. { id: 5, username: 'pending', email: 'pending@example.test', role: 'user', status: 'pending_email', resourceCounts: resourceCounts() }
  460. ];
  461. function contextFor(user: User): AppContextValue {
  462. return {
  463. user,
  464. config,
  465. refreshBootstrap: vi.fn(async () => undefined),
  466. logout: vi.fn(async () => undefined)
  467. };
  468. }
  469. function resourceCounts() {
  470. return { domains: 0, dnsCredentials: 0, apiTokens: 0, inboundMailboxes: 0, inboundMessages: 0, sendEvents: 0, smtpCredential: 0 };
  471. }
  472. const config: RuntimeConfig = {
  473. appBaseUrl: 'https://mail.example.test',
  474. webmailSsoEnabled: true,
  475. mailHostname: 'mail.example.test',
  476. sendingIp: '192.0.2.10',
  477. defaultSpfMechanisms: '',
  478. dmarcPolicy: 'none',
  479. dmarcRua: '',
  480. registrationRequiresApproval: false,
  481. sendRequiresVerified: true,
  482. engagementTrackingEnabled: true,
  483. listUnsubscribeMailto: '',
  484. listUnsubscribeUrl: '',
  485. listUnsubscribePostEnabled: false,
  486. feedbackIdEnabled: false,
  487. reportAbuseTo: '',
  488. csaComplaintsTo: '',
  489. bounceAddress: '',
  490. bounceEnvelopeEnabled: false
  491. };
  492. const adminContext: AppContextValue = contextFor(adminUsers[0]);
  493. const userContext: AppContextValue = contextFor({ id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active' });