| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- 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 { RuntimeConfig } from '../../src/frontend/types';
- import { AdminLayout } from '../../src/layouts/AdminLayout';
- import Settings from '../../src/pages/Settings';
- describe('Settings navigation guard', () => {
- afterEach(() => vi.restoreAllMocks());
- it('guards sidebar and history navigation, beforeunload, and stops guarding after a successful save', async () => {
- const user = userEvent.setup();
- vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
- const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
- settings: { ...runtimeConfig, ...values }
- }));
- const router = renderSettingsRouter();
- let baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
- const cleanUnload = new Event('beforeunload', { cancelable: true });
- window.dispatchEvent(cleanUnload);
- expect(cleanUnload.defaultPrevented).toBe(false);
- await user.clear(baseUrlInput);
- await user.type(baseUrlInput, 'https://changed.example.test');
- const dirtyUnload = new Event('beforeunload', { cancelable: true });
- window.dispatchEvent(dirtyUnload);
- expect(dirtyUnload.defaultPrevented).toBe(true);
- await user.click(screen.getAllByText('发送活动')[0]);
- let dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
- expect(router.state.location.pathname).toBe('/settings');
- await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
- await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
- expect(router.state.location.pathname).toBe('/settings');
- await user.click(screen.getAllByText('发送活动')[0]);
- dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
- await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
- await act(async () => {
- await router.navigate(-1);
- });
- baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
- await user.clear(baseUrlInput);
- await user.type(baseUrlInput, 'https://history.example.test');
- await act(async () => {
- await router.navigate(-1);
- });
- dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
- expect(router.state.location.pathname).toBe('/settings');
- await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
- await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
- await act(async () => {
- await router.navigate(1);
- });
- dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
- await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
- await act(async () => {
- await router.navigate(-1);
- });
- baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
- await user.clear(baseUrlInput);
- await user.type(baseUrlInput, 'https://saved.example.test');
- const saveButton = screen.getByRole('button', { name: /保存设置/ });
- await user.click(saveButton);
- await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
- await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(true));
- const savedUnload = new Event('beforeunload', { cancelable: true });
- window.dispatchEvent(savedUnload);
- expect(savedUnload.defaultPrevented).toBe(false);
- await user.click(screen.getAllByText('概览')[0]);
- await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
- expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
- }, 20_000);
- it('blocks a public HTTP base URL until it is changed to HTTPS', async () => {
- const user = userEvent.setup();
- const insecureConfig = { ...runtimeConfig, appBaseUrl: 'http://mail.example.test' };
- vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: insecureConfig });
- const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
- settings: { ...insecureConfig, ...values }
- }));
- renderSettingsRouter(insecureConfig);
- expect(await screen.findByText('公开访问地址不安全')).not.toBeNull();
- const input = screen.getByLabelText('APP_BASE_URL');
- await user.clear(input);
- await user.type(input, 'http://public.example.test');
- await user.click(screen.getByRole('button', { name: /保存设置/ }));
- expect((await screen.findAllByText('公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。')).length).toBeGreaterThan(0);
- expect(saveSettings).not.toHaveBeenCalled();
- await user.clear(input);
- await user.type(input, 'https://public.example.test');
- await user.click(screen.getByRole('button', { name: /保存设置/ }));
- await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
- });
- });
- function renderSettingsRouter(config = runtimeConfig) {
- const context: AppContextValue = {
- user: {
- id: 1,
- username: 'operator',
- email: 'operator@example.test',
- role: 'admin',
- status: 'active'
- },
- config,
- refreshBootstrap: vi.fn(async () => undefined),
- logout: vi.fn(async () => undefined)
- };
- const router = createMemoryRouter([
- {
- element: <AdminLayout />,
- children: [
- { path: '/settings', element: <Settings /> },
- { path: '*', element: <LocationProbe /> }
- ]
- }
- ], {
- initialEntries: ['/overview', '/settings', '/activity'],
- initialIndex: 1
- });
- render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={context}>
- <RouterProvider router={router} />
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- return router;
- }
- function LocationProbe() {
- const location = useLocation();
- return <div data-testid="location">{location.pathname}</div>;
- }
- const runtimeConfig: RuntimeConfig = {
- appBaseUrl: 'https://mail.example.test',
- mailHostname: 'mail.example.test',
- sendingIp: '192.0.2.10',
- defaultSpfMechanisms: '',
- dmarcPolicy: 'none',
- dmarcRua: '',
- sendRequiresVerified: true,
- engagementTrackingEnabled: true,
- listUnsubscribeMailto: '',
- listUnsubscribeUrl: '',
- listUnsubscribePostEnabled: false,
- feedbackIdEnabled: false,
- reportAbuseTo: '',
- csaComplaintsTo: '',
- bounceAddress: '',
- bounceEnvelopeEnabled: false
- };
|