LoginPage.test.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887
  1. /**
  2. * Tests for the LoginPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { fireEvent, screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { LoginPage } from '../../pages/LoginPage';
  9. import { setAuthToken } from '../../api/client';
  10. import { http, HttpResponse } from 'msw';
  11. import { server } from '../mocks/server';
  12. // Spy on navigation so we can assert the #1889 redirect-away-if-authenticated
  13. // guard. importActual keeps BrowserRouter / useLocation / useSearchParams real.
  14. const mockNavigate = vi.fn();
  15. vi.mock('react-router-dom', async (importActual) => {
  16. const actual = await importActual<typeof import('react-router-dom')>();
  17. return { ...actual, useNavigate: () => mockNavigate };
  18. });
  19. describe('LoginPage', () => {
  20. beforeEach(() => {
  21. server.use(
  22. http.get('/api/v1/auth/status', () => {
  23. return HttpResponse.json({ auth_enabled: true, requires_setup: false });
  24. })
  25. );
  26. });
  27. describe('rendering', () => {
  28. it('renders the login form', async () => {
  29. render(<LoginPage />);
  30. await waitFor(() => {
  31. expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
  32. });
  33. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  34. expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
  35. expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
  36. });
  37. it('renders the sign in description', async () => {
  38. render(<LoginPage />);
  39. await waitFor(() => {
  40. expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
  41. });
  42. });
  43. });
  44. describe('form validation', () => {
  45. it('shows error when submitting empty form', async () => {
  46. const user = userEvent.setup();
  47. render(<LoginPage />);
  48. await waitFor(() => {
  49. expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
  50. });
  51. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  52. // The form has required fields, so HTML5 validation should prevent submission
  53. // or the component shows a toast
  54. });
  55. it('allows entering username and password', async () => {
  56. const user = userEvent.setup();
  57. render(<LoginPage />);
  58. await waitFor(() => {
  59. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  60. });
  61. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  62. await user.type(screen.getByLabelText(/Password/i), 'testpassword');
  63. expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
  64. expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
  65. });
  66. });
  67. describe('login flow', () => {
  68. it('submits login request with credentials', async () => {
  69. const user = userEvent.setup();
  70. let loginCalled = false;
  71. server.use(
  72. http.post('/api/v1/auth/login', async ({ request }) => {
  73. loginCalled = true;
  74. const body = await request.json() as { username: string; password: string };
  75. if (body.username === 'validuser' && body.password === 'validpass') {
  76. return HttpResponse.json({
  77. access_token: 'test-token',
  78. token_type: 'bearer',
  79. user: {
  80. id: 1,
  81. username: 'validuser',
  82. role: 'admin',
  83. is_active: true,
  84. created_at: new Date().toISOString(),
  85. },
  86. });
  87. }
  88. return HttpResponse.json(
  89. { detail: 'Incorrect username or password' },
  90. { status: 401 }
  91. );
  92. })
  93. );
  94. render(<LoginPage />);
  95. await waitFor(() => {
  96. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  97. });
  98. await user.type(screen.getByLabelText(/Username/i), 'validuser');
  99. await user.type(screen.getByLabelText(/Password/i), 'validpass');
  100. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  101. // Verify the login endpoint was called
  102. await waitFor(() => {
  103. expect(loginCalled).toBe(true);
  104. });
  105. });
  106. it('shows loading state during login', async () => {
  107. const user = userEvent.setup();
  108. let resolveLogin: () => void;
  109. const loginPromise = new Promise<void>(resolve => { resolveLogin = resolve; });
  110. // Slow login endpoint that we control
  111. server.use(
  112. http.post('/api/v1/auth/login', async () => {
  113. await loginPromise;
  114. return HttpResponse.json({
  115. access_token: 'test-token',
  116. token_type: 'bearer',
  117. user: {
  118. id: 1,
  119. username: 'testuser',
  120. role: 'admin',
  121. is_active: true,
  122. created_at: new Date().toISOString(),
  123. },
  124. });
  125. })
  126. );
  127. render(<LoginPage />);
  128. await waitFor(() => {
  129. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  130. });
  131. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  132. await user.type(screen.getByLabelText(/Password/i), 'testpass');
  133. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  134. // Check for loading state - button text should change to "Logging in..."
  135. await waitFor(() => {
  136. expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();
  137. });
  138. // Release the login request
  139. resolveLogin!();
  140. });
  141. });
  142. describe('2FA flow', () => {
  143. // Helper: login as a 2FA user and get to the 2FA step
  144. async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {
  145. const user = userEvent.setup();
  146. server.use(
  147. http.post('/api/v1/auth/login', () =>
  148. HttpResponse.json({
  149. requires_2fa: true,
  150. pre_auth_token: 'test-pre-auth-token',
  151. two_fa_methods: twoFAMethods,
  152. })
  153. )
  154. );
  155. render(<LoginPage />);
  156. await waitFor(() => {
  157. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  158. });
  159. await user.type(screen.getByLabelText(/Username/i), 'mfa-user');
  160. await user.type(screen.getByLabelText(/Password/i), 'mfa-password');
  161. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  162. return user;
  163. }
  164. it('shows 2FA step when login returns requires_2fa', async () => {
  165. await loginWith2FA();
  166. await waitFor(() => {
  167. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  168. });
  169. });
  170. it('shows code input on the 2FA step', async () => {
  171. await loginWith2FA();
  172. await waitFor(() => {
  173. // The code input field is rendered
  174. expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
  175. });
  176. });
  177. it('submits 2FA verify request with code and pre_auth_token', async () => {
  178. let verifyCalled = false;
  179. let verifyBody: unknown;
  180. server.use(
  181. http.post('/api/v1/auth/2fa/verify', async ({ request }) => {
  182. verifyCalled = true;
  183. verifyBody = await request.json();
  184. return HttpResponse.json({
  185. access_token: 'final-jwt',
  186. token_type: 'bearer',
  187. user: {
  188. id: 1,
  189. username: 'mfa-user',
  190. role: 'admin',
  191. is_active: true,
  192. created_at: new Date().toISOString(),
  193. },
  194. });
  195. })
  196. );
  197. const user = await loginWith2FA();
  198. await waitFor(() => {
  199. expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
  200. });
  201. await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
  202. await user.click(screen.getByRole('button', { name: /Verify/i }));
  203. await waitFor(() => {
  204. expect(verifyCalled).toBe(true);
  205. });
  206. expect(verifyBody).toMatchObject({
  207. pre_auth_token: 'test-pre-auth-token',
  208. code: '123456',
  209. method: 'totp',
  210. });
  211. });
  212. it('returns to credentials step when back button is clicked', async () => {
  213. await loginWith2FA();
  214. await waitFor(() => {
  215. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  216. });
  217. const user = userEvent.setup();
  218. const backButton = screen.getByRole('button', { name: /Back to login/i });
  219. await user.click(backButton);
  220. await waitFor(() => {
  221. expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
  222. });
  223. });
  224. it('shows method selector when multiple 2FA methods are available', async () => {
  225. await loginWith2FA(['totp', 'email', 'backup']);
  226. await waitFor(() => {
  227. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  228. });
  229. // Multiple method buttons should be visible
  230. expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();
  231. expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();
  232. expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();
  233. });
  234. it('does not show method selector with only one 2FA method', async () => {
  235. await loginWith2FA(['totp']);
  236. await waitFor(() => {
  237. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  238. });
  239. // Single-method: no method selector buttons
  240. expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();
  241. });
  242. it('shows send code button when email method is selected', async () => {
  243. const _user = await loginWith2FA(['email']);
  244. await waitFor(() => {
  245. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  246. });
  247. // For email method the "Send code" button should be shown
  248. await waitFor(() => {
  249. expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();
  250. });
  251. });
  252. });
  253. describe('Remember Me', () => {
  254. const mockUser = {
  255. id: 1,
  256. username: 'testuser',
  257. role: 'admin' as const,
  258. is_active: true,
  259. created_at: new Date().toISOString(),
  260. };
  261. beforeEach(() => {
  262. vi.mocked(localStorage.setItem).mockClear();
  263. sessionStorage.clear();
  264. server.use(
  265. http.post('/api/v1/auth/login', () =>
  266. HttpResponse.json({
  267. access_token: 'test-token',
  268. token_type: 'bearer',
  269. user: mockUser,
  270. })
  271. ),
  272. // Prevent checkAuthStatus from clearing the token when getCurrentUser is called
  273. http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser))
  274. );
  275. });
  276. it('renders Remember Me checkbox on credentials step', async () => {
  277. render(<LoginPage />);
  278. await waitFor(() => {
  279. expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument();
  280. });
  281. expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked();
  282. });
  283. it('does not persist token to localStorage when unchecked (default)', async () => {
  284. const user = userEvent.setup();
  285. render(<LoginPage />);
  286. await waitFor(() => {
  287. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  288. });
  289. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  290. await user.type(screen.getByLabelText(/Password/i), 'testpassword');
  291. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  292. // Token must be in sessionStorage (tab-only) but not in localStorage
  293. await waitFor(() => {
  294. expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
  295. expect(sessionStorage.getItem('auth_token')).toBe('test-token');
  296. });
  297. });
  298. it('persists token to localStorage when Remember Me is checked', async () => {
  299. const user = userEvent.setup();
  300. render(<LoginPage />);
  301. await waitFor(() => {
  302. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  303. });
  304. await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
  305. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  306. await user.type(screen.getByLabelText(/Password/i), 'testpassword');
  307. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  308. await waitFor(() => {
  309. expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token');
  310. });
  311. });
  312. it('carries Remember Me through 2FA verification', async () => {
  313. server.use(
  314. http.post('/api/v1/auth/login', () =>
  315. HttpResponse.json({
  316. requires_2fa: true,
  317. pre_auth_token: 'pre-token',
  318. two_fa_methods: ['totp'],
  319. })
  320. ),
  321. http.post('/api/v1/auth/2fa/verify', () =>
  322. HttpResponse.json({
  323. access_token: 'final-token',
  324. token_type: 'bearer',
  325. user: mockUser,
  326. })
  327. )
  328. );
  329. const user = userEvent.setup();
  330. render(<LoginPage />);
  331. await waitFor(() => {
  332. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  333. });
  334. // Check Remember Me before submitting credentials
  335. await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
  336. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  337. await user.type(screen.getByLabelText(/Password/i), 'testpassword');
  338. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  339. // Now on 2FA step — enter code and verify
  340. await waitFor(() => {
  341. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  342. });
  343. await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
  344. await user.click(screen.getByRole('button', { name: /Verify/i }));
  345. // Token must be persisted to localStorage because Remember Me was checked
  346. await waitFor(() => {
  347. expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token');
  348. });
  349. });
  350. it('checkbox is not shown on 2FA step', async () => {
  351. server.use(
  352. http.post('/api/v1/auth/login', () =>
  353. HttpResponse.json({
  354. requires_2fa: true,
  355. pre_auth_token: 'pre-token',
  356. two_fa_methods: ['totp'],
  357. })
  358. )
  359. );
  360. const user = userEvent.setup();
  361. render(<LoginPage />);
  362. await waitFor(() => {
  363. expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
  364. });
  365. await user.type(screen.getByLabelText(/Username/i), 'testuser');
  366. await user.type(screen.getByLabelText(/Password/i), 'testpassword');
  367. await user.click(screen.getByRole('button', { name: /Sign in/i }));
  368. await waitFor(() => {
  369. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  370. });
  371. expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument();
  372. });
  373. });
  374. describe('OIDC with Remember Me', () => {
  375. const mockUser = {
  376. id: 1,
  377. username: 'oidcuser',
  378. role: 'admin' as const,
  379. is_active: true,
  380. created_at: new Date().toISOString(),
  381. };
  382. beforeEach(() => {
  383. vi.mocked(localStorage.setItem).mockClear();
  384. sessionStorage.clear();
  385. });
  386. afterEach(() => {
  387. window.location.hash = '';
  388. window.history.pushState({}, '', '/login');
  389. sessionStorage.clear();
  390. });
  391. it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => {
  392. sessionStorage.setItem('auth_remember_me', '1');
  393. server.use(
  394. http.post('/api/v1/auth/oidc/exchange', () =>
  395. HttpResponse.json({
  396. access_token: 'oidc-token',
  397. token_type: 'bearer',
  398. user: mockUser,
  399. })
  400. )
  401. );
  402. window.location.hash = '#oidc_token=test-exchange-token';
  403. render(<LoginPage />);
  404. await waitFor(() => {
  405. expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token');
  406. });
  407. expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
  408. });
  409. it('carries Remember Me through OIDC + 2FA flow', async () => {
  410. sessionStorage.setItem('auth_remember_me', '1');
  411. server.use(
  412. http.post('/api/v1/auth/oidc/exchange', () =>
  413. HttpResponse.json({
  414. requires_2fa: true,
  415. pre_auth_token: 'oidc-pre-token',
  416. two_fa_methods: ['totp'],
  417. })
  418. ),
  419. http.post('/api/v1/auth/2fa/verify', () =>
  420. HttpResponse.json({
  421. access_token: 'oidc-2fa-token',
  422. token_type: 'bearer',
  423. user: mockUser,
  424. })
  425. )
  426. );
  427. window.location.hash = '#oidc_token=test-exchange-token';
  428. const user = userEvent.setup();
  429. render(<LoginPage />);
  430. await waitFor(() => {
  431. expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
  432. });
  433. // Flag consumed on mount — no stale value for future flows
  434. expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
  435. await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
  436. await user.click(screen.getByRole('button', { name: /Verify/i }));
  437. await waitFor(() => {
  438. expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token');
  439. });
  440. });
  441. it('cleans up auth_remember_me flag when OIDC returns an error', async () => {
  442. sessionStorage.setItem('auth_remember_me', '1');
  443. window.history.pushState({}, '', '/login?oidc_error=invalid_state');
  444. render(<LoginPage />);
  445. await waitFor(() => {
  446. expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
  447. });
  448. });
  449. it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => {
  450. // No auth_remember_me flag set — token must stay session-only
  451. server.use(
  452. http.post('/api/v1/auth/oidc/exchange', () =>
  453. HttpResponse.json({
  454. access_token: 'oidc-session-token',
  455. token_type: 'bearer',
  456. user: mockUser,
  457. })
  458. )
  459. );
  460. window.location.hash = '#oidc_token=test-exchange-token';
  461. render(<LoginPage />);
  462. await waitFor(() => {
  463. expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token');
  464. });
  465. expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
  466. });
  467. it('shows error toast when OIDC exchange returns unexpected response shape', async () => {
  468. sessionStorage.setItem('auth_remember_me', '1');
  469. server.use(
  470. // Response is missing both access_token and requires_2fa — hits the else branch
  471. http.post('/api/v1/auth/oidc/exchange', () =>
  472. HttpResponse.json({ token_type: 'bearer' })
  473. )
  474. );
  475. window.location.hash = '#oidc_token=test-exchange-token';
  476. render(<LoginPage />);
  477. await waitFor(() => {
  478. expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument();
  479. });
  480. // Flag must still be cleaned up even on malformed response
  481. expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
  482. });
  483. it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => {
  484. server.use(
  485. http.get('/api/v1/auth/oidc/providers', () =>
  486. HttpResponse.json([
  487. {
  488. id: 42,
  489. name: 'FlagIdP',
  490. issuer_url: 'https://flag.test',
  491. client_id: 'c',
  492. is_enabled: true,
  493. icon_url: null,
  494. has_icon: false,
  495. email_claim: 'email',
  496. require_email_verified: true,
  497. auto_create_users: false,
  498. auto_link_existing_accounts: false,
  499. },
  500. ])
  501. ),
  502. http.get('/api/v1/auth/oidc/authorize/42', () =>
  503. HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' })
  504. )
  505. );
  506. const user = userEvent.setup();
  507. render(<LoginPage />);
  508. // Tick "Remember Me"
  509. await waitFor(() => {
  510. expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument();
  511. });
  512. await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
  513. // Wait for OIDC provider button to appear
  514. await waitFor(() => {
  515. expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument();
  516. });
  517. // Stub window.location so the OIDC redirect doesn't actually navigate.
  518. // Keep href valid so relative fetch URLs resolve correctly.
  519. Object.defineProperty(window, 'location', {
  520. writable: true,
  521. value: { ...window.location, href: 'http://localhost:3000/' },
  522. });
  523. await user.click(screen.getByRole('button', { name: /FlagIdP/i }));
  524. await waitFor(() => {
  525. expect(sessionStorage.getItem('auth_remember_me')).toBe('1');
  526. });
  527. });
  528. });
  529. // #1333: icon proxy — login page renders <img src> from /icon endpoint
  530. // rather than the upstream icon_url, so the strict img-src CSP holds.
  531. describe('OIDC icon proxy (#1333)', () => {
  532. beforeEach(() => {
  533. server.use(
  534. http.get('/api/v1/auth/status', () =>
  535. HttpResponse.json({ auth_enabled: true, setup_required: false })
  536. ),
  537. );
  538. });
  539. it('renders provider icon via the proxy URL when has_icon is true', async () => {
  540. server.use(
  541. http.get('/api/v1/auth/oidc/providers', () =>
  542. HttpResponse.json([
  543. {
  544. id: 7,
  545. name: 'IconProv',
  546. issuer_url: 'https://idp.test',
  547. client_id: 'c',
  548. is_enabled: true,
  549. icon_url: 'https://idp.test/icon.png',
  550. email_claim: 'email',
  551. require_email_verified: true,
  552. auto_create_users: false,
  553. auto_link_existing_accounts: false,
  554. has_icon: true,
  555. },
  556. ])
  557. ),
  558. );
  559. render(<LoginPage />);
  560. const button = await screen.findByRole('button', { name: /IconProv/i });
  561. const img = button.querySelector('img');
  562. expect(img).not.toBeNull();
  563. // Same-origin path — never the upstream icon_url. This is the entire
  564. // point of the proxy: keep img-src strictly 'self' data: blob:.
  565. expect(img!.getAttribute('src')).toBe('/api/v1/auth/oidc/providers/7/icon');
  566. });
  567. it('renders shield fallback when has_icon is false', async () => {
  568. server.use(
  569. http.get('/api/v1/auth/oidc/providers', () =>
  570. HttpResponse.json([
  571. {
  572. id: 8,
  573. name: 'NoIconProv',
  574. issuer_url: 'https://idp.test',
  575. client_id: 'c',
  576. is_enabled: true,
  577. icon_url: null,
  578. email_claim: 'email',
  579. require_email_verified: true,
  580. auto_create_users: false,
  581. auto_link_existing_accounts: false,
  582. has_icon: false,
  583. },
  584. ])
  585. ),
  586. );
  587. render(<LoginPage />);
  588. const button = await screen.findByRole('button', { name: /NoIconProv/i });
  589. expect(button.querySelector('img')).toBeNull();
  590. });
  591. it('renders mixed has_icon providers without crash', async () => {
  592. // N12 — multiple providers on the login page with a mix of
  593. // has_icon=true / false. No React-keys-collision warning, both
  594. // branches render correctly side by side.
  595. server.use(
  596. http.get('/api/v1/auth/oidc/providers', () =>
  597. HttpResponse.json([
  598. {
  599. id: 10,
  600. name: 'WithIcon',
  601. issuer_url: 'https://idp.test',
  602. client_id: 'c1',
  603. is_enabled: true,
  604. icon_url: 'https://idp.test/icon.png',
  605. has_icon: true,
  606. email_claim: 'email',
  607. require_email_verified: true,
  608. auto_create_users: false,
  609. auto_link_existing_accounts: false,
  610. },
  611. {
  612. id: 11,
  613. name: 'NoIcon',
  614. issuer_url: 'https://idp.test',
  615. client_id: 'c2',
  616. is_enabled: true,
  617. icon_url: null,
  618. has_icon: false,
  619. email_claim: 'email',
  620. require_email_verified: true,
  621. auto_create_users: false,
  622. auto_link_existing_accounts: false,
  623. },
  624. ])
  625. ),
  626. );
  627. render(<LoginPage />);
  628. const withIconBtn = await screen.findByRole('button', { name: /WithIcon/i });
  629. const noIconBtn = await screen.findByRole('button', { name: /NoIcon/i });
  630. expect(withIconBtn.querySelector('img')).not.toBeNull();
  631. expect(noIconBtn.querySelector('img')).toBeNull();
  632. });
  633. it('swaps in shield fallback when the icon fails to load', async () => {
  634. // I3 (#1333 review): the LoginPage must not show the browser
  635. // broken-image glyph to anonymous users. onError must fall back to
  636. // the Shield icon.
  637. server.use(
  638. http.get('/api/v1/auth/oidc/providers', () =>
  639. HttpResponse.json([
  640. {
  641. id: 9,
  642. name: 'FlakyIcon',
  643. issuer_url: 'https://idp.test',
  644. client_id: 'c',
  645. is_enabled: true,
  646. icon_url: 'https://idp.test/icon.png',
  647. email_claim: 'email',
  648. require_email_verified: true,
  649. auto_create_users: false,
  650. auto_link_existing_accounts: false,
  651. has_icon: true,
  652. },
  653. ])
  654. ),
  655. );
  656. render(<LoginPage />);
  657. const img = (await screen.findByRole('button', { name: /FlakyIcon/i })).querySelector('img');
  658. expect(img).not.toBeNull();
  659. // Fire the image's onError — jsdom doesn't fetch network resources
  660. // so we simulate the failure directly.
  661. fireEvent.error(img!);
  662. // After error, no more <img> in the button; Shield fallback rendered.
  663. await waitFor(() => {
  664. const button = screen.getByRole('button', { name: /FlakyIcon/i });
  665. expect(button.querySelector('img')).toBeNull();
  666. });
  667. });
  668. it('keeps each provider button\'s iconFailed state independent', async () => {
  669. // The OIDCProviderButton sub-component exists specifically so each
  670. // provider owns its own iconFailed state. If a future refactor hoists
  671. // useState into the parent loop, an error on provider A would also
  672. // hide provider B's icon — exactly the regression this test catches.
  673. server.use(
  674. http.get('/api/v1/auth/oidc/providers', () =>
  675. HttpResponse.json([
  676. {
  677. id: 21,
  678. name: 'AlphaIdP',
  679. issuer_url: 'https://a.test',
  680. client_id: 'a',
  681. is_enabled: true,
  682. icon_url: 'https://a.test/icon.png',
  683. email_claim: 'email',
  684. require_email_verified: true,
  685. auto_create_users: false,
  686. auto_link_existing_accounts: false,
  687. has_icon: true,
  688. },
  689. {
  690. id: 22,
  691. name: 'BetaIdP',
  692. issuer_url: 'https://b.test',
  693. client_id: 'b',
  694. is_enabled: true,
  695. icon_url: 'https://b.test/icon.png',
  696. email_claim: 'email',
  697. require_email_verified: true,
  698. auto_create_users: false,
  699. auto_link_existing_accounts: false,
  700. has_icon: true,
  701. },
  702. ])
  703. ),
  704. );
  705. render(<LoginPage />);
  706. const alphaImg = (await screen.findByRole('button', { name: /AlphaIdP/i })).querySelector('img');
  707. const betaImg = (await screen.findByRole('button', { name: /BetaIdP/i })).querySelector('img');
  708. expect(alphaImg).not.toBeNull();
  709. expect(betaImg).not.toBeNull();
  710. fireEvent.error(alphaImg!);
  711. // Alpha's icon swaps to the Shield fallback…
  712. await waitFor(() => {
  713. expect(screen.getByRole('button', { name: /AlphaIdP/i }).querySelector('img')).toBeNull();
  714. });
  715. // …but Beta's icon stays put. If state leaks to the parent, this fails.
  716. expect(screen.getByRole('button', { name: /BetaIdP/i }).querySelector('img')).not.toBeNull();
  717. });
  718. });
  719. // #1889: an already-authenticated visit to /login must redirect to the app,
  720. // not render the credentials form. Browsers autocomplete the origin to its
  721. // most-visited path (/login), so live sessions kept landing on the form and
  722. // it looked like Bambuddy "never stays logged in".
  723. describe('authenticated redirect (#1889)', () => {
  724. const mockUser = {
  725. id: 1,
  726. username: 'testuser',
  727. role: 'admin' as const,
  728. is_active: true,
  729. created_at: new Date().toISOString(),
  730. };
  731. afterEach(() => {
  732. setAuthToken(null);
  733. });
  734. it('redirects an already-authenticated visitor away from /login', async () => {
  735. // A live session: token present, /api/v1/auth/me answers 200.
  736. setAuthToken('valid-token', 'session');
  737. server.use(http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser)));
  738. mockNavigate.mockClear();
  739. render(<LoginPage />);
  740. await waitFor(() => {
  741. expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
  742. });
  743. });
  744. it('does not redirect an unauthenticated visitor', async () => {
  745. // No token → checkAuthStatus leaves user null; the form must stay put.
  746. server.use(http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser)));
  747. mockNavigate.mockClear();
  748. render(<LoginPage />);
  749. await waitFor(() => {
  750. expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
  751. });
  752. expect(mockNavigate).not.toHaveBeenCalledWith('/', { replace: true });
  753. });
  754. });
  755. });