SecurityStatusCard.test.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /**
  2. * Tests for SecurityStatusCard — verifies the five severity levels
  3. * (green / yellow / orange / red / grey) are rendered for the right
  4. * combinations of key_source, legacy_plaintext_rows, and decryption_broken.
  5. */
  6. import { describe, it, expect, beforeEach, vi } from 'vitest';
  7. import userEvent from '@testing-library/user-event';
  8. import { screen, waitFor } from '@testing-library/react';
  9. import { render } from '../utils';
  10. import { SecurityStatusCard } from '../../components/SecurityStatusCard';
  11. import { http, HttpResponse } from 'msw';
  12. import { server } from '../mocks/server';
  13. import type { EncryptionStatus } from '../../api/client';
  14. const STATUS_URL = '/api/v1/auth/encryption-status';
  15. function makeStatus(overrides: Partial<EncryptionStatus> = {}): EncryptionStatus {
  16. return {
  17. key_configured: true,
  18. key_source: 'env',
  19. legacy_plaintext_rows: { oidc_providers: 0, user_totp: 0 },
  20. encrypted_rows: { oidc_providers: 0, user_totp: 0 },
  21. decryption_broken: false,
  22. migration_error_count: 0,
  23. ...overrides,
  24. };
  25. }
  26. describe('SecurityStatusCard', () => {
  27. beforeEach(() => {
  28. server.use(http.get(STATUS_URL, () => HttpResponse.json(makeStatus())));
  29. });
  30. // E2: loading state
  31. it('shows loading indicator while query is pending', () => {
  32. // Delay the response so the component renders in loading state first.
  33. server.use(http.get(STATUS_URL, async () => {
  34. await new Promise(() => { /* never resolves — keeps loading state */ });
  35. return HttpResponse.json(makeStatus());
  36. }));
  37. render(<SecurityStatusCard />);
  38. expect(screen.getByTestId('encryption-loading')).toBeInTheDocument();
  39. });
  40. // E2: error state
  41. it('shows error state when API returns 500', async () => {
  42. server.use(http.get(STATUS_URL, () => new HttpResponse(null, { status: 500 })));
  43. render(<SecurityStatusCard />);
  44. await waitFor(() => {
  45. expect(screen.getByTestId('encryption-error')).toBeInTheDocument();
  46. });
  47. });
  48. // E1: data-testid on status div
  49. it('renders encryption-status testid after data loads', async () => {
  50. render(<SecurityStatusCard />);
  51. await waitFor(() => {
  52. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  53. });
  54. });
  55. it('renders enabled state with env source', async () => {
  56. server.use(
  57. http.get(STATUS_URL, () =>
  58. HttpResponse.json(makeStatus({ key_source: 'env', encrypted_rows: { oidc_providers: 2, user_totp: 5 } })),
  59. ),
  60. );
  61. render(<SecurityStatusCard />);
  62. await waitFor(() => {
  63. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  64. });
  65. expect(screen.getByText(/MFA_ENCRYPTION_KEY environment variable/i)).toBeInTheDocument();
  66. });
  67. it('renders enabled state with file source', async () => {
  68. server.use(
  69. http.get(STATUS_URL, () => HttpResponse.json(makeStatus({ key_source: 'file' }))),
  70. );
  71. render(<SecurityStatusCard />);
  72. await waitFor(() => {
  73. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  74. });
  75. expect(screen.getByText(/key loaded from data directory/i)).toBeInTheDocument();
  76. });
  77. it('renders orange backup hint when key_source is generated', async () => {
  78. server.use(
  79. http.get(STATUS_URL, () =>
  80. HttpResponse.json(makeStatus({ key_source: 'generated' })),
  81. ),
  82. );
  83. render(<SecurityStatusCard />);
  84. await waitFor(() => {
  85. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  86. });
  87. expect(screen.getByText(/included in local backup ZIPs/i)).toBeInTheDocument();
  88. expect(screen.getByText(/DATA_DIR\/\.mfa_encryption_key/i)).toBeInTheDocument();
  89. });
  90. // E4: concurrent warnings — generated key + legacy rows
  91. it('shows backup hint AND legacy-rows warning when key is generated and legacy rows exist', async () => {
  92. server.use(
  93. http.get(STATUS_URL, () =>
  94. HttpResponse.json(
  95. makeStatus({
  96. key_source: 'generated',
  97. legacy_plaintext_rows: { oidc_providers: 2, user_totp: 0 },
  98. }),
  99. ),
  100. ),
  101. );
  102. render(<SecurityStatusCard />);
  103. await waitFor(() => {
  104. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  105. });
  106. // Primary status: backup hint
  107. expect(screen.getByText(/included in local backup ZIPs/i)).toBeInTheDocument();
  108. // Secondary: legacy-rows warning
  109. expect(screen.getByTestId('encryption-legacy-warning')).toBeInTheDocument();
  110. });
  111. it('renders yellow warning when legacy plaintext rows exist', async () => {
  112. server.use(
  113. http.get(STATUS_URL, () =>
  114. HttpResponse.json(
  115. makeStatus({
  116. key_source: 'env',
  117. legacy_plaintext_rows: { oidc_providers: 3, user_totp: 0 },
  118. }),
  119. ),
  120. ),
  121. );
  122. render(<SecurityStatusCard />);
  123. await waitFor(() => {
  124. expect(screen.getByText(/3 legacy plaintext row/i)).toBeInTheDocument();
  125. });
  126. });
  127. it('renders red decryption-broken state when key missing but encrypted rows exist', async () => {
  128. server.use(
  129. http.get(STATUS_URL, () =>
  130. HttpResponse.json(
  131. makeStatus({
  132. key_configured: false,
  133. key_source: 'none',
  134. encrypted_rows: { oidc_providers: 2, user_totp: 1 },
  135. decryption_broken: true,
  136. }),
  137. ),
  138. ),
  139. );
  140. render(<SecurityStatusCard />);
  141. await waitFor(() => {
  142. expect(screen.getByText(/Encryption key missing/i)).toBeInTheDocument();
  143. });
  144. expect(screen.getByText(/3 encrypted record/i)).toBeInTheDocument();
  145. });
  146. it('renders disabled (not configured) state', async () => {
  147. server.use(
  148. http.get(STATUS_URL, () =>
  149. HttpResponse.json(makeStatus({ key_configured: false, key_source: 'none' })),
  150. ),
  151. );
  152. render(<SecurityStatusCard />);
  153. await waitFor(() => {
  154. expect(screen.getByText(/At-rest encryption not configured/i)).toBeInTheDocument();
  155. });
  156. });
  157. // S5: manual retry button recovers from error state
  158. it('renders a Retry button in the error state and recovers when clicked', async () => {
  159. // First call → 500, every subsequent call → 200.
  160. let calls = 0;
  161. server.use(
  162. http.get(STATUS_URL, () => {
  163. calls += 1;
  164. if (calls === 1) {
  165. return new HttpResponse(null, { status: 500 });
  166. }
  167. return HttpResponse.json(makeStatus({ key_source: 'env' }));
  168. }),
  169. );
  170. render(<SecurityStatusCard />);
  171. // Error state with retry button.
  172. const retryButton = await screen.findByTestId('encryption-retry-button');
  173. expect(retryButton).toBeInTheDocument();
  174. expect(screen.getByTestId('encryption-error')).toBeInTheDocument();
  175. // Click Retry → next response is 200, status card renders.
  176. const user = userEvent.setup();
  177. await user.click(retryButton);
  178. await waitFor(() => {
  179. expect(screen.getByTestId('encryption-status')).toBeInTheDocument();
  180. });
  181. });
  182. // S5: bounded polling — after >3 consecutive errors, refetchInterval returns
  183. // false so the card stops hammering a failing endpoint until the user clicks
  184. // the Retry button or reloads the page.
  185. it('polling stops after 3 consecutive errors', async () => {
  186. // Persistent 500 from the API.
  187. let calls = 0;
  188. server.use(
  189. http.get(STATUS_URL, () => {
  190. calls += 1;
  191. return new HttpResponse(null, { status: 500 });
  192. }),
  193. );
  194. vi.useFakeTimers({ shouldAdvanceTime: true });
  195. try {
  196. render(<SecurityStatusCard />);
  197. // First fetch errors immediately — wait for the error UI.
  198. await screen.findByTestId('encryption-error');
  199. // The first failure is `fetchFailureCount=1` → next refetch in 5s.
  200. // 5s + 10s + 15s = 30s walks through failures 1→2→3. After failures
  201. // exceed 3 the function returns false; advancing further must NOT
  202. // produce additional calls.
  203. const callsBeforeBackoff = calls;
  204. // Step the clock far past the entire backoff sequence.
  205. vi.advanceTimersByTime(45_000);
  206. await waitFor(() => {
  207. expect(calls).toBeGreaterThanOrEqual(callsBeforeBackoff);
  208. });
  209. const callsAfterFirstWalk = calls;
  210. // From here, polling must be quiescent — advancing another minute
  211. // must add at most a small bounded number of calls (ideally 0).
  212. vi.advanceTimersByTime(60_000);
  213. // Allow react-query's microtasks to flush.
  214. await Promise.resolve();
  215. // Bounded retry: after the third failure the interval returns false,
  216. // so additional polling calls in the second minute must be 0.
  217. expect(calls - callsAfterFirstWalk).toBe(0);
  218. } finally {
  219. vi.useRealTimers();
  220. }
  221. });
  222. // S5: B2 migration_error_count surfaces a yellow warning banner.
  223. it('renders a migration error warning when migration_error_count > 0', async () => {
  224. server.use(
  225. http.get(STATUS_URL, () =>
  226. HttpResponse.json(makeStatus({ migration_error_count: 3 })),
  227. ),
  228. );
  229. render(<SecurityStatusCard />);
  230. await waitFor(() => {
  231. expect(screen.getByTestId('encryption-migration-warning')).toBeInTheDocument();
  232. });
  233. expect(screen.getByText(/3 legacy row/i)).toBeInTheDocument();
  234. });
  235. });