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
30 changes: 30 additions & 0 deletions e2e/remote/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test as setup } from '@playwright/test';
import { chmodSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { basicAuthorization } from '../../src/utilities/basic-authorization';
import { account, allRoles, storageStatePath } from '../support/accounts';
import { origin } from '../support/env';

// Signs each account in once, through the login API like the application does
// (UTF-8 Basic header, X-Requested-With), and saves the resulting session as a
// browser storage state. Projects that need a signed-in browser load it, so no
// test types a password into a form just to get a session.
for (const role of allRoles()) {
setup(`sign in as the ${role} account`, async ({ request }) => {
const { username, password } = account(role);

const response = await request.post(`${origin()}/pulp/api/v3/login/`, {
headers: {
Authorization: basicAuthorization(username, password),
'X-Requested-With': 'XMLHttpRequest',
},
});
expect(response.status(), `sign in as the ${role} account`).toBe(201);

const path = storageStatePath(role);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
await request.storageState({ path });
// The file holds live session cookies: owner access only.
chmodSync(path, 0o600);
});
}
49 changes: 49 additions & 0 deletions e2e/remote/auth.teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test as teardown } from '@playwright/test';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { allRoles, storageStatePath } from '../support/accounts';
import { origin } from '../support/env';

// Ends the sessions the setup project created and deletes their storage state
// files, so no live session cookie stays on disk after a run. A session that
// is already gone (401) is the state this aims for.
for (const role of allRoles()) {
teardown(`sign out the ${role} session`, async ({ playwright }) => {
const path = storageStatePath(role);
if (!existsSync(path)) {
return;
}

try {
const state = JSON.parse(readFileSync(path, 'utf8')) as {
cookies: { name: string; value: string }[];
};
const csrf = state.cookies.find((c) => c.name === 'csrftoken')?.value;
const context = await playwright.request.newContext({
storageState: path,
});
try {
const response = await context.delete(
`${origin()}/pulp/api/v3/login/`,
{
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrf ?? '',
// a secure-origin CSRF check wants to know where the call comes from
Origin: origin(),
Referer: `${origin()}/`,
},
},
);
if (![204, 401].includes(response.status())) {
throw new Error(
`signing out the ${role} session answered ${response.status()}`,
);
}
} finally {
await context.dispose();
}
} finally {
rmSync(path, { force: true });
}
});
}
75 changes: 75 additions & 0 deletions e2e/remote/session-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
type BrowserContext,
type Page,
type Response,
expect,
} from '@playwright/test';
import { basicAuthorization } from '../../src/utilities/basic-authorization';
import { type Role, account } from '../support/accounts';
import { origin } from '../support/env';

// A browser context that holds no session at all, for tests that sign in or
// out. They never touch the storage state shared by the other tests, so
// ending a session here cannot break a later test.
export const signedOut = { cookies: [], origins: [] };

// Signs the role's account in inside the given context with the login API. The
// context's request client shares the cookie jar of its pages, so every page
// opened afterwards in that context is signed in.
export async function apiLogin(
context: BrowserContext,
role: Role = 'admin',
): Promise<void> {
const { username, password } = account(role);
const response = await context.request.post(
`${origin()}/pulp/api/v3/login/`,
{
headers: {
Authorization: basicAuthorization(username, password),
'X-Requested-With': 'XMLHttpRequest',
},
},
);
expect(response.status(), 'API sign in').toBe(201);
}

// True for a call to the login endpoint made with the given method.
export function isLoginCall(response: Response, method: string): boolean {
return (
response.request().method() === method &&
new URL(response.url()).pathname === '/pulp/api/v3/login/'
);
}

// Clicks Logout in the user menu and returns the answer to the DELETE the
// application sends.
export async function logOutThroughMenu(page: Page): Promise<Response> {
await page.locator('[data-cy=user-dropdown]').click();
const answer = page.waitForResponse((r) => isLoginCall(r, 'DELETE'));
await page.getByText('Logout', { exact: true }).click();
return answer;
}

export interface ApiCall {
method: string;
url: string;
hasAuthorization: boolean;
}

// Records every call to the API that the page sends, with whether the request
// carries an Authorization header. The headers are read at the moment the
// request is sent: reading them later can wait forever for a request that a
// navigation has already cancelled.
export function watchApiCalls(page: Page): { calls: ApiCall[] } {
const calls: ApiCall[] = [];
page.on('request', (request) => {
if (new URL(request.url()).pathname.startsWith('/pulp/api/')) {
calls.push({
method: request.method(),
url: request.url(),
hasAuthorization: 'authorization' in request.headers(),
});
}
});
return { calls };
}
210 changes: 210 additions & 0 deletions e2e/remote/session.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { expect, test } from '@playwright/test';
import { account } from '../support/accounts';
import { origin } from '../support/env';
import { submitLoginForm, title } from '../support/sign-in';
import {
apiLogin,
isLoginCall,
logOutThroughMenu,
signedOut,
watchApiCalls,
} from './session-helpers';

const loginForm = '#pf-login-username-id';
const userMenu = '[data-cy=user-dropdown]';
const loginMenuItem = '[data-cy=pulp-menu-item-Login]';

// The first test runs on the shared session of the admin account that the setup
// project saved. Every test after it starts from a context of its own: tests
// that sign in or out must never use, or end, the shared session.

test('a deep link to a list page survives a reload', async ({ page }) => {
await page.goto('file/repositories');
await expect(title(page, 'Repositories')).toBeVisible();

await page.reload();

await expect(page).toHaveURL(/\/ui\/file\/repositories\/?$/);
await expect(title(page, 'Repositories')).toBeVisible();
});

test.describe('sessions of their own', () => {
test.use({ storageState: signedOut });

test('signing in through the form keeps the session out of reach of scripts and stores no credential', async ({
page,
context,
}) => {
const { username, password } = account('admin');
const watched = watchApiCalls(page);

await page.goto('login');
await submitLoginForm(page, username, password);
await expect(title(page, 'Status')).toBeVisible();
await expect(page.locator(userMenu)).toBeVisible();
// more API traffic after the sign in, from another page
await page.goto('file/repositories');
await expect(title(page, 'Repositories')).toBeVisible();

const cookies = await context.cookies();
const session = cookies.find((c) => c.name === 'sessionid');
expect(session, 'a session cookie was set').toBeDefined();
expect(session?.httpOnly, 'session cookie is HttpOnly').toBe(true);
expect(session?.secure, 'session cookie is Secure').toBe(true);

const visible = await page.evaluate(() => document.cookie);
expect(visible).toContain('csrftoken');
expect(visible).not.toContain('sessionid');

const stored = await page.evaluate(() => ({
everything: JSON.stringify([
Object.entries(localStorage),
Object.entries(sessionStorage),
]),
localCredentials: localStorage.getItem('credentials'),
sessionCredentials: sessionStorage.getItem('credentials'),
}));
expect(stored.everything).not.toContain(password);
expect(stored.localCredentials).toBeNull();
expect(stored.sessionCredentials).toBeNull();

// Exactly one call carries the Basic header: the sign in itself.
const calls = watched.calls;
expect(
calls
.filter((c) => c.hasAuthorization)
.map((c) => `${c.method} ${c.url}`),
).toEqual([`POST ${origin()}/pulp/api/v3/login/`]);
});

test('credentials left in storage by an older version are removed unread and the login page shows', async ({
page,
}) => {
const watched = watchApiCalls(page);

await page.goto('login');
await page.evaluate(() => {
const legacy = JSON.stringify({ username: 'x', password: 'y' });
localStorage.setItem('credentials', legacy);
sessionStorage.setItem('credentials', legacy);
});

await page.reload();

await expect(page.locator(loginForm)).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => [
localStorage.getItem('credentials'),
sessionStorage.getItem('credentials'),
]),
)
.toEqual([null, null]);
const authorized = watched.calls.filter((c) => c.hasAuthorization);
expect(authorized, 'no request used the stored values').toEqual([]);
});

test('a refused sign in with Polish letters in the user name shows the server message and raises no script error', async ({
page,
request,
}) => {
// Letters outside Latin-1: a header built any other way than from UTF-8
// bytes throws in the browser before the request is even sent.
const name = 'Żółw-ćwik';
const wrongPassword = 'not-the-password';
const scriptErrors: Error[] = [];
page.on('pageerror', (error) => scriptErrors.push(error));

// What the server says to the same attempt, read through the API.
const direct = await request.get(`${origin()}/pulp/api/v3/login/`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Basic ${Buffer.from(`${name}:${wrongPassword}`, 'utf8').toString('base64')}`,
},
});
expect(direct.status()).toBe(401);
const detail = ((await direct.json()) as { detail?: string }).detail;
expect(detail, 'the server explains the refusal').toBeTruthy();

await page.goto('login');
await submitLoginForm(page, name, wrongPassword);

await expect(page.getByText(detail as string)).toBeVisible();
await expect(page.locator(loginForm)).toBeVisible();
expect(scriptErrors).toEqual([]);
});

test('a next address pointing at another site is not followed after signing in', async ({
page,
}) => {
const { username, password } = account('admin');
const wentElsewhere: string[] = [];
// Nothing may leave the deployment's origin; a navigation there is recorded
// and refused.
await page.route(/^https?:\/\/evil\.example\//, (route) => {
wentElsewhere.push(route.request().url());
return route.abort();
});

await page.goto('login/?next=//evil.example');
await submitLoginForm(page, username, password);
await expect(page.locator(userMenu)).toBeVisible();

expect(new URL(page.url()).origin).toBe(origin());
expect(wentElsewhere).toEqual([]);
});

test('logging out ends the server session', async ({ page, context }) => {
await apiLogin(context);
await page.goto('status');
await expect(page.locator(userMenu)).toBeVisible();

const answer = await logOutThroughMenu(page);
expect(answer.status()).toBe(204);

await expect(page.locator(loginMenuItem)).toBeVisible();
await expect(page.locator(userMenu)).toHaveCount(0);

const afterwards = await page.evaluate(
async () =>
(
await fetch('/pulp/api/v3/login/', {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
).status,
);
expect(afterwards).toBe(401);
});

test('logging out after the session already ended shows no error', async ({
page,
context,
}) => {
await apiLogin(context);
await page.goto('status');
await expect(page.locator(userMenu)).toBeVisible();

// End the session behind the page's back, the way another tab would.
const ended = await page.evaluate(async () => {
const token = /(?:^|; )csrftoken=([^;]*)/.exec(document.cookie)?.[1];
const response = await fetch('/pulp/api/v3/login/', {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': token ?? '',
},
});
return response.status;
});
expect(ended).toBe(204);

await page.locator(userMenu).click();
const answer = page.waitForResponse((r) => isLoginCall(r, 'DELETE'));
await page.getByText('Logout', { exact: true }).click();
await answer;

await expect(page.locator(loginMenuItem)).toBeVisible();
await expect(page.locator(userMenu)).toHaveCount(0);
await expect(page.locator('.pf-v5-c-alert')).toHaveCount(0);
});
});
16 changes: 16 additions & 0 deletions e2e/remote/session.viewer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
import { account } from '../support/accounts';
import { title } from '../support/sign-in';

// Runs on the shared session of the limited account that the setup project
// saved: the application opens signed in as that account.
test('the viewer account opens the application signed in as itself', async ({
page,
}) => {
await page.goto('status');

await expect(title(page, 'Status')).toBeVisible();
await expect(page.locator('[data-cy=user-dropdown]')).toContainText(
account('viewer').username,
);
});
Loading
Loading