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
67 changes: 67 additions & 0 deletions frontend/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import * as path from 'path';
import { test as base, expect } from '@playwright/test';

import KubernetesClient from '../clients/kubernetes-client';
import { loginFromEnv } from '../setup/login-helper';

import type { CleanupFixture } from './cleanup-fixture';
import { createCleanupFixture } from './cleanup-fixture';

// URLs the console redirects to when a shared storageState session expires or is
// invalidated (e.g. by a console rollout in another spec). Matches the OAuth
// server and the console's own login route.
const OAUTH_REDIRECT_RE = /\/oauth\/|oauth-openshift|\/auth\/login\b/;

export interface SharedTestConfig {
testNamespace: string;
authToken?: string;
Expand All @@ -24,6 +30,67 @@ type WorkerFixtures = {
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
// Override the built-in `page` fixture to self-heal lost sessions. When any
// navigation is bounced to the OAuth login page — during warmup or mid-test —
// re-authenticate the current persona and retry the original target so the
// caller transparently lands on the page it asked for. loginFromEnv returns
// quickly when the OAuth SSO cookie is still valid (the flow auto-completes)
// and resubmits credentials when it isn't. Persona is derived from the project
// name, matching the storageState mapping in playwright.config.ts.
//
// Tests that assert on session/auth behavior directly (e.g. session
// persistence across pod restarts) must opt out with a
// `{ type: 'no-auto-reauth' }` annotation, otherwise transparent recovery
// would mask the very failure they check for.
page: async ({ page }, use, testInfo) => {
if (testInfo.annotations.some((a) => a.type === 'no-auto-reauth')) {
await use(page);
return;
}
const persona = testInfo.project.name.endsWith('-developer') ? 'developer' : 'admin';
const originalGoto = page.goto.bind(page);
let recovering = false;

const recoverIfRedirectedToLogin = async (): Promise<boolean> => {
// Guard against re-entrancy: loginFromEnv navigates internally, and those
// navigations flow back through this override.
if (recovering || !OAUTH_REDIRECT_RE.test(page.url())) {
return false;
}
recovering = true;
try {
await loginFromEnv(page, persona);
} finally {
recovering = false;
}
return true;
};

page.goto = async (url, options) => {
const response = await originalGoto(url, options);
// The console redirects to the OAuth login page client-side, a beat after
// the initial document loads, so `page.url()` can still read the target
// right after goto resolves. Wait for auth to settle before deciding: the
// console boots with a `co-auth-pending` class on <html> and removes it
// once its authenticated bootstrap fetch succeeds (see public/components/
// app.tsx); a 401 instead redirects to OAuth. Race that class dropping
// against the OAuth redirect so we neither miss the redirect nor stall the
// happy path.
if (!recovering) {
// eslint-disable-next-line no-restricted-syntax -- waiting for state, no action follows
const authSettled = page.locator('html:not(.co-auth-pending)').waitFor({ state: 'attached', timeout: 30_000 });
const redirectedToLogin = page.waitForURL(OAUTH_REDIRECT_RE, { timeout: 30_000 });
await Promise.race([authSettled.catch(() => {}), redirectedToLogin.catch(() => {})]);
}
if (await recoverIfRedirectedToLogin()) {
return originalGoto(url, options);
}
return response;
};

await use(page);
},

testConfig: [
async ({}, use) => {
const configPath = path.resolve(import.meta.dirname, '..', '.test-config.json');
Expand Down
3 changes: 3 additions & 0 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export async function setEditorContent(page: Page, content: string): Promise<voi
}

export async function warmupSPA(page: Page): Promise<void> {
// Session recovery on OAuth redirect is handled by the guarded `page` fixture
// (e2e/fixtures/index.ts), which re-authenticates on any navigation — during
// warmup or mid-test — that gets bounced to the login page.
await expect(async () => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#page-sidebar')).toBeVisible({ timeout: 30_000 });
Expand Down
12 changes: 2 additions & 10 deletions frontend/e2e/setup/admin-auth.setup.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import * as path from 'path';

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

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

const adminStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'kubeadmin.json');
import { adminStorageState, loginFromEnv, saveStorageState } from './login-helper';

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');
await loginFromEnv(page, 'admin');
await saveStorageState(page, adminStorageState);
});
20 changes: 6 additions & 14 deletions frontend/e2e/setup/developer-auth.setup.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import * as path from 'path';

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

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

const developerStorageState = path.resolve(import.meta.dirname, '..', '.auth', 'developer.json');
import { developerStorageState, loginFromEnv, saveStorageState } from './login-helper';

setup('login as developer', async ({ page }) => {
setup.skip(process.env.SKIP_GLOBAL_SETUP === 'true', 'SKIP_GLOBAL_SETUP is set');
setup.skip(
!process.env.BRIDGE_HTPASSWD_USERNAME || !process.env.BRIDGE_HTPASSWD_PASSWORD,
'No developer credentials configured',
);

const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME;
const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD;

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 loginFromEnv(page, 'developer');
await saveStorageState(page, developerStorageState);
});
51 changes: 45 additions & 6 deletions frontend/e2e/setup/login-helper.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not removing admin-auth/developer-auth?

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { expect } from '@playwright/test';

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

export const adminStorageState = path.join(STORAGE_STATE_DIR, 'kubeadmin.json');
export const developerStorageState = path.join(STORAGE_STATE_DIR, 'developer.json');

export async function performLogin(
page: Page,
baseURL: string,
Expand All @@ -23,22 +26,58 @@ export async function performLogin(
return;
}

await expect(
page.locator('[data-test-id="login"]').or(page.locator('#inputUsername')).first(),
).toBeVisible({ timeout: 30_000 });
const userMenu = page.getByTestId('user-dropdown-toggle');
const loginForm = page.locator('[data-test-id="login"]').or(page.locator('#inputUsername'));

// The context may already be authenticated (e.g. a reused storageState). In that
// case the OAuth flow completes automatically and lands back on the console
// without ever rendering a login form, so wait for whichever appears first.
await expect(userMenu.or(loginForm).first()).toBeVisible({ timeout: 60_000 });
if (await userMenu.isVisible().catch(() => false)) {
return;
}

if (idpName) {
const providerButton = page.getByText(idpName, { exact: true });
if ((await providerButton.count()) > 0) {
const providerButton = page.getByText(idpName).first();
if (await providerButton.isVisible().catch(() => false)) {
await providerButton.click();
}
}

await expect(page.locator('#inputUsername')).toBeVisible({ timeout: 30_000 });
await page.locator('#inputUsername').fill(username);
await page.locator('#inputPassword').fill(password);
await page.locator('button[type="submit"]').click();

await expect(page.getByTestId('user-dropdown-toggle')).toBeVisible({ timeout: 60_000 });
await expect(userMenu).toBeVisible({ timeout: 60_000 });
}

/**
* Log in using the credentials configured via environment variables for the
* given persona. Admin uses the kubeadmin / kube:admin identity provider;
* developer uses the htpasswd identity provider. Used both by the auth setup
* projects and as a re-authentication fallback for specs whose shared
* storageState session has expired or been invalidated mid-run.
*/
export async function loginFromEnv(
page: Page,
persona: 'admin' | 'developer',
baseURL: string = process.env.WEB_CONSOLE_URL || 'http://localhost:9000',
): Promise<void> {
if (persona === 'developer') {
const username = process.env.BRIDGE_HTPASSWD_USERNAME;
const password = process.env.BRIDGE_HTPASSWD_PASSWORD;
if (!username || !password) {
throw new Error('Developer credentials (BRIDGE_HTPASSWD_USERNAME/PASSWORD) are not configured');
}
const idpName = process.env.BRIDGE_HTPASSWD_IDP || username;
await performLogin(page, baseURL, username, password, idpName);
return;
}

const username = process.env.OPENSHIFT_USERNAME || 'kubeadmin';
const password = process.env.BRIDGE_KUBEADMIN_PASSWORD || '';
await performLogin(page, baseURL, username, password, 'kube:admin');
}

export async function saveStorageState(page: Page, storagePath: string): Promise<void> {
Expand Down
56 changes: 25 additions & 31 deletions frontend/e2e/tests/console/session-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
import { test, expect } from '../../fixtures';
import { performLogin } from '../../setup/login-helper';
import { loginFromEnv } from '../../setup/login-helper';

const CONSOLE_NAMESPACE = 'openshift-console';
const CONSOLE_DEPLOYMENT = 'console';

test.describe(
'Session persistence across pod restarts',
{ tag: ['@admin', '@slow'] },
{
tag: ['@admin', '@slow'],
// Opt out of the page fixture's transparent OAuth re-auth: these tests
// assert the session survives on its own, so auto-recovery would mask a
// real regression.
annotation: { type: 'no-auto-reauth', description: 'asserts session survival directly' },
},
() => {
test.use({ storageState: { cookies: [], origins: [] } });
test.setTimeout(300_000);

test('session survives console pod deletion', async ({ page, k8sClient }) => {
const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';

await test.step('Log in to the console', async () => {
const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME;
const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD;
const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP;

if (htpasswdUser && htpasswdPass) {
await performLogin(page, baseURL, htpasswdUser, htpasswdPass, htpasswdIdp);
} else {
const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD;
test.skip(!kubeadminPassword, 'No credentials configured');
await performLogin(page, baseURL, 'kubeadmin', kubeadminPassword!, 'kube:admin');
}
// These are @admin tests, so always authenticate as the admin persona
// regardless of whether developer (htpasswd) credentials are configured.
test.skip(
!process.env.BRIDGE_KUBEADMIN_PASSWORD,
'No kubeadmin credentials configured',
);
await loginFromEnv(page, 'admin');

await expect(page.getByTestId('user-dropdown-toggle')).toBeVisible({ timeout: 60_000 });
});

await test.step('Verify dashboard loads', async () => {
await page.goto(`${baseURL}/dashboards`, { waitUntil: 'domcontentloaded' });
await page.goto('/dashboards', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveTitle(/Overview/);
});

Expand All @@ -53,7 +53,7 @@ test.describe(
});

await test.step('Verify session persisted — no login redirect', async () => {
await page.goto(`${baseURL}/k8s/cluster/nodes`, {
await page.goto('/k8s/cluster/nodes', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
Expand All @@ -66,20 +66,14 @@ test.describe(
});

test('session survives console plugin toggle', async ({ page, k8sClient }) => {
const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000';

await test.step('Log in to the console', async () => {
const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME;
const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD;
const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP;

if (htpasswdUser && htpasswdPass) {
await performLogin(page, baseURL, htpasswdUser, htpasswdPass, htpasswdIdp);
} else {
const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD;
test.skip(!kubeadminPassword, 'No credentials configured');
await performLogin(page, baseURL, 'kubeadmin', kubeadminPassword!, 'kube:admin');
}
// These are @admin tests, so always authenticate as the admin persona
// regardless of whether developer (htpasswd) credentials are configured.
test.skip(
!process.env.BRIDGE_KUBEADMIN_PASSWORD,
'No kubeadmin credentials configured',
);
await loginFromEnv(page, 'admin');

await expect(page.getByTestId('user-dropdown-toggle')).toBeVisible({ timeout: 60_000 });
});
Expand Down Expand Up @@ -132,7 +126,7 @@ test.describe(
});

await test.step('Verify session persisted after plugin toggle', async () => {
await page.goto(`${baseURL}/dashboards`, {
await page.goto('/dashboards', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useState } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { useLocation } from 'react-router';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useLocation, useNavigate } from 'react-router';
import { k8sGet } from '@console/dynamic-plugin-sdk/src/utils/k8s';
import { ALL_NAMESPACES_KEY } from '@console/shared/src/constants/common';
import {
ALL_NAMESPACES_KEY,
LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY,
} from '@console/shared/src/constants/common';
import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch';
import { useFlag } from '@console/shared/src/hooks/useFlag';
import { usePreferredNamespace } from '../../../components/user-preferences/namespace/usePreferredNamespace';
Expand Down Expand Up @@ -43,6 +46,7 @@ jest.mock('../../../components/user-preferences/namespace/usePreferredNamespace'
const useDispatchMock = useConsoleDispatch as jest.Mock;
const useFlagMock = useFlag as jest.Mock;
const useLocationMock = useLocation as jest.Mock;
const useNavigateMock = useNavigate as jest.Mock;
const useLastNamespaceMock = useLastNamespace as jest.Mock;
const usePreferredNamespaceMock = usePreferredNamespace as jest.Mock;
const k8sGetMock = k8sGet as jest.Mock;
Expand All @@ -64,6 +68,7 @@ describe('useValuesForNamespaceContext', () => {

afterEach(() => {
jest.restoreAllMocks();
sessionStorage.clear();
});

it('should return urlNamespace if it is defined', async () => {
Expand Down Expand Up @@ -201,4 +206,43 @@ describe('useValuesForNamespaceContext', () => {
});
expect(result.current.loaded).toBeFalsy();
});

it('writes the last-namespace session storage entry before publishing when transitioning from ALL_NAMESPACES_KEY to a named namespace', () => {
const namedNamespace = 'my-ns';

// Capture what session storage holds at the exact moment the new active
// namespace is published (setActiveNamespace). NavItemResource reads this
// value synchronously during the render that publish triggers, so it must
// already be up to date; writing it only afterwards (e.g. in an effect)
// would leave the nav one render behind. See getLastNamespace usage.
let storageAtPublish: string | null = null;
const setActiveNamespaceSpy = jest.fn(() => {
storageAtPublish = sessionStorage.getItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY);
});

useFlagMock.mockReturnValue(true);
k8sGetMock.mockReturnValue(Promise.resolve({}));
useLocationMock.mockReturnValue(getLocationData());
usePreferredNamespaceMock.mockReturnValue([undefined, jest.fn(), true]);
useLastNamespaceMock.mockReturnValue([undefined, jest.fn(), true]);
useNavigateMock.mockReturnValue(jest.fn());
// Keep the published active namespace pinned so the transition guard always
// sees the previous (ALL_NAMESPACES_KEY) value as current.
useStateMock.mockReturnValue([ALL_NAMESPACES_KEY, setActiveNamespaceSpy]);

const { result } = renderHook(() => useValuesForNamespaceContext());

// Ignore any writes from mount-time effects; only the explicit transition matters.
setActiveNamespaceSpy.mockClear();
storageAtPublish = null;
sessionStorage.setItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY, ALL_NAMESPACES_KEY);

act(() => {
result.current.setNamespace(namedNamespace);
});

expect(setActiveNamespaceSpy).toHaveBeenCalledWith(namedNamespace);
expect(storageAtPublish).toEqual(namedNamespace);
expect(sessionStorage.getItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY)).toEqual(namedNamespace);
});
});
Loading