From 9a795a2c7b374326f2ff23fdcf4198e51b79c3a8 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Thu, 29 Oct 2026 17:26:06 +0100 Subject: [PATCH 1/2] Add login page regression tests Cover a refused sign-in for a user name with Polish letters, an unreachable server, the absence of a remember option and a plain-text storage warning, and the next-path handling after sign-in. --- src/containers/login/login.test.tsx | 118 +++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/src/containers/login/login.test.tsx b/src/containers/login/login.test.tsx index 184bae7b..5d993350 100644 --- a/src/containers/login/login.test.tsx +++ b/src/containers/login/login.test.tsx @@ -1,21 +1,49 @@ import { screen, waitFor } from '@testing-library/react'; import { HttpResponse, http } from 'msw'; -import { Route, Routes } from 'react-router'; +import { Route, Routes, useLocation } from 'react-router'; import { describe, expect, it } from 'vitest'; import { renderWithProviders } from 'src/test-utils/render'; import { server } from 'src/test-utils/server'; import PulpLoginPage from './login'; +// Prints where the router ended up, for routes the test does not render. +function LocationProbe() { + const { pathname, search } = useLocation(); + return

{`${pathname}${search}`}

; +} + function renderLogin(route = '/login') { return renderWithProviders( } /> Status page

} /> + } />
, { route }, ); } +async function signIn( + user: ReturnType['user'], + name: string, + secret: string, +) { + await user.type(await screen.findByLabelText(/username/i), name); + await user.type(screen.getByLabelText(/^password/i), secret); + await user.click(screen.getByRole('button', { name: 'Log in' })); +} + +function acceptLogin() { + server.use( + http.post('*/pulp/api/v3/login/', () => + HttpResponse.json( + { username: 'zoe', pulp_href: '/pulp/api/v3/users/1/' }, + { status: 201 }, + ), + ), + ); +} + // Two letters outside Latin-1, so a header encoded any other way than UTF-8 is // told apart from a correct one. const password = 'p\u0105\u0142-secret'; @@ -92,4 +120,92 @@ describe('login page', () => { ); expect(screen.queryByText('Status page')).not.toBeInTheDocument(); }); + + it('a refused login for a user name with Polish letters shows the server message and raises no error', async () => { + // Polish letters outside Latin-1 in the user name: a header built any other + // way than from UTF-8 bytes throws before the request is sent. + const name = '\u017b\u00f3\u0142w-\u0107wik'; + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + + const seen: { authorization: string | null }[] = []; + server.use( + http.post('*/pulp/api/v3/login/', ({ request }) => { + seen.push({ authorization: request.headers.get('authorization') }); + return HttpResponse.json( + { detail: 'Invalid username/password.' }, + { status: 401 }, + ); + }), + ); + + try { + const { user } = renderLogin(); + await signIn(user, name, 'wrong'); + + await waitFor(() => + expect(screen.getByText(/Invalid username\/password/)).toBeVisible(), + ); + // Give a stray rejection the chance to surface before asserting. + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + process.off('unhandledRejection', onUnhandled); + } + + expect(unhandled).toEqual([]); + expect(seen).toHaveLength(1); + expect( + Buffer.from( + (seen[0]?.authorization ?? '').slice('Basic '.length), + 'base64', + ).toString('utf8'), + ).toBe(`${name}:wrong`); + expect(screen.queryByText('Status page')).not.toBeInTheDocument(); + }); + + it('an unreachable server shows that the server could not be reached', async () => { + server.use(http.post('*/pulp/api/v3/login/', () => HttpResponse.error())); + + const { user } = renderLogin(); + await signIn(user, 'zoe', 'secret'); + + await waitFor(() => + expect(screen.getByText(/could not be reached/i)).toBeVisible(), + ); + expect(screen.queryByText('Status page')).not.toBeInTheDocument(); + }); + + it('the page offers no remember option and no plain-text storage warning', async () => { + renderLogin(); + + await screen.findByLabelText(/username/i); + + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByText(/remember/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/plain.?text/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/stored/i)).not.toBeInTheDocument(); + }); + + it('a protocol-relative next target is ignored and sign-in ends on the status page', async () => { + acceptLogin(); + + const { user } = renderLogin('/login/?next=//evil.example'); + await signIn(user, 'zoe', 'secret'); + + await screen.findByText('Status page'); + expect(screen.queryByTestId('location')).not.toBeInTheDocument(); + }); + + it('a same-origin next path is honoured after sign-in', async () => { + acceptLogin(); + + const { user } = renderLogin('/login/?next=/file/repositories/'); + await signIn(user, 'zoe', 'secret'); + + expect(await screen.findByTestId('location')).toHaveTextContent( + '/file/repositories/', + ); + expect(screen.queryByText('Status page')).not.toBeInTheDocument(); + }); }); From db1ed1165626130b449dcbda9671f22f01e140f2 Mon Sep 17 00:00:00 2001 From: Danilo Lakovic Date: Thu, 29 Oct 2026 17:29:31 +0100 Subject: [PATCH 2/2] Add session start, logout, route guard and API client regression tests Pin the legacy credentials purge on start, the logout outcomes, the route guard waiting for the session check, and the per-request CSRF header, cookie and X-Requested-With handling of the API client. --- src/api/pulp.test.ts | 118 ++++++++++++++++++++++++ src/app-routes.test.tsx | 96 ++++++++++++++++++++ src/user-context.test.tsx | 186 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 src/api/pulp.test.ts create mode 100644 src/app-routes.test.tsx create mode 100644 src/user-context.test.tsx diff --git a/src/api/pulp.test.ts b/src/api/pulp.test.ts new file mode 100644 index 00000000..2f41c2bc --- /dev/null +++ b/src/api/pulp.test.ts @@ -0,0 +1,118 @@ +import { HttpResponse, http } from 'msw'; +import { afterEach, describe, expect, it } from 'vitest'; +import { server } from 'src/test-utils/server'; +import { PulpAPI } from './pulp'; + +class ExampleAPI extends PulpAPI { + create(name: string) { + return this.http.post('examples/', { name }); + } + + remove() { + return this.http.delete('examples/1/'); + } + + read() { + return this.http.get('examples/'); + } +} + +interface IRequestFacts { + method: string; + requestedWith: string | null; + csrfToken: string | null; + authorization: string | null; + credentials: RequestCredentials; +} + +// Records what the server saw of every request to the example endpoints. +function recordRequests() { + const seen: IRequestFacts[] = []; + const record = ({ request }: { request: Request }) => { + seen.push({ + method: request.method, + requestedWith: request.headers.get('x-requested-with'), + csrfToken: request.headers.get('x-csrftoken'), + authorization: request.headers.get('authorization'), + credentials: request.credentials, + }); + return HttpResponse.json({}); + }; + server.use( + http.get('*/pulp/api/v3/examples/', record), + http.post('*/pulp/api/v3/examples/', record), + http.delete('*/pulp/api/v3/examples/1/', record), + ); + return seen; +} + +function setCsrfCookie(value: string) { + document.cookie = `csrftoken=${value}; path=/`; +} + +describe('api client', () => { + afterEach(() => { + document.cookie = + 'csrftoken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + }); + + it('every request carries X-Requested-With and sends cookies', async () => { + const seen = recordRequests(); + const api = new ExampleAPI(); + + await api.read(); + await api.create('one'); + await api.remove(); + + expect(seen.map((request) => request.method)).toEqual([ + 'GET', + 'POST', + 'DELETE', + ]); + for (const request of seen) { + expect(request.requestedWith).toBe('XMLHttpRequest'); + expect(request.credentials).toBe('include'); + } + }); + + it('an unsafe request carries the CSRF token from the cookie as it is at request time', async () => { + const seen = recordRequests(); + const api = new ExampleAPI(); + + setCsrfCookie('token-a'); + await api.create('one'); + setCsrfCookie('token-b'); + await api.create('two'); + await api.remove(); + + expect(seen.map((request) => request.csrfToken)).toEqual([ + 'token-a', + 'token-b', + 'token-b', + ]); + }); + + it('no CSRF header is sent while the cookie is absent', async () => { + const seen = recordRequests(); + + await new ExampleAPI().create('one'); + + expect(seen).toHaveLength(1); + expect(seen[0]?.csrfToken).toBeNull(); + }); + + it('no request carries an Authorization header unless the caller sets one', async () => { + const seen = recordRequests(); + const api = new ExampleAPI(); + + setCsrfCookie('token-a'); + await api.read(); + await api.create('one'); + await api.remove(); + + expect(seen).toHaveLength(3); + for (const request of seen) { + expect(request.authorization).toBeNull(); + } + }); +}); diff --git a/src/app-routes.test.tsx b/src/app-routes.test.tsx new file mode 100644 index 00000000..0c12f899 --- /dev/null +++ b/src/app-routes.test.tsx @@ -0,0 +1,96 @@ +import { screen, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { useLocation, useRoutes } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import { listOf } from 'src/test-utils/pulp'; +import { renderWithProviders } from 'src/test-utils/render'; +import { server } from 'src/test-utils/server'; +import { dataRoutes } from './app-routes'; + +// The guarded routes of the application, without the lazily loaded layout +// around them. Entries without a path (the index redirect) are left out. +const guardedRoutes = (dataRoutes[0]?.children?.[0]?.children ?? []).filter( + (route) => 'path' in route, +); + +function Routed() { + const { pathname, search } = useLocation(); + return ( + <> +

{`${pathname}${search}`}

+ {useRoutes(guardedRoutes)} + + ); +} + +const PROTECTED_PATH = '/signature-keys'; + +// Holds the answer to GET login/ until the test releases it. +function holdSessionCheck(answer: () => Response) { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + server.use( + http.get('*/pulp/api/v3/login/', async () => { + await gate; + return answer(); + }), + ); + return release; +} + +describe('route guard', () => { + it('a protected route shows a loading state, not the login page, while the session check runs', async () => { + const release = holdSessionCheck(() => + HttpResponse.json({ detail: 'Not authenticated.' }, { status: 401 }), + ); + + renderWithProviders(, { route: PROTECTED_PATH }); + + // Nothing has redirected while the check is pending. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.getByTestId('location')).toHaveTextContent( + new RegExp(`^${PROTECTED_PATH}$`), + ); + expect(screen.queryByLabelText(/username/i)).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + release(); + + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent(/^\/login/), + ); + }); + + it('a protected route keeps its deep link when the session check answers 200', async () => { + const { handler } = listOf('/pulp/api/v3/signing-services/', []); + server.use(handler); + const release = holdSessionCheck(() => + HttpResponse.json({ + username: 'zoe', + pulp_href: '/pulp/api/v3/users/1/', + }), + ); + + renderWithProviders(, { route: PROTECTED_PATH }); + release(); + + expect( + await screen.findByRole('heading', { name: 'Signature keys' }), + ).toBeVisible(); + expect(screen.getByTestId('location')).toHaveTextContent( + new RegExp(`^${PROTECTED_PATH}$`), + ); + expect(screen.queryByLabelText(/username/i)).not.toBeInTheDocument(); + }); + + it('a protected route redirects to the login page only when the session check answers 401', async () => { + renderWithProviders(, { route: PROTECTED_PATH }); + + expect(await screen.findByLabelText(/username/i)).toBeVisible(); + expect(screen.getByTestId('location')).toHaveTextContent( + `/login/?next=${encodeURIComponent(PROTECTED_PATH)}`, + ); + }); +}); diff --git a/src/user-context.test.tsx b/src/user-context.test.tsx new file mode 100644 index 00000000..b62fd414 --- /dev/null +++ b/src/user-context.test.tsx @@ -0,0 +1,186 @@ +import { screen, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from 'src/test-utils/render'; +import { server } from 'src/test-utils/server'; +import { useUserContext } from './user-context'; + +const LOGIN_URL = '*/pulp/api/v3/login/'; + +// Shows the session state and offers a logout button that reports how the +// logout promise ended. +function SessionProbe() { + const { status, user, logout } = useUserContext(); + const [outcome, setOutcome] = useState('none'); + + return ( + <> +

{status}

+

{user?.username ?? 'nobody'}

+

{outcome}

+ + + ); +} + +// Starts the provider with the server reporting someone signed in. +async function renderSignedIn() { + server.use( + http.get(LOGIN_URL, () => + HttpResponse.json({ + username: 'zoe', + pulp_href: '/pulp/api/v3/users/1/', + }), + ), + ); + const rendered = renderWithProviders(); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'), + ); + return rendered; +} + +describe('user context', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('on start it removes the legacy credentials keys from both storages without reading them and asks who is signed in', async () => { + window.localStorage.setItem('credentials', 'legacy-local-value'); + window.sessionStorage.setItem('credentials', 'legacy-session-value'); + const removed = [window.localStorage, window.sessionStorage].map( + (storage) => vi.spyOn(storage, 'removeItem'), + ); + + const asked: string[] = []; + server.use( + http.get(LOGIN_URL, ({ request }) => { + asked.push(`${request.method} ${new URL(request.url).pathname}`); + return HttpResponse.json( + { detail: 'Not authenticated.' }, + { status: 401 }, + ); + }), + ); + + renderWithProviders(); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous'), + ); + + expect(window.localStorage.getItem('credentials')).toBeNull(); + expect(window.sessionStorage.getItem('credentials')).toBeNull(); + for (const spy of removed) { + expect(spy).toHaveBeenCalledWith('credentials'); + } + expect(asked).toEqual(['GET /pulp/api/v3/login/']); + }); + + it('on start it never reads the legacy credentials key', async () => { + window.localStorage.setItem('credentials', 'legacy-local-value'); + window.sessionStorage.setItem('credentials', 'legacy-session-value'); + // The spies sit on the storage objects themselves, because the + // environment does not route calls through the Storage prototype. + const reads = [window.localStorage, window.sessionStorage].map((storage) => + vi.spyOn(storage, 'getItem'), + ); + + renderWithProviders(); + await waitFor(() => + expect(screen.getByTestId('status')).not.toHaveTextContent('checking'), + ); + + for (const spy of reads) { + expect(spy.mock.calls.map(([key]) => key)).not.toContain('credentials'); + } + }); + + it('a signed-in answer from the server makes the user authenticated', async () => { + await renderSignedIn(); + + expect(screen.getByTestId('user')).toHaveTextContent('zoe'); + }); + + it('logout answered 204 ends signed out', async () => { + const { user } = await renderSignedIn(); + server.use( + http.delete(LOGIN_URL, () => new HttpResponse(null, { status: 204 })), + ); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous'), + ); + expect(screen.getByTestId('outcome')).toHaveTextContent('resolved'); + expect(screen.getByTestId('user')).toHaveTextContent('nobody'); + }); + + it('logout answered 401 ends signed out without a rejection', async () => { + const { user } = await renderSignedIn(); + server.use( + http.delete(LOGIN_URL, () => + HttpResponse.json({ detail: 'Not authenticated.' }, { status: 401 }), + ), + ); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('anonymous'), + ); + expect(screen.getByTestId('outcome')).toHaveTextContent('resolved'); + }); + + it('logout answered 403 keeps the user signed in and rejects with the server reason', async () => { + const { user } = await renderSignedIn(); + server.use( + http.delete(LOGIN_URL, () => + HttpResponse.json( + { detail: 'CSRF Failed: token missing.' }, + { status: 403 }, + ), + ), + ); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.getByTestId('outcome')).toHaveTextContent( + 'rejected: CSRF Failed: token missing.', + ), + ); + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('user')).toHaveTextContent('zoe'); + }); + + it('logout answered 500 keeps the user signed in and rejects with the server reason', async () => { + const { user } = await renderSignedIn(); + server.use( + http.delete(LOGIN_URL, () => + HttpResponse.json({ detail: 'Backend unavailable.' }, { status: 500 }), + ), + ); + + await user.click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.getByTestId('outcome')).toHaveTextContent( + 'rejected: Backend unavailable.', + ), + ); + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + }); +});