mailbox-access.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { 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. } from '../../src/frontend/types';
  19. import Account from '../../src/pages/Account';
  20. import AdminPage from '../../src/pages/Admin';
  21. describe('Mailbox access UI', () => {
  22. afterEach(() => vi.restoreAllMocks());
  23. it('keeps the admin mailbox editor in the URL and saves normalized grants', async () => {
  24. const browser = userEvent.setup();
  25. const entry = accessEntry();
  26. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  27. vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
  28. const save = vi.spyOn(api, 'saveAdminMailboxAccess').mockImplementation(async (_id, grants) => ({
  29. mailbox: {
  30. ...entry,
  31. grants: grants.map((grant) => ({
  32. user: adminUsers.find((user) => user.id === grant.userId)!,
  33. permissions: grant,
  34. createdAt: '2026-07-18T00:00:00.000Z',
  35. updatedAt: '2026-07-18T00:00:00.000Z'
  36. }))
  37. }
  38. }));
  39. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  40. initialEntries: ['/admin/mailbox-access']
  41. });
  42. renderWithRouter(router, adminContext);
  43. const configure = await screen.findByRole('button', { name: /配置权限/ });
  44. expect(configure.style.minHeight).toBe('44px');
  45. await browser.click(configure);
  46. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('10'));
  47. const drawer = await screen.findByRole('dialog');
  48. expect(within(drawer).getByText(/owner@example\.test/)).toBeTruthy();
  49. expect(within(drawer).getByText('所有者权限')).toBeTruthy();
  50. await browser.click(within(drawer).getByRole('checkbox', { name: '收取邮件' }));
  51. await browser.click(within(drawer).getByRole('checkbox', { name: '查看配置' }));
  52. await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
  53. expect(await screen.findByText('每个用户至少选择一项权限,或移除该授权行。')).toBeTruthy();
  54. expect(save).not.toHaveBeenCalled();
  55. await browser.click(within(drawer).getByRole('checkbox', { name: '发送邮件' }));
  56. expect((within(drawer).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement).checked).toBe(true);
  57. await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
  58. await waitFor(() => expect(save).toHaveBeenCalledWith(10, [{
  59. userId: 2,
  60. view: true,
  61. receive: false,
  62. send: true
  63. }]));
  64. expect(await screen.findByText('邮箱权限已保存')).toBeTruthy();
  65. await browser.click(within(drawer).getByRole('button', { name: 'Close' }));
  66. await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('mailboxId')).toBe(false));
  67. await waitFor(() => expect(document.activeElement).toBe(configure));
  68. });
  69. it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
  70. const browser = userEvent.setup();
  71. const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
  72. const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
  73. const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
  74. const list = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, viewOnly] });
  75. const router = createMemoryRouter([
  76. { path: '/account', element: <Account /> },
  77. { path: '/inbox', element: <LocationProbe /> }
  78. ], { initialEntries: ['/account'] });
  79. renderWithRouter(router, userContext);
  80. expect(await screen.findByRole('heading', { name: '账号与邮箱权限' })).toBeTruthy();
  81. expect(list).toHaveBeenCalledWith('effective');
  82. expect(screen.getAllByText('管理员分配').length).toBeGreaterThan(0);
  83. const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
  84. expect(viewOnlyCard).toBeTruthy();
  85. expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
  86. expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
  87. const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
  88. expect(assignedCard).toBeTruthy();
  89. await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
  90. expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
  91. });
  92. });
  93. function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
  94. return render(
  95. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  96. <AntApp>
  97. <I18nProvider>
  98. <AppContext.Provider value={context}>
  99. <RouterProvider router={router} />
  100. </AppContext.Provider>
  101. </I18nProvider>
  102. </AntApp>
  103. </ConfigProvider>
  104. );
  105. }
  106. function LocationProbe() {
  107. const location = useLocation();
  108. return <div data-testid="location">{location.pathname}{location.search}</div>;
  109. }
  110. function accessEntry(): AdminMailboxAccessEntry {
  111. const mailbox = mailboxFixture(10, 'support@example.test', 'owner', { view: true, receive: true, send: true }, 1);
  112. return {
  113. mailbox,
  114. owner: adminUsers[0],
  115. grants: [{
  116. user: adminUsers[1],
  117. permissions: { view: true, receive: true, send: false },
  118. createdAt: '2026-07-18T00:00:00.000Z',
  119. updatedAt: '2026-07-18T00:00:00.000Z'
  120. }]
  121. };
  122. }
  123. function mailboxFixture(
  124. id: number,
  125. address: string,
  126. type: MailboxAccessType,
  127. permissions: MailboxPermissions,
  128. ownerUserId: number
  129. ): InboundMailbox {
  130. const [, domain] = address.split('@');
  131. return {
  132. id,
  133. userId: ownerUserId,
  134. ownerUserId,
  135. domainId: id,
  136. domain,
  137. address,
  138. localPart: address.split('@')[0],
  139. displayName: '',
  140. aliases: [],
  141. forwardTo: [],
  142. keepForwarded: true,
  143. quotaMb: 1024,
  144. passwordSet: true,
  145. passwordRecoverable: false,
  146. status: 'active',
  147. messageCount: permissions.receive ? 3 : null,
  148. unreadCount: permissions.receive ? 1 : null,
  149. lastMessageAt: permissions.receive ? '2026-07-18T00:00:00.000Z' : null,
  150. access: { type, permissions },
  151. createdAt: '2026-07-18T00:00:00.000Z',
  152. updatedAt: '2026-07-18T00:00:00.000Z'
  153. };
  154. }
  155. const adminUsers: AdminUser[] = [
  156. { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
  157. { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() }
  158. ];
  159. function contextFor(user: User): AppContextValue {
  160. return {
  161. user,
  162. config,
  163. refreshBootstrap: vi.fn(async () => undefined),
  164. logout: vi.fn(async () => undefined)
  165. };
  166. }
  167. function resourceCounts() {
  168. return { domains: 0, dnsCredentials: 0, apiTokens: 0, inboundMailboxes: 0, inboundMessages: 0, sendEvents: 0, smtpCredential: 0 };
  169. }
  170. const config: RuntimeConfig = {
  171. appBaseUrl: 'https://mail.example.test',
  172. mailHostname: 'mail.example.test',
  173. sendingIp: '192.0.2.10',
  174. defaultSpfMechanisms: '',
  175. dmarcPolicy: 'none',
  176. dmarcRua: '',
  177. registrationRequiresApproval: false,
  178. sendRequiresVerified: true,
  179. engagementTrackingEnabled: true,
  180. listUnsubscribeMailto: '',
  181. listUnsubscribeUrl: '',
  182. listUnsubscribePostEnabled: false,
  183. feedbackIdEnabled: false,
  184. reportAbuseTo: '',
  185. csaComplaintsTo: '',
  186. bounceAddress: '',
  187. bounceEnvelopeEnabled: false
  188. };
  189. const adminContext: AppContextValue = contextFor(adminUsers[0]);
  190. const userContext: AppContextValue = contextFor({ id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active' });