diff --git a/web/mock-server/server.js b/web/mock-server/server.js index f1c6b18ed..7d9e29e39 100644 --- a/web/mock-server/server.js +++ b/web/mock-server/server.js @@ -159,6 +159,15 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(mockApiSpec)); // API Routes app.get('/api/users/debug/locals', (req, res) => { + const cookieHeader = req.headers.cookie ?? ''; + const tokenMatch = cookieHeader.match(/(?:^|;\s*)token=([^;]+)/); + const tokenValue = tokenMatch ? tokenMatch[1] : null; + + if (tokenValue === 'stale-jwt-token') { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + res.json({ locals: { owner: 1, diff --git a/web/src/lib/backend/api.ts b/web/src/lib/backend/api.ts index 4af4ba710..9f709ec17 100644 --- a/web/src/lib/backend/api.ts +++ b/web/src/lib/backend/api.ts @@ -85,7 +85,7 @@ export const get = async ( if (!response.ok) { if (response.status === UNAUTHORIZED) { redirectToLogin(); - return undefined; + throw new Error('Unauthorized'); } if (response.status === NOT_FOUND) { throw new Error( diff --git a/web/src/pages/LoginPage/LoginPage.test.tsx b/web/src/pages/LoginPage/LoginPage.test.tsx new file mode 100644 index 000000000..dd8905912 --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.test.tsx @@ -0,0 +1,108 @@ +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { CookiesProvider } from 'react-cookie'; +import Cookies from 'universal-cookie'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { LoginPage } from './LoginPage'; + +vi.mock('../../lib/hooks/useUserLocals', () => ({ + useUserLocals: vi.fn(), +})); + +vi.mock('./components/LoginForm', () => ({ + default: () =>
, +})); + +vi.mock('../../components/AuthPageBackground', () => ({ + AuthPageBackground: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { useUserLocals } from '../../lib/hooks/useUserLocals'; + +const mockUseUserLocals = useUserLocals as ReturnType; + +function renderLoginPage(cookieValue?: string) { + const cookies = new Cookies(); + if (cookieValue != null) { + cookies.set('token', cookieValue, { path: '/' }); + } + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + + + + + + ); +} + +describe('LoginPage', () => { + beforeEach(() => { + const cookies = new Cookies(); + cookies.remove('token', { path: '/' }); + vi.clearAllMocks(); + }); + + it('renders the login form when there is no cookie', () => { + mockUseUserLocals.mockReturnValue({ data: undefined, isLoading: false, isError: false, error: null }); + const { getByTestId } = renderLoginPage(); + expect(getByTestId('login-form')).toBeInTheDocument(); + }); + + it('renders the login form when the cookie is valid', () => { + mockUseUserLocals.mockReturnValue({ + data: { user: { id: 1 }, locals: {} }, + isLoading: false, + isError: false, + error: null, + }); + const { getByTestId } = renderLoginPage('valid-token'); + expect(getByTestId('login-form')).toBeInTheDocument(); + }); + + it('clears the token cookie when the cookie is present and userLocals fetch errors', async () => { + mockUseUserLocals.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Unauthorized'), + }); + + const cookies = new Cookies(); + cookies.set('token', 'stale-jwt-token', { path: '/' }); + + renderLoginPage('stale-jwt-token'); + + await waitFor(() => { + const tokenCookie = cookies.get('token'); + expect(tokenCookie).toBeUndefined(); + }); + }); + + it('does not clear the token cookie when no cookie is present and fetch errors', async () => { + mockUseUserLocals.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Unauthorized'), + }); + + const cookies = new Cookies(); + + renderLoginPage(); + + await waitFor(() => { + expect(cookies.get('token')).toBeUndefined(); + }); + }); +}); diff --git a/web/src/pages/LoginPage/LoginPage.tsx b/web/src/pages/LoginPage/LoginPage.tsx index 62ea873c5..121c57e54 100644 --- a/web/src/pages/LoginPage/LoginPage.tsx +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -1,7 +1,19 @@ +import { useEffect } from 'react'; +import { useCookies } from 'react-cookie'; import LoginForm from './components/LoginForm'; import { AuthPageBackground } from '../../components/AuthPageBackground'; +import { useUserLocals } from '../../lib/hooks/useUserLocals'; export function LoginPage() { + const [cookies, , removeCookie] = useCookies(['token']); + const { isError } = useUserLocals(); + + useEffect(() => { + if (cookies.token && isError) { + removeCookie('token', { path: '/' }); + } + }, [cookies.token, isError, removeCookie]); + return ( diff --git a/web/tests/login-loop.spec.ts b/web/tests/login-loop.spec.ts new file mode 100644 index 000000000..b407a8803 --- /dev/null +++ b/web/tests/login-loop.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +test.describe('login-loop prevention', () => { + test('clears a stale token cookie and renders the login form without looping', async ({ + page, + context, + }) => { + await context.addCookies([ + { + name: 'token', + value: 'stale-jwt-token', + domain: 'localhost', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'Lax', + }, + ]); + + await page.goto('/login'); + + await expect(page.locator('h1')).toContainText('Log in', { + timeout: 10_000, + }); + + await expect + .poll( + async () => { + const cookies = await context.cookies(); + return cookies.find((c) => c.name === 'token'); + }, + { timeout: 15_000, intervals: [500] } + ) + .toBeUndefined(); + }); +});