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
118 changes: 118 additions & 0 deletions src/api/pulp.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
96 changes: 96 additions & 0 deletions src/app-routes.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p data-testid='location'>{`${pathname}${search}`}</p>
{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<void>((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(<Routed />, { 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(<Routed />, { 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(<Routed />, { route: PROTECTED_PATH });

expect(await screen.findByLabelText(/username/i)).toBeVisible();
expect(screen.getByTestId('location')).toHaveTextContent(
`/login/?next=${encodeURIComponent(PROTECTED_PATH)}`,
);
});
});
118 changes: 117 additions & 1 deletion src/containers/login/login.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid='location'>{`${pathname}${search}`}</p>;
}

function renderLogin(route = '/login') {
return renderWithProviders(
<Routes>
<Route path='/login' element={<PulpLoginPage />} />
<Route path='/status' element={<p>Status page</p>} />
<Route path='*' element={<LocationProbe />} />
</Routes>,
{ route },
);
}

async function signIn(
user: ReturnType<typeof renderLogin>['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';
Expand Down Expand Up @@ -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();
});
});
Loading
Loading