| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834 |
- /**
- * Tests for the LoginPage component.
- */
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
- import { fireEvent, screen, waitFor } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { render } from '../utils';
- import { LoginPage } from '../../pages/LoginPage';
- import { http, HttpResponse } from 'msw';
- import { server } from '../mocks/server';
- describe('LoginPage', () => {
- beforeEach(() => {
- server.use(
- http.get('/api/v1/auth/status', () => {
- return HttpResponse.json({ auth_enabled: true, requires_setup: false });
- })
- );
- });
- describe('rendering', () => {
- it('renders the login form', async () => {
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
- });
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
- });
- it('renders the sign in description', async () => {
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
- });
- });
- });
- describe('form validation', () => {
- it('shows error when submitting empty form', async () => {
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
- });
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- // The form has required fields, so HTML5 validation should prevent submission
- // or the component shows a toast
- });
- it('allows entering username and password', async () => {
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpassword');
- expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
- expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
- });
- });
- describe('login flow', () => {
- it('submits login request with credentials', async () => {
- const user = userEvent.setup();
- let loginCalled = false;
- server.use(
- http.post('/api/v1/auth/login', async ({ request }) => {
- loginCalled = true;
- const body = await request.json() as { username: string; password: string };
- if (body.username === 'validuser' && body.password === 'validpass') {
- return HttpResponse.json({
- access_token: 'test-token',
- token_type: 'bearer',
- user: {
- id: 1,
- username: 'validuser',
- role: 'admin',
- is_active: true,
- created_at: new Date().toISOString(),
- },
- });
- }
- return HttpResponse.json(
- { detail: 'Incorrect username or password' },
- { status: 401 }
- );
- })
- );
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'validuser');
- await user.type(screen.getByLabelText(/Password/i), 'validpass');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- // Verify the login endpoint was called
- await waitFor(() => {
- expect(loginCalled).toBe(true);
- });
- });
- it('shows loading state during login', async () => {
- const user = userEvent.setup();
- let resolveLogin: () => void;
- const loginPromise = new Promise<void>(resolve => { resolveLogin = resolve; });
- // Slow login endpoint that we control
- server.use(
- http.post('/api/v1/auth/login', async () => {
- await loginPromise;
- return HttpResponse.json({
- access_token: 'test-token',
- token_type: 'bearer',
- user: {
- id: 1,
- username: 'testuser',
- role: 'admin',
- is_active: true,
- created_at: new Date().toISOString(),
- },
- });
- })
- );
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpass');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- // Check for loading state - button text should change to "Logging in..."
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();
- });
- // Release the login request
- resolveLogin!();
- });
- });
- describe('2FA flow', () => {
- // Helper: login as a 2FA user and get to the 2FA step
- async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {
- const user = userEvent.setup();
- server.use(
- http.post('/api/v1/auth/login', () =>
- HttpResponse.json({
- requires_2fa: true,
- pre_auth_token: 'test-pre-auth-token',
- two_fa_methods: twoFAMethods,
- })
- )
- );
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'mfa-user');
- await user.type(screen.getByLabelText(/Password/i), 'mfa-password');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- return user;
- }
- it('shows 2FA step when login returns requires_2fa', async () => {
- await loginWith2FA();
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- });
- it('shows code input on the 2FA step', async () => {
- await loginWith2FA();
- await waitFor(() => {
- // The code input field is rendered
- expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
- });
- });
- it('submits 2FA verify request with code and pre_auth_token', async () => {
- let verifyCalled = false;
- let verifyBody: unknown;
- server.use(
- http.post('/api/v1/auth/2fa/verify', async ({ request }) => {
- verifyCalled = true;
- verifyBody = await request.json();
- return HttpResponse.json({
- access_token: 'final-jwt',
- token_type: 'bearer',
- user: {
- id: 1,
- username: 'mfa-user',
- role: 'admin',
- is_active: true,
- created_at: new Date().toISOString(),
- },
- });
- })
- );
- const user = await loginWith2FA();
- await waitFor(() => {
- expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
- });
- await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
- await user.click(screen.getByRole('button', { name: /Verify/i }));
- await waitFor(() => {
- expect(verifyCalled).toBe(true);
- });
- expect(verifyBody).toMatchObject({
- pre_auth_token: 'test-pre-auth-token',
- code: '123456',
- method: 'totp',
- });
- });
- it('returns to credentials step when back button is clicked', async () => {
- await loginWith2FA();
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- const user = userEvent.setup();
- const backButton = screen.getByRole('button', { name: /Back to login/i });
- await user.click(backButton);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
- });
- });
- it('shows method selector when multiple 2FA methods are available', async () => {
- await loginWith2FA(['totp', 'email', 'backup']);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- // Multiple method buttons should be visible
- expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();
- });
- it('does not show method selector with only one 2FA method', async () => {
- await loginWith2FA(['totp']);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- // Single-method: no method selector buttons
- expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();
- });
- it('shows send code button when email method is selected', async () => {
- const _user = await loginWith2FA(['email']);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- // For email method the "Send code" button should be shown
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();
- });
- });
- });
- describe('Remember Me', () => {
- const mockUser = {
- id: 1,
- username: 'testuser',
- role: 'admin' as const,
- is_active: true,
- created_at: new Date().toISOString(),
- };
- beforeEach(() => {
- vi.mocked(localStorage.setItem).mockClear();
- sessionStorage.clear();
- server.use(
- http.post('/api/v1/auth/login', () =>
- HttpResponse.json({
- access_token: 'test-token',
- token_type: 'bearer',
- user: mockUser,
- })
- ),
- // Prevent checkAuthStatus from clearing the token when getCurrentUser is called
- http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser))
- );
- });
- it('renders Remember Me checkbox on credentials step', async () => {
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument();
- });
- expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked();
- });
- it('does not persist token to localStorage when unchecked (default)', async () => {
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpassword');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- // Token must be in sessionStorage (tab-only) but not in localStorage
- await waitFor(() => {
- expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
- expect(sessionStorage.getItem('auth_token')).toBe('test-token');
- });
- });
- it('persists token to localStorage when Remember Me is checked', async () => {
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpassword');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- await waitFor(() => {
- expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token');
- });
- });
- it('carries Remember Me through 2FA verification', async () => {
- server.use(
- http.post('/api/v1/auth/login', () =>
- HttpResponse.json({
- requires_2fa: true,
- pre_auth_token: 'pre-token',
- two_fa_methods: ['totp'],
- })
- ),
- http.post('/api/v1/auth/2fa/verify', () =>
- HttpResponse.json({
- access_token: 'final-token',
- token_type: 'bearer',
- user: mockUser,
- })
- )
- );
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- // Check Remember Me before submitting credentials
- await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpassword');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- // Now on 2FA step — enter code and verify
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
- await user.click(screen.getByRole('button', { name: /Verify/i }));
- // Token must be persisted to localStorage because Remember Me was checked
- await waitFor(() => {
- expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token');
- });
- });
- it('checkbox is not shown on 2FA step', async () => {
- server.use(
- http.post('/api/v1/auth/login', () =>
- HttpResponse.json({
- requires_2fa: true,
- pre_auth_token: 'pre-token',
- two_fa_methods: ['totp'],
- })
- )
- );
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
- });
- await user.type(screen.getByLabelText(/Username/i), 'testuser');
- await user.type(screen.getByLabelText(/Password/i), 'testpassword');
- await user.click(screen.getByRole('button', { name: /Sign in/i }));
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument();
- });
- });
- describe('OIDC with Remember Me', () => {
- const mockUser = {
- id: 1,
- username: 'oidcuser',
- role: 'admin' as const,
- is_active: true,
- created_at: new Date().toISOString(),
- };
- beforeEach(() => {
- vi.mocked(localStorage.setItem).mockClear();
- sessionStorage.clear();
- });
- afterEach(() => {
- window.location.hash = '';
- window.history.pushState({}, '', '/login');
- sessionStorage.clear();
- });
- it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => {
- sessionStorage.setItem('auth_remember_me', '1');
- server.use(
- http.post('/api/v1/auth/oidc/exchange', () =>
- HttpResponse.json({
- access_token: 'oidc-token',
- token_type: 'bearer',
- user: mockUser,
- })
- )
- );
- window.location.hash = '#oidc_token=test-exchange-token';
- render(<LoginPage />);
- await waitFor(() => {
- expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token');
- });
- expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
- });
- it('carries Remember Me through OIDC + 2FA flow', async () => {
- sessionStorage.setItem('auth_remember_me', '1');
- server.use(
- http.post('/api/v1/auth/oidc/exchange', () =>
- HttpResponse.json({
- requires_2fa: true,
- pre_auth_token: 'oidc-pre-token',
- two_fa_methods: ['totp'],
- })
- ),
- http.post('/api/v1/auth/2fa/verify', () =>
- HttpResponse.json({
- access_token: 'oidc-2fa-token',
- token_type: 'bearer',
- user: mockUser,
- })
- )
- );
- window.location.hash = '#oidc_token=test-exchange-token';
- const user = userEvent.setup();
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
- });
- // Flag consumed on mount — no stale value for future flows
- expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
- await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
- await user.click(screen.getByRole('button', { name: /Verify/i }));
- await waitFor(() => {
- expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token');
- });
- });
- it('cleans up auth_remember_me flag when OIDC returns an error', async () => {
- sessionStorage.setItem('auth_remember_me', '1');
- window.history.pushState({}, '', '/login?oidc_error=invalid_state');
- render(<LoginPage />);
- await waitFor(() => {
- expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
- });
- });
- it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => {
- // No auth_remember_me flag set — token must stay session-only
- server.use(
- http.post('/api/v1/auth/oidc/exchange', () =>
- HttpResponse.json({
- access_token: 'oidc-session-token',
- token_type: 'bearer',
- user: mockUser,
- })
- )
- );
- window.location.hash = '#oidc_token=test-exchange-token';
- render(<LoginPage />);
- await waitFor(() => {
- expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token');
- });
- expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
- });
- it('shows error toast when OIDC exchange returns unexpected response shape', async () => {
- sessionStorage.setItem('auth_remember_me', '1');
- server.use(
- // Response is missing both access_token and requires_2fa — hits the else branch
- http.post('/api/v1/auth/oidc/exchange', () =>
- HttpResponse.json({ token_type: 'bearer' })
- )
- );
- window.location.hash = '#oidc_token=test-exchange-token';
- render(<LoginPage />);
- await waitFor(() => {
- expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument();
- });
- // Flag must still be cleaned up even on malformed response
- expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
- });
- it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => {
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 42,
- name: 'FlagIdP',
- issuer_url: 'https://flag.test',
- client_id: 'c',
- is_enabled: true,
- icon_url: null,
- has_icon: false,
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- },
- ])
- ),
- http.get('/api/v1/auth/oidc/authorize/42', () =>
- HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' })
- )
- );
- const user = userEvent.setup();
- render(<LoginPage />);
- // Tick "Remember Me"
- await waitFor(() => {
- expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument();
- });
- await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
- // Wait for OIDC provider button to appear
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument();
- });
- // Stub window.location so the OIDC redirect doesn't actually navigate.
- // Keep href valid so relative fetch URLs resolve correctly.
- Object.defineProperty(window, 'location', {
- writable: true,
- value: { ...window.location, href: 'http://localhost:3000/' },
- });
- await user.click(screen.getByRole('button', { name: /FlagIdP/i }));
- await waitFor(() => {
- expect(sessionStorage.getItem('auth_remember_me')).toBe('1');
- });
- });
- });
- // #1333: icon proxy — login page renders <img src> from /icon endpoint
- // rather than the upstream icon_url, so the strict img-src CSP holds.
- describe('OIDC icon proxy (#1333)', () => {
- beforeEach(() => {
- server.use(
- http.get('/api/v1/auth/status', () =>
- HttpResponse.json({ auth_enabled: true, setup_required: false })
- ),
- );
- });
- it('renders provider icon via the proxy URL when has_icon is true', async () => {
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 7,
- name: 'IconProv',
- issuer_url: 'https://idp.test',
- client_id: 'c',
- is_enabled: true,
- icon_url: 'https://idp.test/icon.png',
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- has_icon: true,
- },
- ])
- ),
- );
- render(<LoginPage />);
- const button = await screen.findByRole('button', { name: /IconProv/i });
- const img = button.querySelector('img');
- expect(img).not.toBeNull();
- // Same-origin path — never the upstream icon_url. This is the entire
- // point of the proxy: keep img-src strictly 'self' data: blob:.
- expect(img!.getAttribute('src')).toBe('/api/v1/auth/oidc/providers/7/icon');
- });
- it('renders shield fallback when has_icon is false', async () => {
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 8,
- name: 'NoIconProv',
- issuer_url: 'https://idp.test',
- client_id: 'c',
- is_enabled: true,
- icon_url: null,
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- has_icon: false,
- },
- ])
- ),
- );
- render(<LoginPage />);
- const button = await screen.findByRole('button', { name: /NoIconProv/i });
- expect(button.querySelector('img')).toBeNull();
- });
- it('renders mixed has_icon providers without crash', async () => {
- // N12 — multiple providers on the login page with a mix of
- // has_icon=true / false. No React-keys-collision warning, both
- // branches render correctly side by side.
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 10,
- name: 'WithIcon',
- issuer_url: 'https://idp.test',
- client_id: 'c1',
- is_enabled: true,
- icon_url: 'https://idp.test/icon.png',
- has_icon: true,
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- },
- {
- id: 11,
- name: 'NoIcon',
- issuer_url: 'https://idp.test',
- client_id: 'c2',
- is_enabled: true,
- icon_url: null,
- has_icon: false,
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- },
- ])
- ),
- );
- render(<LoginPage />);
- const withIconBtn = await screen.findByRole('button', { name: /WithIcon/i });
- const noIconBtn = await screen.findByRole('button', { name: /NoIcon/i });
- expect(withIconBtn.querySelector('img')).not.toBeNull();
- expect(noIconBtn.querySelector('img')).toBeNull();
- });
- it('swaps in shield fallback when the icon fails to load', async () => {
- // I3 (#1333 review): the LoginPage must not show the browser
- // broken-image glyph to anonymous users. onError must fall back to
- // the Shield icon.
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 9,
- name: 'FlakyIcon',
- issuer_url: 'https://idp.test',
- client_id: 'c',
- is_enabled: true,
- icon_url: 'https://idp.test/icon.png',
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- has_icon: true,
- },
- ])
- ),
- );
- render(<LoginPage />);
- const img = (await screen.findByRole('button', { name: /FlakyIcon/i })).querySelector('img');
- expect(img).not.toBeNull();
- // Fire the image's onError — jsdom doesn't fetch network resources
- // so we simulate the failure directly.
- fireEvent.error(img!);
- // After error, no more <img> in the button; Shield fallback rendered.
- await waitFor(() => {
- const button = screen.getByRole('button', { name: /FlakyIcon/i });
- expect(button.querySelector('img')).toBeNull();
- });
- });
- it('keeps each provider button\'s iconFailed state independent', async () => {
- // The OIDCProviderButton sub-component exists specifically so each
- // provider owns its own iconFailed state. If a future refactor hoists
- // useState into the parent loop, an error on provider A would also
- // hide provider B's icon — exactly the regression this test catches.
- server.use(
- http.get('/api/v1/auth/oidc/providers', () =>
- HttpResponse.json([
- {
- id: 21,
- name: 'AlphaIdP',
- issuer_url: 'https://a.test',
- client_id: 'a',
- is_enabled: true,
- icon_url: 'https://a.test/icon.png',
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- has_icon: true,
- },
- {
- id: 22,
- name: 'BetaIdP',
- issuer_url: 'https://b.test',
- client_id: 'b',
- is_enabled: true,
- icon_url: 'https://b.test/icon.png',
- email_claim: 'email',
- require_email_verified: true,
- auto_create_users: false,
- auto_link_existing_accounts: false,
- has_icon: true,
- },
- ])
- ),
- );
- render(<LoginPage />);
- const alphaImg = (await screen.findByRole('button', { name: /AlphaIdP/i })).querySelector('img');
- const betaImg = (await screen.findByRole('button', { name: /BetaIdP/i })).querySelector('img');
- expect(alphaImg).not.toBeNull();
- expect(betaImg).not.toBeNull();
- fireEvent.error(alphaImg!);
- // Alpha's icon swaps to the Shield fallback…
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /AlphaIdP/i }).querySelector('img')).toBeNull();
- });
- // …but Beta's icon stays put. If state leaks to the parent, this fails.
- expect(screen.getByRole('button', { name: /BetaIdP/i }).querySelector('img')).not.toBeNull();
- });
- });
- });
|