settings-navigation-guard.test.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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 { RuntimeConfig } from '../../src/frontend/types';
  11. import { AdminLayout } from '../../src/layouts/AdminLayout';
  12. import Settings from '../../src/pages/Settings';
  13. describe('Settings navigation guard', () => {
  14. afterEach(() => vi.restoreAllMocks());
  15. it('guards sidebar and history navigation, beforeunload, and stops guarding after a successful save', async () => {
  16. const user = userEvent.setup();
  17. vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
  18. const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
  19. settings: { ...runtimeConfig, ...values }
  20. }));
  21. const router = renderSettingsRouter();
  22. let baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  23. const cleanUnload = new Event('beforeunload', { cancelable: true });
  24. window.dispatchEvent(cleanUnload);
  25. expect(cleanUnload.defaultPrevented).toBe(false);
  26. await user.clear(baseUrlInput);
  27. await user.type(baseUrlInput, 'https://changed.example.test');
  28. const dirtyUnload = new Event('beforeunload', { cancelable: true });
  29. window.dispatchEvent(dirtyUnload);
  30. expect(dirtyUnload.defaultPrevented).toBe(true);
  31. await user.click(screen.getAllByText('发送活动')[0]);
  32. let dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  33. expect(router.state.location.pathname).toBe('/settings');
  34. await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
  35. await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
  36. expect(router.state.location.pathname).toBe('/settings');
  37. await user.click(screen.getAllByText('发送活动')[0]);
  38. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  39. await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
  40. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  41. await act(async () => {
  42. await router.navigate(-1);
  43. });
  44. baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  45. await user.clear(baseUrlInput);
  46. await user.type(baseUrlInput, 'https://history.example.test');
  47. await act(async () => {
  48. await router.navigate(-1);
  49. });
  50. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  51. expect(router.state.location.pathname).toBe('/settings');
  52. await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
  53. await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
  54. await act(async () => {
  55. await router.navigate(1);
  56. });
  57. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  58. await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
  59. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  60. await act(async () => {
  61. await router.navigate(-1);
  62. });
  63. baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  64. await user.clear(baseUrlInput);
  65. await user.type(baseUrlInput, 'https://saved.example.test');
  66. const saveButton = screen.getByRole('button', { name: /保存设置/ });
  67. await user.click(saveButton);
  68. await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
  69. await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(true));
  70. const savedUnload = new Event('beforeunload', { cancelable: true });
  71. window.dispatchEvent(savedUnload);
  72. expect(savedUnload.defaultPrevented).toBe(false);
  73. await user.click(screen.getAllByText('概览')[0]);
  74. await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
  75. expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
  76. }, 20_000);
  77. it('blocks a public HTTP base URL until it is changed to HTTPS', async () => {
  78. const user = userEvent.setup();
  79. const insecureConfig = { ...runtimeConfig, appBaseUrl: 'http://mail.example.test' };
  80. vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: insecureConfig });
  81. const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
  82. settings: { ...insecureConfig, ...values }
  83. }));
  84. renderSettingsRouter(insecureConfig);
  85. expect(await screen.findByText('公开访问地址不安全')).not.toBeNull();
  86. const input = screen.getByLabelText('APP_BASE_URL');
  87. await user.clear(input);
  88. await user.type(input, 'http://public.example.test');
  89. await user.click(screen.getByRole('button', { name: /保存设置/ }));
  90. expect((await screen.findAllByText('公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。')).length).toBeGreaterThan(0);
  91. expect(saveSettings).not.toHaveBeenCalled();
  92. await user.clear(input);
  93. await user.type(input, 'https://public.example.test');
  94. await user.click(screen.getByRole('button', { name: /保存设置/ }));
  95. await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
  96. });
  97. });
  98. function renderSettingsRouter(config = runtimeConfig) {
  99. const context: AppContextValue = {
  100. user: {
  101. id: 1,
  102. username: 'operator',
  103. email: 'operator@example.test',
  104. role: 'admin',
  105. status: 'active'
  106. },
  107. config,
  108. refreshBootstrap: vi.fn(async () => undefined),
  109. logout: vi.fn(async () => undefined)
  110. };
  111. const router = createMemoryRouter([
  112. {
  113. element: <AdminLayout />,
  114. children: [
  115. { path: '/settings', element: <Settings /> },
  116. { path: '*', element: <LocationProbe /> }
  117. ]
  118. }
  119. ], {
  120. initialEntries: ['/overview', '/settings', '/activity'],
  121. initialIndex: 1
  122. });
  123. render(
  124. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  125. <AntApp>
  126. <I18nProvider>
  127. <AppContext.Provider value={context}>
  128. <RouterProvider router={router} />
  129. </AppContext.Provider>
  130. </I18nProvider>
  131. </AntApp>
  132. </ConfigProvider>
  133. );
  134. return router;
  135. }
  136. function LocationProbe() {
  137. const location = useLocation();
  138. return <div data-testid="location">{location.pathname}</div>;
  139. }
  140. const runtimeConfig: RuntimeConfig = {
  141. appBaseUrl: 'https://mail.example.test',
  142. mailHostname: 'mail.example.test',
  143. sendingIp: '192.0.2.10',
  144. defaultSpfMechanisms: '',
  145. dmarcPolicy: 'none',
  146. dmarcRua: '',
  147. sendRequiresVerified: true,
  148. engagementTrackingEnabled: true,
  149. listUnsubscribeMailto: '',
  150. listUnsubscribeUrl: '',
  151. listUnsubscribePostEnabled: false,
  152. feedbackIdEnabled: false,
  153. reportAbuseTo: '',
  154. csaComplaintsTo: '',
  155. bounceAddress: '',
  156. bounceEnvelopeEnabled: false
  157. };