Skip to content
137 changes: 137 additions & 0 deletions frontend/e2e/fixtures/auth-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Frame, 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>>();

/**
* The last app route each page navigated to before any auth redirect. Used to
* send the page back where the test intended to be after re-authenticating,
* since by the time recovery runs the page URL is the OAuth/login page.
*/
const lastAppUrl = new WeakMap<Page, string>();

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

/**
* True for OAuth server / console login URLs — the pages a session-expiry
* redirect passes through. These are never the route a test wants to resume at.
*/
function isAuthUrl(url: string): boolean {
return /\/oauth\/|\/oauth2\/|\/login(\/|$|\?)|\/auth\//.test(url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Anchor authentication URL matching to the pathname.

The current expression matches /oauth/ and /auth/ anywhere in the full URL. A normal application route or query value can then be classified as an auth URL. This prevents lastAppUrl updates and can start unnecessary recovery.

Parse the URL, normalize its pathname, and match the complete auth-path shape.

Proposed fix
 function isAuthUrl(url: string): boolean {
-  return /\/oauth\/|\/oauth2\/|\/login(\/|$|\?)|\/auth\//.test(url);
+  const pathname = new URL(url).pathname.normalize('NFC');
+  return /^\/(?:oauth2?|login|auth)(?:\/.*)?$/.test(pathname);
 }

As per path instructions, “Normalize Unicode and anchor regexes (^$); watch for ReDoS.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isAuthUrl(url: string): boolean {
return /\/oauth\/|\/oauth2\/|\/login(\/|$|\?)|\/auth\//.test(url);
function isAuthUrl(url: string): boolean {
const pathname = new URL(url).pathname.normalize('NFC');
return /^\/(?:oauth2?|login|auth)(?:\/.*)?$/.test(pathname);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/fixtures/auth-fixture.ts` around lines 31 - 32, Update isAuthUrl
to parse the URL and evaluate only its normalized pathname, avoiding matches
from query strings or unrelated URL text. Anchor the authentication-path regex
to the complete pathname shape with ^ and $, while preserving recognition of
OAuth, OAuth2, login, and auth routes.

Source: Path instructions

}

/**
* 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. After re-login, navigates back to
* the route the test was heading to so deep-link tests resume in place.
*
* 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,
detectTimeoutMs = 0,
): Promise<boolean> {
// If a re-login is already running (or being set up) for this page, await it
// rather than starting a second one.
const existing = reloginInProgress.get(page);
if (existing) {
await existing;
return true;
}

// Claim the re-login slot synchronously — before any await — so concurrent
// navigation events can't both pass the check above and launch duplicate
// logins. The deferred is published now and settled once we know whether a
// recovery is actually needed; if not, we release the slot immediately.
let release!: () => void;
const claim = new Promise<void>((resolve) => {
release = resolve;
});
reloginInProgress.set(page, claim);
Comment on lines +103 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- storage-state configuration ---'
fd -i 'playwright.config.*|package.json' frontend -x sh -c \
  'printf "\n--- %s ---\n" "$1"; rg -n -C 3 "storageState|workers|fullyParallel" "$1" || true' sh {}

printf '%s\n' '--- storage-state persistence implementation ---'
rg -n -C 5 --glob '*.ts' \
  'function saveStorageState|const saveStorageState|saveStorageState\(|storageState\(\{.*path' \
  frontend/e2e

Repository: openshift/console

Length of output: 6073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- playwright projects and storage-state paths ---'
sed -n '1,190p' frontend/playwright.config.ts

printf '%s\n' '--- auth fixture and fixture scopes ---'
sed -n '1,150p' frontend/e2e/fixtures/auth-fixture.ts
sed -n '1,180p' frontend/e2e/fixtures/index.ts

printf '%s\n' '--- storage-state path definitions and save helper ---'
rg -n -C 8 --glob '*.ts' \
  'adminStorageState|developerStorageState|STORAGE_STATE_DIR|storageState\(\{ path|saveStorageState' frontend/e2e frontend/playwright.config.ts

Repository: openshift/console

Length of output: 22794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Playwright version ---'
rg -n -C 2 '"`@playwright/test`"|"playwright"' frontend/package.json package.json frontend/yarn.lock yarn.lock pnpm-lock.yaml package-lock.json 2>/dev/null || true

printf '%s\n' '--- repository-level synchronization around auth-state writes ---'
rg -n -C 4 --glob '!node_modules/**' \
  'kubeadmin\.json|developer\.json|storageState\(\{ *path|saveStorageState|lockfile|proper-lockfile|mkdir.*lock|writeFile.*storage' .

Repository: openshift/console

Length of output: 16321


🌐 Web query:

Playwright storageState({ path }) implementation atomic file write behavior

💡 Result:

The Playwright storageState({ path }) method does not implement atomic file write behavior [1][2]. When this method is called, Playwright serializes the browser context's state (cookies, local storage, and optionally IndexedDB) and writes it directly to the specified path using standard file system operations [1]. Internally, Playwright uses Node.js's fs.promises.writeFile to save the state as a JSON file [1][3][4]. This process creates or overwrites the file directly [1][2]. Because it does not use an atomic pattern—such as writing to a temporary file and then renaming it—there is a possibility that a process reading the file simultaneously could encounter a partially written or corrupted state if the write operation is interrupted [1][2]. Additionally, the method does not provide built-in protections against race conditions or concurrent writes to the same path [2]. If multiple test workers or processes attempt to call storageState with the same output path simultaneously, their writes may interleave or cause conflicts [2]. To mitigate these risks in environments where concurrency is a concern, it is recommended to: 1. Use unique paths for different workers or test runs (e.g., by utilizing test.info().outputPath()) [2]. 2. Avoid sharing the same state file across concurrent processes that might write to it [2]. 3. Treat the file as an immutable artifact once it has been generated by a setup process, ensuring that subsequent test workers only perform read operations [2].

Citations:


Serialize storage-state writes by statePath.

WORKERS can run multiple workers in one project, but all admin workers use kubeadmin.json and all developer workers use developer.json. saveStorageState writes directly without a lock or atomic replacement. Concurrent recovery can corrupt or overwrite the shared state. Use a lock keyed by statePath or assign each worker a separate state file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/fixtures/auth-fixture.ts` around lines 60 - 68, Update the
authentication recovery flow around saveStorageState to serialize storage-state
writes by statePath. Use a shared lock keyed by each statePath so concurrent
workers targeting kubeadmin.json or developer.json cannot write simultaneously,
and ensure the lock is released on every success and failure path; preserve the
existing re-login coordination behavior.

Comment on lines +108 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Propagate the actual recovery result to joiners.

The shared claim is Promise<void>, but recoverSessionIfExpired returns Promise<boolean>. A caller that joins at Lines 64-67 always receives true. The owner resolves the claim without recovery when the login page is not detected or credentials are unavailable.

Use Promise<boolean> for the claim. Resolve false on both no-op paths and true after successful recovery. Return the existing claim to joiners.

Proposed fix
-  let release!: () => void;
+  let release!: (recovered: boolean) => void;
...
-  const claim = new Promise<void>((resolve, reject) => {
+  const claim = new Promise<boolean>((resolve, reject) => {
...
   if (existing) {
-    await existing;
-    return true;
+    return existing;
   }
...
-      release();
+      release(false);
...
-      release();
+      release(false);
...
-    release();
+    release(true);

Also update the reloginInProgress map value type to Promise<boolean>.

Also applies to: 117-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/fixtures/auth-fixture.ts` around lines 75 - 86, Update the
reloginInProgress map and shared claim in recoverSessionIfExpired to use
Promise<boolean>, return the existing claim directly to joiners, resolve false
when the login page is absent or credentials are unavailable, and resolve true
only after successful recovery.


const finish = (): void => {
release();
reloginInProgress.delete(page);
};

if (!(await isOnLoginPage(page, detectTimeoutMs))) {
finish();
return false;
}

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

const intendedUrl = lastAppUrl.get(page);
try {
// 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);
}
// Restore the route the test was navigating to before the redirect, so
// deep-link tests resume where they expected rather than on the console
// home page that performLogin lands on.
if (intendedUrl && intendedUrl !== page.url()) {
await page.goto(intendedUrl, { waitUntil: 'domcontentloaded' });
}
} finally {
finish();
}
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: Frame) => {
if (frame !== page.mainFrame()) {
return;
}
const url = frame.url();
const onAuthUrl = isAuthUrl(url);
// Remember the most recent non-auth route so recovery can return to it.
if (url && url !== 'about:blank' && !onAuthUrl) {
lastAppUrl.set(page, url);
}
// On a normal (non-auth) navigation, detect the login page instantly so the
// hot path adds no latency. When we land on an auth URL the session likely
// expired but the login form may still be rendering, so give it a bounded
// window to appear before deciding recovery isn't needed.
// 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, onAuthUrl ? 5_000 : 0).catch(() => {
/* best-effort recovery */
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
page.on('framenavigated', handler);
return () => page.off('framenavigated', handler);
}
19 changes: 19 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,24 @@ 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 navigation listener re-authenticates the persona whenever a
// navigation lands on the login page. The proactive check below is a
// best-effort guard for a page that somehow starts on the login page; the
// page is typically about:blank at fixture setup, so it usually no-ops and
// the listener does the real work.
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
8 changes: 4 additions & 4 deletions frontend/e2e/pages/web-terminal-config-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class WebTerminalConfigPage extends BasePage {

async navigateToWebTerminalConfig(): Promise<void> {
await this.goTo('/k8s/cluster/operator.openshift.io~v1~Console/cluster');
await this.waitForLoadingComplete(10_000);
await this.waitForLoadingComplete(30_000);
const customizeButton = this.page.getByRole('button', { name: 'Customize' });
// eslint-disable-next-line no-restricted-syntax
await customizeButton
Expand All @@ -25,17 +25,17 @@ export class WebTerminalConfigPage extends BasePage {
await this.robustClick(customizeButton.first());
} else {
const actionsMenu = this.page.getByTestId('actions-menu-button');
await this.robustClick(actionsMenu);
await this.robustClick(actionsMenu, { timeout: 60_000 });
const customizeAction = this.page.locator('[data-test-action="Customize"]:not([disabled])');
await this.robustClick(customizeAction);
}
await this.waitForLoadingComplete(10_000);
await this.waitForLoadingComplete(30_000);
await this.clickWebTerminalTab();
}

async clickWebTerminalTab(): Promise<void> {
const tab = this.page.getByRole('tab', { name: 'Web Terminal' });
await this.robustClick(tab, { timeout: 60_000 });
await this.robustClick(tab, { timeout: 60_000, retries: 1 });
await this.waitForLoadingComplete(5_000);
}

Expand Down
26 changes: 6 additions & 20 deletions frontend/e2e/pages/web-terminal-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,12 @@ export class WebTerminalPage extends BasePage {
private readonly closeTerminalButton = this.page.getByLabel(/Close terminal/);
private readonly inactivityMessageArea = this.page.locator('div.co-cloudshell-exec__error-msg');

async waitForTerminalIconVisible(maxRetries = 10): Promise<void> {
async waitForTerminalIconVisible(): Promise<void> {
await warmupSPA(this.page);
try {
// eslint-disable-next-line no-restricted-syntax
await this.terminalIcon.waitFor({ state: 'visible', timeout: 30_000 });
return;
} catch {
// Icon not visible on first load — retry with reloads
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
await this.page.reload();
try {
// eslint-disable-next-line no-restricted-syntax
await this.terminalIcon.waitFor({ state: 'visible', timeout: 15_000 });
return;
} catch {
// Retry
}
}
throw new Error(`Terminal icon not visible after ${maxRetries} retries`);
await expect(async () => {
await this.page.reload({ waitUntil: 'domcontentloaded' });
await expect(this.terminalIcon).toBeVisible({ timeout: 15_000 });
}).toPass({ intervals: [2_000, 5_000, 10_000], timeout: 120_000 });
}

async clickTerminalIcon(): Promise<void> {
Expand All @@ -54,7 +40,7 @@ export class WebTerminalPage extends BasePage {
await this.loadingBox.waitFor({ state: 'detached', timeout: 60_000 }).catch(() => {});
}

async waitForTerminalWindow(timeoutMs = 60_000): Promise<void> {
async waitForTerminalWindow(timeoutMs = 120_000): Promise<void> {
await expect(this.terminalContainer).toBeVisible({ timeout: timeoutMs });
await expect(this.terminalWindow).toBeVisible({ timeout: timeoutMs });
}
Expand Down
9 changes: 3 additions & 6 deletions frontend/e2e/setup/admin-auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import * as path from 'path';

import { test as setup } from '@playwright/test';

import { performLogin, saveStorageState } from './login-helper';
import { getAdminCredentials, performLogin, saveStorageState } from './login-helper';

const adminStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'kubeadmin.json');

setup('login as kubeadmin', async ({ page }) => {
setup.skip(process.env.SKIP_GLOBAL_SETUP === 'true', 'SKIP_GLOBAL_SETUP is set');

const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';
const username = process.env.OPENSHIFT_USERNAME || 'kubeadmin';
const password = process.env.BRIDGE_KUBEADMIN_PASSWORD || '';

await performLogin(page, baseURL, username, password, 'kube:admin');
const { username, password, idpName } = getAdminCredentials();
await performLogin(page, username, password, idpName);
await saveStorageState(page, adminStorageState);
});
13 changes: 4 additions & 9 deletions frontend/e2e/setup/developer-auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@ import * as path from 'path';

import { test as setup } from '@playwright/test';

import { performLogin, saveStorageState } from './login-helper';
import { getDeveloperCredentials, performLogin, saveStorageState } from './login-helper';

const developerStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'developer.json');

setup('login as developer', async ({ page }) => {
setup.skip(process.env.SKIP_GLOBAL_SETUP === 'true', 'SKIP_GLOBAL_SETUP is set');

const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME;
const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD;
const creds = getDeveloperCredentials();
setup.skip(!creds, 'No developer credentials configured');

setup.skip(!htpasswdUser || !htpasswdPass, 'No developer credentials configured');

const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';
const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP || htpasswdUser!;

await performLogin(page, baseURL, htpasswdUser!, htpasswdPass!, htpasswdIdp);
await performLogin(page, creds!.username, creds!.password, creds!.idpName);
await saveStorageState(page, developerStorageState);
});
71 changes: 69 additions & 2 deletions frontend/e2e/setup/login-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,81 @@ import { expect } from '@playwright/test';

const STORAGE_STATE_DIR = path.resolve(import.meta.dirname, '..', '.auth');

export function getBaseURL(): string {
return process.env.WEB_CONSOLE_URL || 'http://localhost:9000';
}

export function getAdminCredentials(): { username: string; password: string; idpName: string } {
return {
username: process.env.OPENSHIFT_USERNAME || 'kubeadmin',
password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '',
idpName: 'kube:admin',
};
}

export function getDeveloperCredentials(): {
username: string;
password: string;
idpName: string;
} | null {
const username = process.env.BRIDGE_HTPASSWD_USERNAME;
const password = process.env.BRIDGE_HTPASSWD_PASSWORD;
if (!username || !password) return null;
return {
username,
password,
idpName: process.env.BRIDGE_HTPASSWD_IDP || username,
};
}

/**
* 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 showing the OAuth login page (i.e. the
* session has expired or was never established).
*
* A session-expiry redirect goes through the OAuth server before the login
* form renders, so an instantaneous visibility check can race the redirect
* chain and miss it. `timeoutMs` gives the login locator a bounded window to
* appear. It defaults to 0 (instantaneous) so callers on the hot path stay
* cheap; pass a small timeout only when a redirect may still be settling.
*/
export async function isOnLoginPage(page: Page, timeoutMs = 0): Promise<boolean> {
const loginLocator = page
.locator('[data-test-id="login"]')
.or(page.locator('#inputUsername'))
.first();
try {
// eslint-disable-next-line no-restricted-syntax
await loginLocator.waitFor({ state: 'visible', timeout: timeoutMs });
return true;
} catch {
return false;
}
}

export async function performLogin(
page: Page,
baseURL: string,
username: string,
password: string,
idpName?: string,
): Promise<void> {
await page.goto(baseURL, { timeout: 90_000, waitUntil: 'domcontentloaded' });
await page.goto(getBaseURL(), { timeout: 90_000, waitUntil: 'domcontentloaded' });

const authDisabled = await page
.evaluate(() => (window as any).SERVER_FLAGS?.authDisabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test.describe('Web Terminal basic user', () => {
});

test('open terminal with advanced timeout', async ({ page }) => {
test.slow();
const webTerminal = new WebTerminalPage(page);

await test.step('Open terminal with 1-minute timeout', async () => {
Expand All @@ -45,6 +46,7 @@ test.describe('Web Terminal basic user', () => {
});

test('verify Open in new tab button', async ({ page }) => {
test.slow();
const webTerminal = new WebTerminalPage(page);

await test.step('Wait for terminal icon and open terminal', async () => {
Expand Down
Loading