Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions web/mock-server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/backend/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
108 changes: 108 additions & 0 deletions web/src/pages/LoginPage/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="login-form" />,
}));

vi.mock('../../components/AuthPageBackground', () => ({
AuthPageBackground: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));

import { useUserLocals } from '../../lib/hooks/useUserLocals';

const mockUseUserLocals = useUserLocals as ReturnType<typeof vi.fn>;

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(
<QueryClientProvider client={queryClient}>
<CookiesProvider>
<MemoryRouter initialEntries={['/login']}>
<LoginPage />
</MemoryRouter>
</CookiesProvider>
</QueryClientProvider>
);
}

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();
});
});
});
12 changes: 12 additions & 0 deletions web/src/pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthPageBackground>
<LoginForm />
Expand Down
36 changes: 36 additions & 0 deletions web/tests/login-loop.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});