diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index 0ae703794ab..d8fb779cbd2 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -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; @@ -24,6 +30,67 @@ type WorkerFixtures = { }; export const test = base.extend({ + // 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 => { + // 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 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'); diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index 916bafa5fde..2d5740d3623 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -27,6 +27,9 @@ export async function setEditorContent(page: Page, content: string): Promise { + // 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 }); diff --git a/frontend/e2e/setup/admin-auth.setup.ts b/frontend/e2e/setup/admin-auth.setup.ts index 54f64063583..08baf91ab79 100644 --- a/frontend/e2e/setup/admin-auth.setup.ts +++ b/frontend/e2e/setup/admin-auth.setup.ts @@ -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); }); diff --git a/frontend/e2e/setup/developer-auth.setup.ts b/frontend/e2e/setup/developer-auth.setup.ts index 328139b4b74..38c6326162e 100644 --- a/frontend/e2e/setup/developer-auth.setup.ts +++ b/frontend/e2e/setup/developer-auth.setup.ts @@ -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); }); diff --git a/frontend/e2e/setup/login-helper.ts b/frontend/e2e/setup/login-helper.ts index a05875dc50a..014435bd2fd 100644 --- a/frontend/e2e/setup/login-helper.ts +++ b/frontend/e2e/setup/login-helper.ts @@ -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, @@ -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 { + 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 { diff --git a/frontend/e2e/tests/console/session-persistence.spec.ts b/frontend/e2e/tests/console/session-persistence.spec.ts index 70a24fd3755..3d838cf3239 100644 --- a/frontend/e2e/tests/console/session-persistence.spec.ts +++ b/frontend/e2e/tests/console/session-persistence.spec.ts @@ -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/); }); @@ -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, }); @@ -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 }); }); @@ -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, }); diff --git a/frontend/packages/console-app/src/providers/detect-context/__tests__/namespace.spec.ts b/frontend/packages/console-app/src/providers/detect-context/__tests__/namespace.spec.ts index ffc66a03322..86ad7940577 100644 --- a/frontend/packages/console-app/src/providers/detect-context/__tests__/namespace.spec.ts +++ b/frontend/packages/console-app/src/providers/detect-context/__tests__/namespace.spec.ts @@ -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'; @@ -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; @@ -64,6 +68,7 @@ describe('useValuesForNamespaceContext', () => { afterEach(() => { jest.restoreAllMocks(); + sessionStorage.clear(); }); it('should return urlNamespace if it is defined', async () => { @@ -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); + }); }); diff --git a/frontend/packages/console-app/src/providers/detect-context/namespace.ts b/frontend/packages/console-app/src/providers/detect-context/namespace.ts index 8a470d7382b..da2293c2977 100644 --- a/frontend/packages/console-app/src/providers/detect-context/namespace.ts +++ b/frontend/packages/console-app/src/providers/detect-context/namespace.ts @@ -3,7 +3,11 @@ import { useLocation, useNavigate } from 'react-router'; import { formatNamespaceRoute, setActiveApplication } from '@console/internal/actions/ui'; import { getNamespace } from '@console/internal/components/utils/link'; import { flagPending } from '@console/internal/reducers/features'; -import { ALL_APPLICATIONS_KEY, FLAGS } from '@console/shared/src/constants/common'; +import { + ALL_APPLICATIONS_KEY, + FLAGS, + 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'; @@ -44,6 +48,7 @@ export const useValuesForNamespaceContext: UseValuesForNamespaceContext = () => const updateNamespace = useCallback( (ns: string) => { if (ns !== activeNamespaceRef.current) { + sessionStorage.setItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY, ns.trim()); setActiveNamespace(ns); dispatch(setActiveApplication(ALL_APPLICATIONS_KEY)); const oldPath = window.location.pathname; @@ -92,6 +97,14 @@ export const useValuesForNamespaceContext: UseValuesForNamespaceContext = () => } }, [urlNamespace, updateNamespace]); + // Mirror the active namespace into session storage (scoped to the current + // browser tab) so components that render outside NamespaceContext can read it + useEffect(() => { + if (activeNamespace) { + sessionStorage.setItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY, activeNamespace.trim()); + } + }, [activeNamespace]); + // Change active namespace (in context and redux state) as well as last used namespace // when a component calls setNamespace, for example via useActiveNamespace() const setNamespace = useCallback( diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 09a2ed6a63b..cd0f42d3709 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -11,6 +11,9 @@ const chrome = { ...devices['Desktop Chrome'], userAgent: INTEGRATION_TEST_USER_ const isDebug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; +// Keep these paths in sync with adminStorageState/developerStorageState in +// e2e/setup/login-helper.ts. They cannot be imported here: that module uses +// import.meta (ESM) while Playwright loads this config as CommonJS. const adminStorageState = path.resolve(__dirname, 'e2e', '.auth', 'kubeadmin.json'); const developerStorageState = path.resolve(__dirname, 'e2e', '.auth', 'developer.json'); const hasDeveloper = !!process.env.BRIDGE_HTPASSWD_USERNAME; @@ -51,7 +54,6 @@ export default defineConfig({ testMatch: '**/*.spec.ts', forbidOnly: isCI, globalTimeout: Number(process.env.GLOBAL_TIMEOUT_MS) || 0, - maxFailures: isCI ? 10 : 0, retries: isCI ? 2 : 0, timeout: 120_000, reporter: isCI