diff --git a/e2e/remote/auth.setup.ts b/e2e/remote/auth.setup.ts new file mode 100644 index 00000000..1d265184 --- /dev/null +++ b/e2e/remote/auth.setup.ts @@ -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); + }); +} diff --git a/e2e/remote/auth.teardown.ts b/e2e/remote/auth.teardown.ts new file mode 100644 index 00000000..2b90142e --- /dev/null +++ b/e2e/remote/auth.teardown.ts @@ -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 }); + } + }); +} diff --git a/e2e/remote/session-helpers.ts b/e2e/remote/session-helpers.ts new file mode 100644 index 00000000..7825a2ef --- /dev/null +++ b/e2e/remote/session-helpers.ts @@ -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 { + 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 { + 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 }; +} diff --git a/e2e/remote/session.spec.ts b/e2e/remote/session.spec.ts new file mode 100644 index 00000000..c44948b4 --- /dev/null +++ b/e2e/remote/session.spec.ts @@ -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); + }); +}); diff --git a/e2e/remote/session.viewer.spec.ts b/e2e/remote/session.viewer.spec.ts new file mode 100644 index 00000000..f943df62 --- /dev/null +++ b/e2e/remote/session.viewer.spec.ts @@ -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, + ); +}); diff --git a/e2e/remote/two-tabs.spec.ts b/e2e/remote/two-tabs.spec.ts new file mode 100644 index 00000000..5369bad4 --- /dev/null +++ b/e2e/remote/two-tabs.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { title } from '../support/sign-in'; +import { apiLogin, logOutThroughMenu, signedOut } from './session-helpers'; + +// One browser context holds one cookie jar, so every tab of it shares a single +// server session. It is a context of its own with a fresh sign in, because +// logging out ends the session for everything that uses it. +test.use({ storageState: signedOut }); + +test('after logging out in one tab, the other tab is refused by the server and recovers on reload', async ({ + context, +}) => { + await apiLogin(context); + + const tabA = await context.newPage(); + const tabB = await context.newPage(); + await tabA.goto('file/repositories'); + await tabB.goto('file/repositories'); + await expect(title(tabA, 'Repositories')).toBeVisible(); + await expect(title(tabB, 'Repositories')).toBeVisible(); + + const answers: number[] = []; + tabB.on('response', (response) => { + if (new URL(response.url()).pathname.startsWith('/pulp/api/')) { + answers.push(response.status()); + } + }); + + await logOutThroughMenu(tabA); + + // Tab B is not reloaded: it moves on to another list page inside the app. + await tabB.locator('[data-cy="pulp-menu-item-Task management"]').click(); + + await expect + .poll(() => answers, { message: 'the server refuses tab B' }) + .toContain(401); + + // Nothing may crash the page. What it shows may be the request's error or + // the login page, both are a correct way to say the session is gone. + await expect(tabB.getByText('The stack trace is:')).toHaveCount(0); + await expect( + tabB.locator('.pf-v5-c-alert, #pf-login-username-id').first(), + ).toBeVisible(); + + await tabB.reload(); + await expect(tabB.locator('#pf-login-username-id')).toBeVisible(); +}); diff --git a/e2e/support/accounts.ts b/e2e/support/accounts.ts new file mode 100644 index 00000000..3112a90d --- /dev/null +++ b/e2e/support/accounts.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// Accounts of a remote run. They are read from the JSON file named by +// PULP_UI_E2E_ACCOUNTS ({"admin": {"username", "password"}, "viewer": {...}}) +// and nowhere else; the file and its contents are never logged or copied. + +export type Role = 'admin' | 'viewer'; + +export interface Credentials { + username: string; + password: string; +} + +const roles: Role[] = ['admin', 'viewer']; + +function missing(detail: string): Error { + return new Error( + `PULP_UI_E2E_ACCOUNTS ${detail}. Remote end-to-end tests read their ` + + 'accounts from a JSON file given by this variable, shaped like ' + + '{"admin": {"username", "password"}, "viewer": {"username", "password"}}.', + ); +} + +export function accountsFile(): string { + const path = process.env['PULP_UI_E2E_ACCOUNTS']; + if (!path) { + throw missing('is not set'); + } + return path; +} + +export function account(role: Role): Credentials { + const path = accountsFile(); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')); + } catch { + // The reason is left out on purpose: a parse error can quote the file. + throw missing('does not name a readable JSON file'); + } + + const entry = (parsed as Record | undefined>)[ + role + ]; + if ( + !entry || + typeof entry.username !== 'string' || + typeof entry.password !== 'string' || + !entry.username || + !entry.password + ) { + throw missing(`has no complete "${role}" entry`); + } + return { username: entry.username, password: entry.password }; +} + +export function allRoles(): Role[] { + return roles; +} + +// Where the saved browser session of a role lives. The directory is ignored by +// git; the files hold live session cookies and exist only during a run. +export function storageStatePath(role: Role): string { + return join('playwright', '.auth', `${role}.json`); +} diff --git a/e2e/support/env.ts b/e2e/support/env.ts index 11fa9e72..69b7b005 100644 --- a/e2e/support/env.ts +++ b/e2e/support/env.ts @@ -30,3 +30,9 @@ export function account(): { username: string; password: string } { password: required('PULP_UI_E2E_PASSWORD'), }; } + +// The origin (scheme, host and port) the UI is served from. The API lives under +// the same origin, so API calls made by a test are built from it. +export function origin(): string { + return new URL(baseUrl()).origin; +} diff --git a/e2e/support/sign-in.ts b/e2e/support/sign-in.ts index 663dd9fe..e4f90709 100644 --- a/e2e/support/sign-in.ts +++ b/e2e/support/sign-in.ts @@ -11,13 +11,29 @@ export function title(page: Page, text: string) { // the Status page, where the application sends a fresh sign-in. export async function signIn(page: Page): Promise { baseUrl(); - const { username, password } = account(); + await signInWith(page, account()); +} +// The same, for an explicit account and starting from the plain login page. +export async function signInWith( + page: Page, + { username, password }: { username: string; password: string }, +): Promise { await page.goto('login'); - await page.locator('#pf-login-username-id').fill(username); - await page.locator('#pf-login-password-id').fill(password); - await page.getByRole('button', { name: 'Log in' }).click(); + await submitLoginForm(page, username, password); await expect(title(page, 'Status')).toBeVisible(); await expect(page.locator('[data-cy=user-dropdown]')).toBeVisible(); } + +// Types a user name and password into the login form that is already open and +// submits it. What follows is up to the caller. +export async function submitLoginForm( + page: Page, + username: string, + password: string, +): Promise { + await page.locator('#pf-login-username-id').fill(username); + await page.locator('#pf-login-password-id').fill(password); + await page.getByRole('button', { name: 'Log in' }).click(); +} diff --git a/playwright.config.ts b/playwright.config.ts index 75d50a75..6e517e7d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,12 @@ import { defineConfig } from '@playwright/test'; // e2e/support/env.ts); nothing here names a host or a credential. The base URL // is the UI root and must end in /ui/, and tests navigate with relative paths // such as page.goto('deb/repositories') so the prefix is kept. +// +// The remote projects run against a deployment given by PULP_UI_E2E_BASE_URL +// with the accounts listed in the JSON file named by PULP_UI_E2E_ACCOUNTS. They +// sign in once through the API (remote-setup), reuse that session per role, and +// end it again (remote-teardown). They are meant for a person at a terminal, not +// for a hosted workflow: their output holds session data. export default defineConfig({ testDir: 'e2e', // One worker in CI: the tests share one Pulp and sign in as the same account. @@ -22,5 +28,27 @@ export default defineConfig({ name: 'smoke', testMatch: 'smoke/**/*.spec.ts', }, + { + name: 'remote-setup', + testMatch: 'remote/auth.setup.ts', + teardown: 'remote-teardown', + }, + { + name: 'remote-teardown', + testMatch: 'remote/auth.teardown.ts', + }, + { + name: 'remote-admin', + testMatch: 'remote/**/*.spec.ts', + testIgnore: '**/*.viewer.spec.ts', + use: { storageState: 'playwright/.auth/admin.json' }, + dependencies: ['remote-setup'], + }, + { + name: 'remote-viewer', + testMatch: 'remote/**/*.viewer.spec.ts', + use: { storageState: 'playwright/.auth/viewer.json' }, + dependencies: ['remote-setup'], + }, ], });