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