integration-resource-resilience.test.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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 type { ReactElement } from 'react';
  5. import { createMemoryRouter, RouterProvider } from 'react-router-dom';
  6. import { describe, expect, it, vi } from 'vitest';
  7. import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
  8. import { I18nProvider } from '../../src/frontend/i18n/react';
  9. import { api } from '../../src/frontend/services/api';
  10. import { mailhubTheme } from '../../src/frontend/theme';
  11. import type {
  12. ApiToken,
  13. DnsCredential,
  14. RuntimeConfig,
  15. SmtpCredential,
  16. SmtpRelay,
  17. Webhook
  18. } from '../../src/frontend/types';
  19. import ApiTokens from '../../src/pages/ApiTokens';
  20. import DnsApi from '../../src/pages/DnsApi';
  21. import SmtpCredentials from '../../src/pages/SmtpCredentials';
  22. import Webhooks from '../../src/pages/Webhooks';
  23. describe('Integration resource resilience and deep links', () => {
  24. it('keeps an initial Webhook endpoint failure visible and never presents it as an empty list', async () => {
  25. const user = userEvent.setup();
  26. const endpoints = vi.spyOn(api, 'webhooks')
  27. .mockRejectedValueOnce(new Error('Webhook endpoint service unavailable'))
  28. .mockResolvedValue({ webhooks: [webhook] });
  29. vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
  30. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  31. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  32. renderPage(<Webhooks />, '/integrations/webhooks', '/integrations/webhooks');
  33. const errorTitle = await screen.findByText('Webhook 端点加载失败');
  34. const alert = errorTitle.closest('.ant-alert');
  35. expect(alert).not.toBeNull();
  36. expect(within(alert as HTMLElement).getByText('Webhook endpoint service unavailable')).toBeTruthy();
  37. expect(screen.queryByText('暂无 Webhook。创建后即可接收投递状态回调。')).toBeNull();
  38. await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
  39. expect(await screen.findByText(webhook.name)).toBeTruthy();
  40. expect(endpoints).toHaveBeenCalledTimes(2);
  41. });
  42. it('keeps an initial API token failure visible instead of presenting a business empty state', async () => {
  43. const user = userEvent.setup();
  44. const tokens = vi.spyOn(api, 'apiTokens')
  45. .mockRejectedValueOnce(new Error('API token service unavailable'))
  46. .mockResolvedValue({ tokens: [apiToken] });
  47. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  48. renderPage(<ApiTokens />, '/integrations/api-keys', '/integrations/api-keys');
  49. const error = await screen.findByText('API token service unavailable');
  50. const alert = error.closest('.ant-alert');
  51. expect(alert).not.toBeNull();
  52. expect(document.querySelector('.empty-state')).toBeNull();
  53. expect(screen.queryByText('创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮箱访问范围。')).toBeNull();
  54. await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
  55. expect(await screen.findByText(apiToken.name)).toBeTruthy();
  56. expect(tokens).toHaveBeenCalledTimes(2);
  57. });
  58. it('keeps an initial DNS credential failure visible instead of presenting a business empty state', async () => {
  59. const user = userEvent.setup();
  60. const credentials = vi.spyOn(api, 'dnsCredentials')
  61. .mockRejectedValueOnce(new Error('DNS credential service unavailable'))
  62. .mockResolvedValue({ credentials: [dnsCredential] });
  63. renderPage(<DnsApi />, '/integrations/dns', '/integrations/dns');
  64. const error = await screen.findByText('DNS credential service unavailable');
  65. const alert = error.closest('.ant-alert');
  66. expect(alert).not.toBeNull();
  67. expect(document.querySelector('.empty-state')).toBeNull();
  68. await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
  69. expect(await screen.findByText(dnsCredential.name)).toBeTruthy();
  70. expect(credentials).toHaveBeenCalledTimes(2);
  71. await user.click(screen.getByRole('button', { name: /新增凭据/ }));
  72. expect(await findDrawer('新增 DNS 集成凭据')).toBeTruthy();
  73. });
  74. it('restores a DNS credential detail from the URL and uses the DNS-specific deletion warning', async () => {
  75. const user = userEvent.setup();
  76. vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
  77. const router = renderPage(
  78. <DnsApi />,
  79. '/integrations/dns',
  80. `/integrations/dns?credentialId=${dnsCredential.id}`
  81. );
  82. expect(await findDrawer(dnsCredential.name)).toBeTruthy();
  83. expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id));
  84. await user.click(screen.getByRole('button', { name: `删除 ${dnsCredential.name}` }));
  85. expect(await screen.findByText('确认删除该 DNS 集成凭据?删除后将无法再通过此 Provider 管理对应 Zone。')).toBeTruthy();
  86. expect(screen.queryByText('确认删除该 API 密钥?')).toBeNull();
  87. });
  88. it('closes a list-opened DNS detail without leaving a duplicate list history entry', async () => {
  89. const user = userEvent.setup();
  90. vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
  91. const router = renderPage(
  92. <DnsApi />,
  93. '/integrations/dns',
  94. '/integrations/dns',
  95. appContext(runtimeConfig),
  96. { initialEntries: ['/overview', '/integrations/dns'], initialIndex: 1 }
  97. );
  98. await user.click(await screen.findByRole('row', { name: new RegExp(dnsCredential.name) }));
  99. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id)));
  100. const detail = await findDrawer(dnsCredential.name);
  101. await user.click(within(detail).getByRole('button', { name: 'Close' }));
  102. await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('credentialId')).toBe(false));
  103. await act(async () => {
  104. await router.navigate(-1);
  105. });
  106. expect(router.state.location.pathname).toBe('/overview');
  107. });
  108. it('replaces an untouched DNS direct link when closing it', async () => {
  109. const user = userEvent.setup();
  110. vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
  111. const router = renderPage(
  112. <DnsApi />,
  113. '/integrations/dns',
  114. `/integrations/dns?credentialId=${dnsCredential.id}`,
  115. appContext(runtimeConfig),
  116. { initialEntries: ['/overview', `/integrations/dns?credentialId=${dnsCredential.id}`], initialIndex: 1 }
  117. );
  118. const detail = await findDrawer(dnsCredential.name);
  119. await user.click(within(detail).getByRole('button', { name: 'Close' }));
  120. await waitFor(() => expect(router.state.location.search).toBe(''));
  121. await act(async () => {
  122. await router.navigate(-1);
  123. });
  124. expect(router.state.location.pathname).toBe('/overview');
  125. });
  126. it('keeps relay navigation usable when the SMTP credential list fails', async () => {
  127. const user = userEvent.setup();
  128. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
  129. vi.spyOn(api, 'smtpCredentials').mockRejectedValue(new Error('SMTP credential service unavailable'));
  130. vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
  131. const router = renderPage(
  132. <SmtpCredentials />,
  133. '/integrations/smtp',
  134. `/integrations/smtp?tab=relays&relayId=${smtpRelay.id}`
  135. );
  136. const relayDetail = await findDrawer(smtpRelay.name);
  137. expect(within(relayDetail).getByText(`${smtpRelay.host}:${smtpRelay.port}`)).toBeTruthy();
  138. expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id));
  139. expect(screen.getAllByText(smtpRelay.name).length).toBeGreaterThan(0);
  140. await user.click(screen.getByRole('tab', { name: 'SMTP 凭据' }));
  141. expect(await screen.findByText('SMTP credential service unavailable')).toBeTruthy();
  142. });
  143. it('keeps credential navigation usable when the SMTP relay list fails', async () => {
  144. const user = userEvent.setup();
  145. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
  146. vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
  147. vi.spyOn(api, 'smtpRelays').mockRejectedValue(new Error('SMTP relay service unavailable'));
  148. const router = renderPage(
  149. <SmtpCredentials />,
  150. '/integrations/smtp',
  151. `/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`
  152. );
  153. const credentialDetail = await findDrawer(smtpCredential.username);
  154. expect(within(credentialDetail).getAllByText(smtpCredential.username).length).toBeGreaterThan(0);
  155. expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(smtpCredential.id));
  156. await user.click(screen.getByRole('tab', { name: '发送中继' }));
  157. expect(await screen.findByText('SMTP relay service unavailable')).toBeTruthy();
  158. });
  159. it('closes a list-opened SMTP relay without leaving the detail behind the list', async () => {
  160. const user = userEvent.setup();
  161. mockSmtpApis();
  162. const listPath = '/integrations/smtp?tab=relays';
  163. const router = renderPage(
  164. <SmtpCredentials />,
  165. '/integrations/smtp',
  166. listPath,
  167. appContext(runtimeConfig),
  168. { initialEntries: ['/overview', listPath], initialIndex: 1 }
  169. );
  170. await user.click(await screen.findByRole('row', { name: new RegExp(smtpRelay.name) }));
  171. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id)));
  172. const detail = await findDrawer(smtpRelay.name);
  173. await user.click(within(detail).getByRole('button', { name: 'Close' }));
  174. await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
  175. await act(async () => {
  176. await router.navigate(-1);
  177. });
  178. expect(router.state.location.pathname).toBe('/overview');
  179. });
  180. it('replaces an untouched SMTP credential direct link while preserving its tab', async () => {
  181. const user = userEvent.setup();
  182. mockSmtpApis();
  183. const detailPath = `/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`;
  184. const router = renderPage(
  185. <SmtpCredentials />,
  186. '/integrations/smtp',
  187. detailPath,
  188. appContext(runtimeConfig),
  189. { initialEntries: ['/overview', detailPath], initialIndex: 1 }
  190. );
  191. const detail = await findDrawer(smtpCredential.username);
  192. await user.click(within(detail).getByRole('button', { name: 'Close' }));
  193. await waitFor(() => {
  194. const params = new URLSearchParams(router.state.location.search);
  195. expect(params.get('tab')).toBe('credentials');
  196. expect(params.has('credentialId')).toBe(false);
  197. });
  198. await act(async () => {
  199. await router.navigate(-1);
  200. });
  201. expect(router.state.location.pathname).toBe('/overview');
  202. });
  203. it('restores API token and messages guide deep links while upgrading a public HTTP base URL to HTTPS', async () => {
  204. const user = userEvent.setup();
  205. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
  206. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  207. const router = renderPage(
  208. <ApiTokens />,
  209. '/integrations/api-keys',
  210. `/integrations/api-keys?guide=messages&tokenId=${apiToken.id}`,
  211. appContext({ ...runtimeConfig, appBaseUrl: 'http://mail-send.ss5.xyz' })
  212. );
  213. expect(await findDrawer(apiToken.name)).toBeTruthy();
  214. const guide = await findDrawer('API 使用文档');
  215. expect(within(guide).getByText('需要 messages:read 权限,并受 Token 的邮箱访问范围及账号收信权限共同限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。')).toBeTruthy();
  216. expect(within(guide).getAllByText('https://mail-send.ss5.xyz/api/inbound-messages').length).toBeGreaterThan(0);
  217. expect(guide.textContent).not.toContain('http://mail-send.ss5.xyz');
  218. const params = new URLSearchParams(router.state.location.search);
  219. expect(params.get('guide')).toBe('messages');
  220. expect(params.get('tokenId')).toBe(String(apiToken.id));
  221. await user.click(within(guide).getByRole('button', { name: 'Close' }));
  222. await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('guide')).toBe(false));
  223. expect(new URLSearchParams(router.state.location.search).get('tokenId')).toBe(String(apiToken.id));
  224. });
  225. it('closes nested token and guide history without reopening either drawer on Back', async () => {
  226. const user = userEvent.setup();
  227. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
  228. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  229. const listPath = '/integrations/api-keys';
  230. const router = renderPage(
  231. <ApiTokens />,
  232. '/integrations/api-keys',
  233. listPath,
  234. appContext(runtimeConfig),
  235. { initialEntries: ['/overview', listPath], initialIndex: 1 }
  236. );
  237. await user.click(await screen.findByRole('row', { name: new RegExp(apiToken.name) }));
  238. const tokenDetail = await findDrawer(apiToken.name);
  239. await user.click(within(tokenDetail).getByRole('button', { name: /API 使用文档/ }));
  240. const guide = await findDrawer('API 使用文档');
  241. expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
  242. await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
  243. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
  244. await act(async () => {
  245. await router.navigate(-1);
  246. });
  247. expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
  248. await act(async () => {
  249. await router.navigate(1);
  250. });
  251. expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
  252. await user.click(within(guide).getByRole('button', { name: 'Close' }));
  253. await waitFor(() => {
  254. const params = new URLSearchParams(router.state.location.search);
  255. expect(params.has('guide')).toBe(false);
  256. expect(params.get('tokenId')).toBe(String(apiToken.id));
  257. });
  258. await user.click(within(tokenDetail).getByRole('button', { name: 'Close' }));
  259. await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
  260. await act(async () => {
  261. await router.navigate(-1);
  262. });
  263. expect(router.state.location.pathname).toBe('/overview');
  264. });
  265. it('closes a changed direct-link API guide before replacing its direct token detail', async () => {
  266. const user = userEvent.setup();
  267. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
  268. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  269. const directPath = `/integrations/api-keys?guide=send&tokenId=${apiToken.id}`;
  270. const router = renderPage(
  271. <ApiTokens />,
  272. '/integrations/api-keys',
  273. directPath,
  274. appContext(runtimeConfig),
  275. { initialEntries: ['/overview', directPath], initialIndex: 1 }
  276. );
  277. const tokenDetail = await findDrawer(apiToken.name);
  278. const guide = await findDrawer('API 使用文档');
  279. await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
  280. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
  281. await act(async () => {
  282. await router.navigate(-1);
  283. });
  284. expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
  285. await act(async () => {
  286. await router.navigate(1);
  287. });
  288. expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
  289. await user.click(within(guide).getByRole('button', { name: 'Close' }));
  290. await waitFor(() => {
  291. const params = new URLSearchParams(router.state.location.search);
  292. expect(params.has('guide')).toBe(false);
  293. expect(params.get('tokenId')).toBe(String(apiToken.id));
  294. });
  295. await user.click(within(tokenDetail).getByRole('button', { name: 'Close' }));
  296. await waitFor(() => expect(router.state.location.search).toBe(''));
  297. await act(async () => {
  298. await router.navigate(-1);
  299. });
  300. expect(router.state.location.pathname).toBe('/overview');
  301. });
  302. });
  303. function renderPage(
  304. element: ReactElement,
  305. routePath: string,
  306. initialEntry: string,
  307. context: AppContextValue = appContext(runtimeConfig),
  308. history?: { initialEntries: string[]; initialIndex: number }
  309. ) {
  310. const router = createMemoryRouter([
  311. { path: routePath, element },
  312. { path: '*', element: <div>other</div> }
  313. ], history || { initialEntries: [initialEntry], initialIndex: 0 });
  314. render(
  315. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  316. <AntApp>
  317. <I18nProvider>
  318. <AppContext.Provider value={context}>
  319. <RouterProvider router={router} />
  320. </AppContext.Provider>
  321. </I18nProvider>
  322. </AntApp>
  323. </ConfigProvider>
  324. );
  325. return router;
  326. }
  327. async function findDrawer(title: string) {
  328. const titleElement = await screen.findByText(title, { selector: '.ant-drawer-title' });
  329. const drawer = titleElement.closest('[role="dialog"]');
  330. expect(drawer).not.toBeNull();
  331. return drawer as HTMLElement;
  332. }
  333. function appContext(config: RuntimeConfig): AppContextValue {
  334. return {
  335. user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
  336. config,
  337. refreshBootstrap: vi.fn(async () => undefined),
  338. logout: vi.fn(async () => undefined)
  339. };
  340. }
  341. function mockSmtpApis() {
  342. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
  343. vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
  344. vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
  345. }
  346. const runtimeConfig: RuntimeConfig = {
  347. appBaseUrl: 'https://mail.example.test',
  348. mailHostname: 'mail.example.test',
  349. sendingIp: '192.0.2.10',
  350. defaultSpfMechanisms: '',
  351. dmarcPolicy: 'none',
  352. dmarcRua: '',
  353. registrationRequiresApproval: false,
  354. sendRequiresVerified: true,
  355. engagementTrackingEnabled: true,
  356. listUnsubscribeMailto: '',
  357. listUnsubscribeUrl: '',
  358. listUnsubscribePostEnabled: false,
  359. feedbackIdEnabled: false,
  360. reportAbuseTo: '',
  361. csaComplaintsTo: '',
  362. bounceAddress: '',
  363. bounceEnvelopeEnabled: false,
  364. submission: {
  365. enabled: true,
  366. host: 'mail.example.test',
  367. ports: [{ port: 587, protocol: 'STARTTLS' }],
  368. username: 'primary-smtp',
  369. passwordSet: true,
  370. inboundEnabled: true,
  371. tls: true,
  372. requireTlsForAuth: true
  373. }
  374. };
  375. const webhook: Webhook = {
  376. id: 7,
  377. userId: 1,
  378. domainId: null,
  379. mailboxId: null,
  380. name: 'Production events',
  381. url: 'https://hooks.example.test/mailhub',
  382. secretPrefix: 'whsec_prod',
  383. events: ['sent', 'failed'],
  384. enabled: true,
  385. createdAt: '2026-07-14T00:00:00.000Z',
  386. updatedAt: '2026-07-14T00:00:00.000Z'
  387. };
  388. const dnsCredential: DnsCredential = {
  389. id: 3,
  390. userId: 1,
  391. name: 'Primary Cloudflare',
  392. provider: 'cloudflare',
  393. zoneName: 'example.test',
  394. defaultTtl: 600,
  395. credentialSet: true,
  396. createdAt: '2026-07-14T00:00:00.000Z',
  397. updatedAt: '2026-07-14T00:00:00.000Z'
  398. };
  399. const smtpCredential: SmtpCredential = {
  400. id: 5,
  401. userId: 1,
  402. username: 'application-smtp',
  403. passwordSet: true,
  404. createdAt: '2026-07-14T00:00:00.000Z',
  405. updatedAt: '2026-07-14T00:00:00.000Z'
  406. };
  407. const smtpRelay: SmtpRelay = {
  408. id: 8,
  409. userId: 1,
  410. name: 'SES outbound',
  411. host: 'email-smtp.us-east-1.amazonaws.com',
  412. port: 587,
  413. secure: false,
  414. username: 'relay-user',
  415. passwordSet: true,
  416. helo: 'mail.example.test',
  417. isDefault: true,
  418. createdAt: '2026-07-14T00:00:00.000Z',
  419. updatedAt: '2026-07-14T00:00:00.000Z'
  420. };
  421. const apiToken: ApiToken = {
  422. id: 9,
  423. userId: 1,
  424. name: 'Message reader',
  425. tokenPrefix: 'mh_message',
  426. tokenRecoverable: false,
  427. scopes: ['messages:read'],
  428. mailboxAccess: 'owner',
  429. mailboxIds: [],
  430. status: 'active',
  431. createdAt: '2026-07-14T00:00:00.000Z'
  432. };