api-token-secret.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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('requires selected mailboxes for send or 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('all'));
  60. await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
  61. const editor = await screen.findByRole('dialog');
  62. expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
  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('allows a send-only token to select an assigned mailbox with send access', async () => {
  79. const browser = userEvent.setup();
  80. const base = mailboxFixture();
  81. const owned = {
  82. ...base,
  83. id: 1,
  84. userId: 1,
  85. ownerUserId: 1,
  86. address: 'owned@example.test',
  87. access: { type: 'owner' as const, permissions: { view: true, receive: true, send: true } }
  88. };
  89. const assigned = {
  90. ...base,
  91. id: 2,
  92. address: 'assigned@example.test',
  93. access: { type: 'assigned' as const, permissions: { view: true, receive: true, send: false } }
  94. };
  95. const sendOnly = {
  96. ...base,
  97. id: 3,
  98. address: 'send-only@example.test',
  99. access: { type: 'assigned' as const, permissions: { view: true, receive: false, send: true } }
  100. };
  101. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  102. const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, sendOnly] });
  103. const created = tokenFixture({
  104. name: 'Shared sender',
  105. token: 'mh_shared.full-secret',
  106. tokenRecoverable: true,
  107. scopes: ['send'],
  108. mailboxAccess: 'selected',
  109. mailboxIds: [sendOnly.id]
  110. });
  111. const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
  112. renderPage('user');
  113. await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
  114. await browser.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
  115. const editor = await screen.findByRole('dialog');
  116. await browser.type(within(editor).getByLabelText('名称'), 'Shared sender');
  117. await browser.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
  118. await browser.click(within(editor).getByLabelText('授权邮箱'));
  119. expect(await screen.findByText(owned.address)).toBeTruthy();
  120. expect(screen.getByText(new RegExp(assigned.address))).toBeTruthy();
  121. expect(screen.getByText(new RegExp(sendOnly.address))).toBeTruthy();
  122. await browser.click(screen.getByText(new RegExp(sendOnly.address)));
  123. await browser.click(within(editor).getByRole('button', { name: /创建密钥/ }));
  124. await waitFor(() => expect(createToken).toHaveBeenCalledWith({
  125. name: 'Shared sender',
  126. scopes: ['send'],
  127. expiresAt: null,
  128. mailboxAccess: 'selected',
  129. mailboxIds: [sendOnly.id]
  130. }));
  131. });
  132. it('shows selected mailbox access in the detail drawer for a send-only token', async () => {
  133. const browser = userEvent.setup();
  134. const mailbox = mailboxFixture();
  135. const token = tokenFixture({
  136. scopes: ['send'],
  137. mailboxAccess: 'selected',
  138. mailboxIds: [mailbox.id]
  139. });
  140. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
  141. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  142. renderPage('user');
  143. await browser.click(await screen.findByText(token.name));
  144. const detail = await screen.findByRole('dialog');
  145. expect(within(detail).getByText('指定邮箱 · 1')).toBeTruthy();
  146. expect(within(detail).getByText(mailbox.address)).toBeTruthy();
  147. });
  148. it('preserves selected mailbox access when editing a mailboxes:read token', async () => {
  149. const browser = userEvent.setup();
  150. const mailbox = mailboxFixture();
  151. const token = tokenFixture({
  152. name: 'Mailbox reader',
  153. scopes: ['mailboxes:read'],
  154. mailboxAccess: 'selected',
  155. mailboxIds: [mailbox.id]
  156. });
  157. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
  158. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  159. const updateToken = vi.spyOn(api, 'updateApiToken').mockResolvedValue({ token });
  160. renderPage('user');
  161. await browser.click(await screen.findByRole('button', { name: /编辑.*Mailbox reader/ }));
  162. const editor = await screen.findByRole('dialog');
  163. expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
  164. expect(within(editor).getByText(mailbox.address)).toBeTruthy();
  165. await browser.click(within(editor).getByRole('button', { name: /保\s*存/ }));
  166. await waitFor(() => expect(updateToken).toHaveBeenCalledWith(token.id, {
  167. name: token.name,
  168. scopes: ['mailboxes:read'],
  169. expiresAt: null,
  170. mailboxAccess: 'selected',
  171. mailboxIds: [mailbox.id]
  172. }));
  173. });
  174. it('regenerates an unrecoverable legacy token only after destructive confirmation', async () => {
  175. const user = userEvent.setup();
  176. const legacy = tokenFixture({ tokenRecoverable: false, token: undefined, name: 'Legacy worker' });
  177. const rotated = tokenFixture({ tokenRecoverable: true, token: 'mh_rotated.new-secret', name: 'Legacy worker' });
  178. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [legacy] });
  179. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  180. const rotate = vi.spyOn(api, 'rotateApiToken').mockResolvedValue({ token: rotated });
  181. renderPage();
  182. await user.click(await screen.findByRole('button', { name: '重新生成 Legacy worker' }));
  183. expect(screen.getByText('旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。')).not.toBeNull();
  184. const confirmations = screen.getAllByRole('button', { name: '重新生成' });
  185. await user.click(confirmations[confirmations.length - 1]);
  186. await waitFor(() => expect(rotate).toHaveBeenCalledWith(9));
  187. expect(await screen.findByText('API 密钥已重新生成')).not.toBeNull();
  188. expect((await screen.findAllByText('mh_rotated.new-secret')).length).toBeGreaterThan(0);
  189. });
  190. });
  191. function renderPage(role: UserRole = 'admin') {
  192. const context: AppContextValue = {
  193. user: { id: 1, username: 'operator', email: 'operator@example.test', role, status: 'active' },
  194. config,
  195. refreshBootstrap: vi.fn(async () => undefined),
  196. logout: vi.fn(async () => undefined)
  197. };
  198. return render(
  199. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  200. <AntApp>
  201. <I18nProvider>
  202. <AppContext.Provider value={context}>
  203. <MemoryRouter initialEntries={['/integrations/api-keys']}>
  204. <ApiTokens />
  205. </MemoryRouter>
  206. </AppContext.Provider>
  207. </I18nProvider>
  208. </AntApp>
  209. </ConfigProvider>
  210. );
  211. }
  212. function tokenFixture(overrides: Partial<ApiToken> = {}): ApiToken {
  213. return {
  214. id: 9,
  215. name: 'CI sender',
  216. tokenPrefix: 'mh_12345678',
  217. tokenRecoverable: false,
  218. scopes: ['send'],
  219. mailboxAccess: 'owner',
  220. mailboxIds: [],
  221. status: 'active',
  222. createdAt: '2026-07-14T00:00:00.000Z',
  223. ...overrides
  224. };
  225. }
  226. function mailboxFixture(): InboundMailbox {
  227. return {
  228. id: 42,
  229. userId: 2,
  230. ownerUserId: 2,
  231. domainId: 5,
  232. domain: 'example.test',
  233. address: 'billing@example.test',
  234. localPart: 'billing',
  235. displayName: 'Billing',
  236. aliases: [],
  237. forwardTo: [],
  238. keepForwarded: true,
  239. quotaMb: 1024,
  240. passwordSet: true,
  241. passwordRecoverable: false,
  242. status: 'active',
  243. messageCount: 1,
  244. unreadCount: 1,
  245. access: { type: 'admin', permissions: { view: true, receive: true, send: true } },
  246. createdAt: '2026-07-14T00:00:00.000Z',
  247. updatedAt: '2026-07-14T00:00:00.000Z'
  248. };
  249. }
  250. const config: RuntimeConfig = {
  251. appBaseUrl: 'https://mail.example.test',
  252. mailHostname: 'mail.example.test',
  253. sendingIp: '192.0.2.10',
  254. defaultSpfMechanisms: '',
  255. dmarcPolicy: 'none',
  256. dmarcRua: '',
  257. registrationRequiresApproval: false,
  258. sendRequiresVerified: true,
  259. engagementTrackingEnabled: true,
  260. listUnsubscribeMailto: '',
  261. listUnsubscribeUrl: '',
  262. listUnsubscribePostEnabled: false,
  263. feedbackIdEnabled: false,
  264. reportAbuseTo: '',
  265. csaComplaintsTo: '',
  266. bounceAddress: '',
  267. bounceEnvelopeEnabled: false
  268. };