api-token-secret.test.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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 { MemoryRouter } from 'react-router-dom';
  5. import { 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 { ApiToken, InboundMailbox, RuntimeConfig, UserRole } from '../../src/frontend/types';
  11. import ApiTokens from '../../src/pages/ApiTokens';
  12. describe('API token secrets and message access', () => {
  13. it('keeps a newly created full token copyable after acknowledgement', async () => {
  14. const user = userEvent.setup();
  15. const fullToken = 'mh_12345678.full-secret-value';
  16. const summary = tokenFixture({ token: fullToken, tokenRecoverable: true });
  17. vi.spyOn(api, 'apiTokens')
  18. .mockResolvedValueOnce({ tokens: [] })
  19. .mockResolvedValue({ tokens: [summary] });
  20. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  21. const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: summary });
  22. renderPage();
  23. await screen.findByText(/新 API 密钥会加密保存并支持完整复制/);
  24. await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
  25. const editor = await screen.findByRole('dialog');
  26. await user.type(within(editor).getByLabelText('名称'), 'CI sender');
  27. await user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
  28. await waitFor(() => expect(createToken).toHaveBeenCalledWith({
  29. name: 'CI sender',
  30. scopes: ['send'],
  31. expiresAt: null,
  32. mailboxAccess: 'owner',
  33. mailboxIds: []
  34. }));
  35. expect((await screen.findAllByText(fullToken)).length).toBeGreaterThan(0);
  36. const reveal = screen.getByRole('dialog', { name: 'API 密钥已创建' });
  37. await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
  38. await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API 密钥已创建' })).toBeNull());
  39. await waitFor(() => expect(screen.getAllByText(fullToken)).toHaveLength(1));
  40. const copyButtons = screen.getAllByRole('button', { name: '复制完整 Token CI sender' });
  41. expect(copyButtons).toHaveLength(1);
  42. await user.click(copyButtons[0]);
  43. expect(screen.queryByRole('dialog', { name: 'CI sender' })).toBeNull();
  44. });
  45. it('progressively requires selected mailboxes for messages:read and loads all choices for admins', async () => {
  46. const user = userEvent.setup();
  47. const mailbox = mailboxFixture();
  48. const created = tokenFixture({
  49. token: 'mh_selected.full-secret',
  50. tokenRecoverable: true,
  51. scopes: ['send', 'messages:read'],
  52. mailboxAccess: 'selected',
  53. mailboxIds: [mailbox.id]
  54. });
  55. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  56. const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  57. const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
  58. renderPage('admin');
  59. await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith(true));
  60. await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
  61. const editor = await screen.findByRole('dialog');
  62. expect(within(editor).queryByText('邮件读取范围')).toBeNull();
  63. await user.type(within(editor).getByLabelText('名称'), 'Message reader');
  64. await user.click(within(editor).getByRole('checkbox', { name: 'messages:read' }));
  65. expect(await within(editor).findByText('邮件读取范围')).not.toBeNull();
  66. await user.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
  67. await user.click(within(editor).getByLabelText('授权邮箱'));
  68. await user.click(await screen.findByText(new RegExp(mailbox.address)));
  69. await user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
  70. await waitFor(() => expect(createToken).toHaveBeenCalledWith({
  71. name: 'Message reader',
  72. scopes: ['send', 'messages:read'],
  73. expiresAt: null,
  74. mailboxAccess: 'selected',
  75. mailboxIds: [mailbox.id]
  76. }));
  77. });
  78. it('regenerates an unrecoverable legacy token only after destructive confirmation', async () => {
  79. const user = userEvent.setup();
  80. const legacy = tokenFixture({ tokenRecoverable: false, token: undefined, name: 'Legacy worker' });
  81. const rotated = tokenFixture({ tokenRecoverable: true, token: 'mh_rotated.new-secret', name: 'Legacy worker' });
  82. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [legacy] });
  83. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  84. const rotate = vi.spyOn(api, 'rotateApiToken').mockResolvedValue({ token: rotated });
  85. renderPage();
  86. await user.click(await screen.findByRole('button', { name: '重新生成 Legacy worker' }));
  87. expect(screen.getByText('旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。')).not.toBeNull();
  88. const confirmations = screen.getAllByRole('button', { name: '重新生成' });
  89. await user.click(confirmations[confirmations.length - 1]);
  90. await waitFor(() => expect(rotate).toHaveBeenCalledWith(9));
  91. expect(await screen.findByText('API 密钥已重新生成')).not.toBeNull();
  92. expect((await screen.findAllByText('mh_rotated.new-secret')).length).toBeGreaterThan(0);
  93. });
  94. });
  95. function renderPage(role: UserRole = 'admin') {
  96. const context: AppContextValue = {
  97. user: { id: 1, username: 'operator', email: 'operator@example.test', role, status: 'active' },
  98. config,
  99. refreshBootstrap: vi.fn(async () => undefined),
  100. logout: vi.fn(async () => undefined)
  101. };
  102. return render(
  103. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  104. <AntApp>
  105. <I18nProvider>
  106. <AppContext.Provider value={context}>
  107. <MemoryRouter initialEntries={['/integrations/api-keys']}>
  108. <ApiTokens />
  109. </MemoryRouter>
  110. </AppContext.Provider>
  111. </I18nProvider>
  112. </AntApp>
  113. </ConfigProvider>
  114. );
  115. }
  116. function tokenFixture(overrides: Partial<ApiToken> = {}): ApiToken {
  117. return {
  118. id: 9,
  119. name: 'CI sender',
  120. tokenPrefix: 'mh_12345678',
  121. tokenRecoverable: false,
  122. scopes: ['send'],
  123. mailboxAccess: 'owner',
  124. mailboxIds: [],
  125. status: 'active',
  126. createdAt: '2026-07-14T00:00:00.000Z',
  127. ...overrides
  128. };
  129. }
  130. function mailboxFixture(): InboundMailbox {
  131. return {
  132. id: 42,
  133. userId: 2,
  134. domainId: 5,
  135. domain: 'example.test',
  136. address: 'billing@example.test',
  137. localPart: 'billing',
  138. displayName: 'Billing',
  139. aliases: [],
  140. forwardTo: [],
  141. keepForwarded: true,
  142. quotaMb: 1024,
  143. passwordSet: true,
  144. passwordRecoverable: false,
  145. status: 'active',
  146. messageCount: 1,
  147. unreadCount: 1,
  148. createdAt: '2026-07-14T00:00:00.000Z',
  149. updatedAt: '2026-07-14T00:00:00.000Z'
  150. };
  151. }
  152. const config: RuntimeConfig = {
  153. appBaseUrl: 'https://mail.example.test',
  154. mailHostname: 'mail.example.test',
  155. sendingIp: '192.0.2.10',
  156. defaultSpfMechanisms: '',
  157. dmarcPolicy: 'none',
  158. dmarcRua: '',
  159. registrationRequiresApproval: false,
  160. sendRequiresVerified: true,
  161. engagementTrackingEnabled: true,
  162. listUnsubscribeMailto: '',
  163. listUnsubscribeUrl: '',
  164. listUnsubscribePostEnabled: false,
  165. feedbackIdEnabled: false,
  166. reportAbuseTo: '',
  167. csaComplaintsTo: '',
  168. bounceAddress: '',
  169. bounceEnvelopeEnabled: false
  170. };