api-token-secret.test.tsx 7.4 KB

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