LoginPage.test.tsx 37 KB

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