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/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(); + }); }); 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'); + }); +});