CameraTokensPage.test.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /**
  2. * Frontend tests for the Camera API Tokens page (#1108).
  3. *
  4. * Coverage:
  5. * - List populates the "My tokens" table.
  6. * - Create flow shows the plaintext exactly once in a copy modal.
  7. * - Days input is clamped to the 365-day cap.
  8. * - Revoke triggers the confirm prompt and refreshes the list.
  9. * - Listing endpoints never return the plaintext (`token` field is null in the
  10. * refreshed view; covered indirectly via the create-then-refresh flow).
  11. */
  12. import { describe, it, expect, afterEach, vi } from 'vitest';
  13. import { screen, waitFor, within } from '@testing-library/react';
  14. import userEvent from '@testing-library/user-event';
  15. import { http, HttpResponse } from 'msw';
  16. import { render } from '../utils';
  17. import { server } from '../mocks/server';
  18. import CameraTokensPage from '../../pages/CameraTokensPage';
  19. function token(overrides: Partial<Record<string, unknown>> = {}) {
  20. return {
  21. id: 1,
  22. user_id: 7,
  23. name: 'Home Assistant',
  24. scope: 'camera_stream',
  25. lookup_prefix: 'abcd1234',
  26. created_at: '2026-04-01T10:00:00Z',
  27. expires_at: '2026-07-01T10:00:00Z',
  28. last_used_at: null,
  29. token: null,
  30. ...overrides,
  31. };
  32. }
  33. afterEach(() => {
  34. server.resetHandlers();
  35. vi.restoreAllMocks();
  36. });
  37. describe('CameraTokensPage', () => {
  38. it('renders the user\'s tokens', async () => {
  39. server.use(
  40. http.get('*/api/v1/auth/tokens', ({ request }) => {
  41. // No `user_id` query → caller's own tokens.
  42. const url = new URL(request.url);
  43. if (url.searchParams.has('user_id')) {
  44. return HttpResponse.json([]);
  45. }
  46. return HttpResponse.json([token({ id: 1, name: 'Home Assistant' })]);
  47. }),
  48. );
  49. render(<CameraTokensPage />);
  50. expect(await screen.findByText('Home Assistant')).toBeInTheDocument();
  51. expect(screen.getByText('abcd1234…')).toBeInTheDocument();
  52. });
  53. it('shows the empty state when the user has no tokens', async () => {
  54. server.use(
  55. http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
  56. http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
  57. http.get('*/api/v1/users/', () => HttpResponse.json([])),
  58. );
  59. render(<CameraTokensPage />);
  60. // Auth-disabled test environment treats user as admin → "No tokens yet"
  61. // renders once per panel (My tokens + admin view).
  62. await waitFor(() =>
  63. expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0),
  64. );
  65. });
  66. it('creates a token and displays the plaintext exactly once', async () => {
  67. let getCount = 0;
  68. server.use(
  69. http.get('*/api/v1/auth/tokens', () => {
  70. getCount += 1;
  71. // First load = empty, post-create reload = the new row WITHOUT the
  72. // plaintext (the listing API never returns it).
  73. return HttpResponse.json(
  74. getCount === 1 ? [] : [token({ id: 42, name: 'My Frigate', token: null })],
  75. );
  76. }),
  77. http.post('*/api/v1/auth/tokens', async ({ request }) => {
  78. const body = await request.json();
  79. expect(body).toMatchObject({
  80. name: 'My Frigate',
  81. expires_in_days: 90,
  82. scope: 'camera_stream',
  83. });
  84. return HttpResponse.json(
  85. token({ id: 42, name: 'My Frigate', token: 'bblt_abcd1234_secretsecretsecretsecretsecret' }),
  86. { status: 201 },
  87. );
  88. }),
  89. );
  90. const user = userEvent.setup();
  91. render(<CameraTokensPage />);
  92. await screen.findByText(/no tokens yet/i);
  93. await user.type(screen.getByLabelText(/token name/i), 'My Frigate');
  94. await user.click(screen.getByRole('button', { name: /^create$/i }));
  95. // Plaintext shown once in the modal.
  96. expect(
  97. await screen.findByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
  98. ).toBeInTheDocument();
  99. expect(screen.getByText(/only time this token will be visible/i)).toBeInTheDocument();
  100. await user.click(screen.getByRole('button', { name: /i've saved it/i }));
  101. // After dismissing, the listing reload shows the row but NOT the plaintext.
  102. expect(await screen.findByText('My Frigate')).toBeInTheDocument();
  103. expect(
  104. screen.queryByText('bblt_abcd1234_secretsecretsecretsecretsecret'),
  105. ).not.toBeInTheDocument();
  106. });
  107. it('clamps the days input to the 365-day policy cap', async () => {
  108. server.use(
  109. http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
  110. );
  111. const user = userEvent.setup();
  112. render(<CameraTokensPage />);
  113. await screen.findByText(/no tokens yet/i);
  114. const daysInput = screen.getByLabelText(/days until expiry/i) as HTMLInputElement;
  115. await user.clear(daysInput);
  116. await user.type(daysInput, '500');
  117. expect(Number(daysInput.value)).toBe(365);
  118. });
  119. it('revokes a token after confirming in the styled modal', async () => {
  120. let revoked = false;
  121. server.use(
  122. http.get('*/api/v1/auth/tokens', () =>
  123. HttpResponse.json(revoked ? [] : [token({ id: 9, name: 'kiosk' })]),
  124. ),
  125. // Auth-disabled test env treats the user as admin, so the page also
  126. // calls /tokens/all and /users/. Stub them out so the refresh path
  127. // doesn't try to hit unmocked endpoints.
  128. http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
  129. http.get('*/api/v1/users/', () => HttpResponse.json([])),
  130. http.delete('*/api/v1/auth/tokens/9', () => {
  131. revoked = true;
  132. return new HttpResponse(null, { status: 204 });
  133. }),
  134. );
  135. const user = userEvent.setup();
  136. render(<CameraTokensPage />);
  137. await screen.findByText('kiosk');
  138. // Open the confirm modal.
  139. await user.click(screen.getByRole('button', { name: /revoke/i }));
  140. // Modal shows the token name and a Cancel + Revoke pair.
  141. const dialog = await screen.findByRole('dialog');
  142. expect(dialog).toHaveTextContent(/kiosk/);
  143. // Confirm — scope to the dialog so we don't match the row's revoke
  144. // button still rendered in the table behind the modal.
  145. await user.click(within(dialog).getByRole('button', { name: /^revoke$/i }));
  146. await waitFor(() => {
  147. expect(screen.queryByText('kiosk')).not.toBeInTheDocument();
  148. // "No tokens yet" appears once for "My tokens" and (in admin mode) once
  149. // for the all-users panel — at least one match is sufficient.
  150. expect(screen.getAllByText(/no tokens yet/i).length).toBeGreaterThan(0);
  151. });
  152. });
  153. it('does not revoke when the user cancels in the modal', async () => {
  154. let revokeCalled = false;
  155. server.use(
  156. http.get('*/api/v1/auth/tokens', () =>
  157. HttpResponse.json([token({ id: 9, name: 'kiosk' })]),
  158. ),
  159. http.get('*/api/v1/auth/tokens/all', () => HttpResponse.json([])),
  160. http.get('*/api/v1/users/', () => HttpResponse.json([])),
  161. http.delete('*/api/v1/auth/tokens/9', () => {
  162. revokeCalled = true;
  163. return new HttpResponse(null, { status: 204 });
  164. }),
  165. );
  166. const user = userEvent.setup();
  167. render(<CameraTokensPage />);
  168. await screen.findByText('kiosk');
  169. await user.click(screen.getByRole('button', { name: /revoke/i }));
  170. const dialog = await screen.findByRole('dialog');
  171. await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
  172. // Modal closed, listing untouched, DELETE never sent.
  173. await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
  174. expect(screen.getByText('kiosk')).toBeInTheDocument();
  175. expect(revokeCalled).toBe(false);
  176. });
  177. });