Skip to content
Closed
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
89 changes: 89 additions & 0 deletions frontend/e2e/fixtures/auth-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Page, TestInfo } from '@playwright/test';

import {
isOnLoginPage,
performLogin,
resolveCredentialsForStorageState,
saveStorageState,
} from '../setup/login-helper';

/**
* Guards against re-entrant / concurrent re-login attempts on the same page.
*/
const reloginInProgress = new WeakMap<Page, Promise<void>>();

function storageStatePath(testInfo: TestInfo): string | undefined {
const state = testInfo.project.use.storageState;
return typeof state === 'string' ? state : undefined;
}

/**
* Detects when a navigation has landed on the OAuth login page — meaning the
* session snapshot in storageState has expired — and transparently
* re-authenticates the current persona, refreshing the stored session so
* subsequent tests reuse the fresh state.
*
* The setup projects establish the session once; long runs can outlive the
* OAuth token, after which every navigation silently redirects to the login
* page and tests hang waiting for elements that never appear. This fixture is
* the self-healing recovery for that case.
*/
export async function recoverSessionIfExpired(
page: Page,
testInfo: TestInfo,
): Promise<boolean> {
if (!(await isOnLoginPage(page))) {
return false;
}

const existing = reloginInProgress.get(page);
if (existing) {
await existing;
return true;
}

const statePath = storageStatePath(testInfo);
const credentials = statePath ? resolveCredentialsForStorageState(statePath) : undefined;
if (!credentials) {
// No credentials to recover with (e.g. auth disabled or dev creds unset).
return false;
}

const relogin = (async () => {
// eslint-disable-next-line no-console
console.warn(
`[auth] Session expired for "${testInfo.titlePath.join(' > ')}"; re-authenticating.`,
);
await performLogin(page, credentials.username, credentials.password, credentials.idpName);
if (statePath) {
await saveStorageState(page, statePath);
}
})();

reloginInProgress.set(page, relogin);
try {
await relogin;
} finally {
reloginInProgress.delete(page);
}
return true;
}

/**
* Attaches a main-frame navigation listener that re-authenticates whenever a
* navigation lands on the login page. Returns a disposer to detach it.
*/
export function attachSessionRecovery(page: Page, testInfo: TestInfo): () => void {
const handler = (frame: import('@playwright/test').Frame) => {
if (frame !== page.mainFrame()) {
return;
}
// Fire-and-forget: recovery guards its own re-entrancy. Swallow errors so a
// transient navigation event doesn't reject an unrelated step.
void recoverSessionIfExpired(page, testInfo).catch(() => {
/* best-effort recovery */
});
};
page.on('framenavigated', handler);
return () => page.off('framenavigated', handler);
}
16 changes: 16 additions & 0 deletions frontend/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { test as base, expect } from '@playwright/test';

import KubernetesClient from '../clients/kubernetes-client';

import { attachSessionRecovery, recoverSessionIfExpired } from './auth-fixture';
import type { CleanupFixture } from './cleanup-fixture';
import { createCleanupFixture } from './cleanup-fixture';

Expand All @@ -24,6 +25,21 @@ type WorkerFixtures = {
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
// Override the built-in page fixture to transparently recover from expired
// sessions. Long runs can outlive the OAuth token captured in storageState;
// without this, navigations silently redirect to the login page and tests
// hang. The listener re-authenticates the persona whenever a navigation
// lands on the login page, and a proactive check covers the initial page.
page: async ({ page }, use, testInfo) => {
const detach = attachSessionRecovery(page, testInfo);
try {
await recoverSessionIfExpired(page, testInfo);
await use(page);
} finally {
detach();
}
},

testConfig: [
async ({}, use) => {
const configPath = path.resolve(import.meta.dirname, '..', '.test-config.json');
Expand Down
30 changes: 30 additions & 0 deletions frontend/e2e/setup/login-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ export function getDeveloperCredentials(): {
};
}

/**
* Resolves the login credentials for a given storage-state file. The auth
* fixture uses this to re-authenticate the correct persona (admin vs.
* developer) when a session expires mid-run, mirroring the setup projects.
* Returns null when the required credentials are not configured.
*/
export function resolveCredentialsForStorageState(
storageStatePath: string,
): { username: string; password: string; idpName: string } | null {
const fileName = path.basename(storageStatePath);
if (fileName === 'developer.json') {
return getDeveloperCredentials();
}
// Default to the kubeadmin/admin persona.
return getAdminCredentials();
}

/**
* Returns true when the given page is currently showing the OAuth login page
* (i.e. the session has expired or was never established).
*/
export async function isOnLoginPage(page: Page): Promise<boolean> {
return page
.locator('[data-test-id="login"]')
.or(page.locator('#inputUsername'))
.first()
.isVisible()
.catch(() => false);
}

export async function performLogin(
page: Page,
username: string,
Expand Down