diff --git a/suites/playwright-chat-app/suite.yaml b/suites/playwright-chat-app/suite.yaml index fd2ec27..cf24ec0 100644 --- a/suites/playwright-chat-app/suite.yaml +++ b/suites/playwright-chat-app/suite.yaml @@ -3,7 +3,7 @@ workdir: /opt/app/data required_env: - CODEX_INIT_IMAGE select: | - suite_tags=("svc_chat_app" "svc_gateway" "svc_agents_orchestrator" "svc_organizations" "svc_files" "svc_media_proxy") + suite_tags=("svc_chat_app" "svc_gateway" "svc_agents_orchestrator" "svc_organizations" "svc_files" "svc_media_proxy" "svc_tracing_app") if [ -z "${TAGS:-}" ]; then exit 0 diff --git a/suites/playwright-chat-app/test/e2e/chat-api.ts b/suites/playwright-chat-app/test/e2e/chat-api.ts index 669979d..6862c61 100644 --- a/suites/playwright-chat-app/test/e2e/chat-api.ts +++ b/suites/playwright-chat-app/test/e2e/chat-api.ts @@ -390,6 +390,10 @@ type CreateAgentOptions = { type SetupTestAgentOptions = { endpoint: string; initImage?: string; + protocol?: string; + remoteName?: string; + token?: string; + authMethod?: string; }; type CreateTestModelOptions = { @@ -473,6 +477,10 @@ export async function setupTestAgent( organizationId, endpoint: opts.endpoint, namePrefix: 'e2e-model', + protocol: opts.protocol, + remoteName: opts.remoteName, + token: opts.token, + authMethod: opts.authMethod, }); const agentName = `e2e-codex-agent-${now}`; diff --git a/suites/playwright-chat-app/test/e2e/chat-trace-link.spec.ts b/suites/playwright-chat-app/test/e2e/chat-trace-link.spec.ts new file mode 100644 index 0000000..0440931 --- /dev/null +++ b/suites/playwright-chat-app/test/e2e/chat-trace-link.spec.ts @@ -0,0 +1,155 @@ +import { randomUUID } from 'node:crypto'; +import { expect, type Page } from '@playwright/test'; +import { test } from './fixtures'; +import { + createChat, + getMessages, + resolveIdentityId, + sendChatMessage, + setupTestAgent, + waitForAgentReply, +} from './chat-api'; +import { setSelectedOrganization } from './organization-helpers'; +import { completeOidcLogin } from './sign-in-helper'; + +const CODEX_TEST_LLM_ENDPOINT = + process.env.E2E_TEST_LLM_ENDPOINT ?? 'https://testllm.dev/v1/org/agynio/suite/codex/responses'; +const CLAUDE_TEST_LLM_ENDPOINT = + process.env.E2E_TEST_LLM_ENDPOINT_CLAUDE ?? 'https://testllm.dev/v1/org/agynio/suite/claude/messages'; +const CLAUDE_INIT_IMAGE = process.env.CLAUDE_INIT_IMAGE ?? 'ghcr.io/agynio/agent-init-claude:latest'; +const CLAUDE_PROTOCOL = 'PROTOCOL_ANTHROPIC_MESSAGES'; + +function isTimeoutError(error: unknown): error is Error { + return error instanceof Error && error.name === 'TimeoutError'; +} + +type TraceScenario = { + name: string; + endpoint: string; + protocol?: string; + initImage?: string; +}; + +const TRACE_SCENARIOS: TraceScenario[] = [ + { + name: 'codex', + endpoint: CODEX_TEST_LLM_ENDPOINT, + initImage: process.env.E2E_AGENT_INIT_IMAGE, + }, + { + name: 'claude', + endpoint: CLAUDE_TEST_LLM_ENDPOINT, + protocol: CLAUDE_PROTOCOL, + initImage: CLAUDE_INIT_IMAGE, + }, +]; + +async function openTraceFromChat( + page: Page, + params: { chatId: string; organizationId: string; messageId: string; messageText: string }, +): Promise { + const chatLoaded = page.waitForResponse( + (resp) => resp.url().includes('GetMessages') && resp.status() === 200, + { timeout: 15000 }, + ); + await page.goto(`/chats/${encodeURIComponent(params.chatId)}`); + await chatLoaded; + + const messageRow = page.getByTestId('chat-message').filter({ hasText: params.messageText }).first(); + await expect(messageRow).toBeVisible({ timeout: 60000 }); + await messageRow.hover(); + + const actionsTrigger = messageRow.getByTestId('message-actions-trigger'); + await expect(actionsTrigger).toBeVisible({ timeout: 10000 }); + await actionsTrigger.click(); + + const traceLink = page.getByTestId('message-trace-link'); + await expect(traceLink).toBeVisible({ timeout: 10000 }); + + const traceHref = await traceLink.getAttribute('href'); + if (!traceHref) { + throw new Error('Trace link is missing href.'); + } + + const traceUrl = new URL(traceHref, page.url()); + expect(traceUrl.pathname).toBe(`/message/${params.messageId}`); + expect(traceUrl.searchParams.get('orgId')).toBe(params.organizationId); + + const [tracePage] = await Promise.all([ + page.waitForEvent('popup'), + traceLink.click(), + ]); + + await tracePage.waitForLoadState('domcontentloaded'); + + const callbackPromise = tracePage.waitForURL(/\/callback/, { timeout: 60000 }).catch((error) => { + if (isTimeoutError(error)) { + return null; + } + throw error; + }); + const completed = await completeOidcLogin(tracePage, { timeoutMs: 10000 }); + if (completed) { + await callbackPromise; + } + + const runUrlPattern = new RegExp(`/${params.organizationId}/runs/[0-9a-f]{32}(\\?.*)?$`); + await expect(tracePage).toHaveURL(runUrlPattern, { timeout: 120000 }); + + await expect(tracePage.getByTestId('run-summary-status')).toContainText(/finished/i, { timeout: 120000 }); + + const eventsList = tracePage.getByTestId('run-events-list'); + await expect(eventsList).toBeVisible({ timeout: 120000 }); + const eventItems = eventsList.locator('[data-testid^="run-event-"]'); + await expect.poll(() => eventItems.count(), { timeout: 120000 }).toBeGreaterThanOrEqual(2); + + const messageEvent = eventsList.getByRole('button', { name: /Message • Source/ }).first(); + await messageEvent.click(); + await expect(tracePage.getByTestId('run-event-details-message-content')).toContainText(params.messageText); + + const llmEvents = eventsList.getByRole('button', { name: /LLM Call/ }); + await expect.poll(() => llmEvents.count(), { timeout: 120000 }).toBeGreaterThan(0); + await llmEvents.first().click(); + await expect(tracePage.getByTestId('run-event-details-llm-output')).not.toHaveText('', { timeout: 120000 }); + + await tracePage.close(); +} + +test.describe('chat trace link', { + tag: ['@svc_chat_app', '@svc_tracing_app', '@svc_agents_orchestrator', '@svc_gateway', '@svc_organizations'], +}, () => { + for (const scenario of TRACE_SCENARIOS) { + test(`view trace opens tracing run (${scenario.name})`, async ({ page }) => { + test.setTimeout(8 * 60_000); + + const { organizationId, participantId } = await setupTestAgent(page, { + endpoint: scenario.endpoint, + protocol: scenario.protocol, + initImage: scenario.initImage, + }); + const identityId = await resolveIdentityId(page); + const chatId = await createChat(page, organizationId, participantId); + await setSelectedOrganization(page, organizationId); + + const messageText = `trace-${scenario.name}-${randomUUID()}`; + await sendChatMessage(page, chatId, messageText); + + await waitForAgentReply(page, chatId, identityId, 180_000); + + const messages = await getMessages(page, chatId); + const userMessage = messages.find( + (message) => message.body === messageText && message.senderId === identityId, + ); + if (!userMessage?.id) { + throw new Error(`Expected to find message id for ${messageText}.`); + } + + await openTraceFromChat(page, { + chatId, + organizationId, + messageId: userMessage.id, + messageText, + }); + }); + } +}); diff --git a/suites/playwright-chat-app/test/e2e/fixtures.ts b/suites/playwright-chat-app/test/e2e/fixtures.ts index 38e10c9..e52fb66 100644 --- a/suites/playwright-chat-app/test/e2e/fixtures.ts +++ b/suites/playwright-chat-app/test/e2e/fixtures.ts @@ -1,10 +1,10 @@ import type { Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; -import { signInViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; export { expect }; async function signInAndLoad(page: Page) { - await signInViaMockAuth(page); + await signInViaOidc(page); } export const test = base.extend({ diff --git a/suites/playwright-chat-app/test/e2e/multi-user-fixtures.ts b/suites/playwright-chat-app/test/e2e/multi-user-fixtures.ts index f2a1bfd..43169c6 100644 --- a/suites/playwright-chat-app/test/e2e/multi-user-fixtures.ts +++ b/suites/playwright-chat-app/test/e2e/multi-user-fixtures.ts @@ -1,6 +1,6 @@ import type { Browser, Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; -import { signInViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; const USER_A_EMAIL = 'e2e-user-a@agyn.test'; const USER_B_EMAIL = 'e2e-user-b@agyn.test'; @@ -19,7 +19,7 @@ async function createUserContext(browser: Browser, email: string) { page.on('requestfailed', (request) => { console.log(`[request-failed] ${request.url()} — ${request.failure()?.errorText}`); }); - await signInViaMockAuth(page, email); + await signInViaOidc(page, email); return { page, context }; } diff --git a/suites/playwright-chat-app/test/e2e/sign-in-helper.ts b/suites/playwright-chat-app/test/e2e/sign-in-helper.ts index 5b3c8f0..57e6337 100644 --- a/suites/playwright-chat-app/test/e2e/sign-in-helper.ts +++ b/suites/playwright-chat-app/test/e2e/sign-in-helper.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; const defaultEmail = 'e2e-tester@agyn.test'; @@ -8,6 +8,39 @@ type SignInOptions = { force?: boolean; }; +type BrowserLoginOptions = { + onLoginPage?: (page: Page) => Promise; + email?: string; + timeoutMs?: number; +}; + +function isTimeoutError(error: unknown): error is Error { + return error instanceof Error && error.name === 'TimeoutError'; +} + +async function waitForLocator(locator: Locator, timeout: number): Promise { + try { + await locator.waitFor({ timeout }); + return true; + } catch (error) { + if (isTimeoutError(error)) { + return false; + } + throw error; + } +} + +async function isLocatorVisible(locator: Locator, timeout: number): Promise { + try { + return await locator.isVisible({ timeout }); + } catch (error) { + if (isTimeoutError(error)) { + return false; + } + throw error; + } +} + async function clearAuthState(page: Page): Promise { await page.evaluate(() => { window.sessionStorage.clear(); @@ -16,11 +49,59 @@ async function clearAuthState(page: Page): Promise { await page.context().clearCookies(); } -export async function signInViaMockAuth( +async function waitForLoginForm(page: Page, timeoutMs: number): Promise { + const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/i }); + const emailInput = page.getByTestId('login-email-input'); + const usernameInput = page.getByTestId('login-username-input'); + return Promise.race([ + waitForLocator(loginHeading, timeoutMs), + waitForLocator(emailInput, timeoutMs), + waitForLocator(usernameInput, timeoutMs), + ]); +} + +async function fillLoginForm( page: Page, - email?: string, - options: SignInOptions = {}, -): Promise { + expectedEmail: string, + onLoginPage?: (page: Page) => Promise, +): Promise { + if (onLoginPage) { + await onLoginPage(page); + } + + const strategyTabs = page.getByTestId('login-strategy-tabs'); + if (await isLocatorVisible(strategyTabs, 2000)) { + const emailTab = strategyTabs.getByRole('tab', { name: 'Email' }); + if (await isLocatorVisible(emailTab, 2000)) { + await emailTab.click(); + } + } + + const emailInput = page.getByTestId('login-email-input'); + if ((await emailInput.count()) > 0) { + await expect(emailInput).toBeVisible({ timeout: 5000 }); + await emailInput.fill(expectedEmail); + } else { + const usernameInput = page.getByTestId('login-username-input'); + await expect(usernameInput).toBeVisible({ timeout: 5000 }); + await usernameInput.fill(expectedEmail); + } + + await page.getByRole('button', { name: 'Continue' }).click(); +} + +export async function completeOidcLogin(page: Page, options: BrowserLoginOptions = {}): Promise { + const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; + const timeoutMs = options.timeoutMs ?? 30000; + const loginReady = await waitForLoginForm(page, timeoutMs); + if (!loginReady) { + return false; + } + await fillLoginForm(page, expectedEmail, options.onLoginPage); + return true; +} + +export async function signInViaOidc(page: Page, email?: string, options: SignInOptions = {}): Promise { const expectedEmail = email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; const forceLogin = options.force ?? false; @@ -30,54 +111,49 @@ export async function signInViaMockAuth( await page.goto('/'); } - const loginUrlPattern = /mockauth\.dev\/r\/.*\/oidc/; const chatList = page.getByTestId('chat-list'); const noOrganizationsScreen = page.getByTestId('no-organizations-screen'); const appReady = chatList.or(noOrganizationsScreen); - const initialRoute = await Promise.race([ - page - .waitForURL(loginUrlPattern, { timeout: 10000 }) - .then(() => 'login') - .catch(() => null), + let initialState: 'app' | 'login' | null = await Promise.race([ appReady .waitFor({ timeout: 10000 }) - .then(() => 'app') - .catch(() => null), + .then(() => 'app' as const) + .catch((error) => { + if (isTimeoutError(error)) { + return null; + } + throw error; + }), + waitForLoginForm(page, 10000).then((ready) => (ready ? ('login' as const) : null)), ]); - if (initialRoute === 'app' && !forceLogin) { + if (initialState === 'app' && !forceLogin) { await expect(appReady).toBeVisible({ timeout: 30000 }); return false; } - if (!initialRoute || (initialRoute === 'app' && forceLogin)) { - const loginReached = await page - .waitForURL(loginUrlPattern, { timeout: 15000 }) - .then(() => true) - .catch(() => false); - if (!loginReached) { + if (initialState !== 'login') { + const loginReady = await waitForLoginForm(page, 15000); + if (!loginReady) { await expect(appReady).toBeVisible({ timeout: 30000 }); return false; } + initialState = 'login'; } - if (options.onLoginPage) { - await options.onLoginPage(page); - } - - const strategyTabs = page.getByTestId('login-strategy-tabs'); - if (await strategyTabs.isVisible()) { - await strategyTabs.getByRole('tab', { name: 'Email' }).click(); + const callbackPromise = page.waitForURL(/\/callback/, { timeout: 60000 }).catch((error) => { + if (isTimeoutError(error)) { + return null; + } + throw error; + }); + const completed = await completeOidcLogin(page, { email: expectedEmail, onLoginPage: options.onLoginPage }); + if (completed) { + await callbackPromise; } - const emailInput = page.getByTestId('login-email-input'); - await expect(emailInput).toBeVisible(); - await emailInput.fill(expectedEmail); - - await page.getByRole('button', { name: 'Continue' }).click(); - - await page.waitForURL(/\/chats/); + await page.waitForURL(/\/chats/, { timeout: 60000 }); await expect(appReady).toBeVisible({ timeout: 30000 }); return true; } diff --git a/suites/playwright-chat-app/test/e2e/sign-in.spec.ts b/suites/playwright-chat-app/test/e2e/sign-in.spec.ts index 46296c8..05fa07d 100644 --- a/suites/playwright-chat-app/test/e2e/sign-in.spec.ts +++ b/suites/playwright-chat-app/test/e2e/sign-in.spec.ts @@ -1,14 +1,14 @@ import { argosScreenshot } from '@argos-ci/playwright'; import { test, expect } from '@playwright/test'; -import { signInViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; const defaultEmail = 'e2e-tester@agyn.test'; const expectedEmail = process.env.E2E_OIDC_EMAIL ?? defaultEmail; test.describe('sign-in', { tag: ['@svc_chat_app', '@svc_gateway'] }, () => { - test('signs in via mockauth redirect flow', async ({ page }) => { + test('signs in via oidc redirect flow', async ({ page }) => { test.setTimeout(60_000); - const signedIn = await signInViaMockAuth(page, expectedEmail, { + await signInViaOidc(page, expectedEmail, { onLoginPage: async (loginPage) => { const loginHeading = loginPage.getByRole('heading', { level: 1 }); await expect(loginHeading).toContainText('Log in to'); @@ -43,11 +43,6 @@ test.describe('sign-in', { tag: ['@svc_chat_app', '@svc_gateway'] }, () => { }; }); - if (!signedIn) { - expect(storedUser).toBeNull(); - return; - } - expect(storedUser).not.toBeNull(); expect(storedUser?.accessToken).toBeTruthy(); expect(storedUser?.idToken).toBeTruthy(); diff --git a/suites/playwright-tracing-app/test/e2e/fixtures.ts b/suites/playwright-tracing-app/test/e2e/fixtures.ts index dff40a3..ce877f7 100644 --- a/suites/playwright-tracing-app/test/e2e/fixtures.ts +++ b/suites/playwright-tracing-app/test/e2e/fixtures.ts @@ -1,33 +1,17 @@ import type { Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; -import { ensureMockAuthEmailStrategy, seedOidcSessionViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; export { expect }; type TestFixtures = object; -type WorkerFixtures = { - mockAuthReady: void; -}; - async function signInAndLoad(page: Page) { - await seedOidcSessionViaMockAuth(page); + await signInViaOidc(page); } -export const test = base.extend({ - mockAuthReady: [ - async ({ playwright }, run) => { - const request = await playwright.request.newContext(); - try { - await ensureMockAuthEmailStrategy(request); - await run(); - } finally { - await request.dispose(); - } - }, - { scope: 'worker' }, - ], - page: async ({ page, mockAuthReady: _mockAuthReady }, runPage) => { +export const test = base.extend({ + page: async ({ page }, runPage) => { page.on('console', (msg) => { if (msg.type() === 'error') { console.log('[browser-error]', msg.text()); diff --git a/suites/playwright-tracing-app/test/e2e/message-deeplink-oidc.spec.ts b/suites/playwright-tracing-app/test/e2e/message-deeplink-oidc.spec.ts index 311edbc..ffb693c 100644 --- a/suites/playwright-tracing-app/test/e2e/message-deeplink-oidc.spec.ts +++ b/suites/playwright-tracing-app/test/e2e/message-deeplink-oidc.spec.ts @@ -1,25 +1,17 @@ import { test, expect } from '@playwright/test'; import { readOidcSession } from './oidc-helpers'; -import { clearAuthState, completeMockAuthLogin, ensureMockAuthEmailStrategy } from './sign-in-helper'; +import { clearAuthState, completeOidcLogin, signInViaOidc } from './sign-in-helper'; import { createFullChainRun } from './tracing-run'; -test.describe('message deep link oidc callback', { tag: ['@svc_tracing_app', '@svc_agents_orchestrator'] }, () => { - test.beforeAll(async ({ playwright }) => { - const request = await playwright.request.newContext(); - try { - await ensureMockAuthEmailStrategy(request); - } finally { - await request.dispose(); - } - }); +function isTimeoutError(error: unknown): error is Error { + return error instanceof Error && error.name === 'TimeoutError'; +} +test.describe('message deep link oidc callback', { tag: ['@svc_tracing_app', '@svc_agents_orchestrator'] }, () => { test('returns to deep link after login', async ({ page }) => { test.setTimeout(8 * 60_000); - await page.goto('/'); - const initialCallback = page.waitForURL(/\/callback/, { timeout: 60000 }); - await completeMockAuthLogin(page); - await initialCallback; + await signInViaOidc(page); await expect .poll(async () => { @@ -35,24 +27,47 @@ test.describe('message deep link oidc callback', { tag: ['@svc_tracing_app', '@s const messageUrl = `/message/${run.messageId}?orgId=${run.organizationId}`; await page.goto(messageUrl); - const callbackPromise = page.waitForURL(/\/callback/, { timeout: 60000 }); - await completeMockAuthLogin(page); - await callbackPromise; + const callbackPromise = page.waitForURL(/\/callback/, { timeout: 60000 }).catch((error) => { + if (isTimeoutError(error)) { + return null; + } + throw error; + }); + const completed = await completeOidcLogin(page); + if (completed) { + await callbackPromise; + } - const messageUrlPattern = new RegExp(`/message/${run.messageId}\\?orgId=${run.organizationId}$`); const runUrlPattern = new RegExp(`/${run.organizationId}/runs/${run.runId}(\\?.*)?$`); - const finalUrlPattern = new RegExp(`${messageUrlPattern.source}|${runUrlPattern.source}`); - await expect(page).toHaveURL(finalUrlPattern, { timeout: 60000 }); - - const currentUrl = page.url(); - if (runUrlPattern.test(currentUrl)) { - const runIdFromUrl = new URL(currentUrl).pathname.split('/').pop(); - if (!runIdFromUrl) { - throw new Error(`Run redirect URL did not include a run id: ${currentUrl}`); - } - expect(runIdFromUrl).toBe(run.runId); - } else { - await expect(page).toHaveURL(messageUrlPattern); + await expect(page).toHaveURL(runUrlPattern, { timeout: 60000 }); + + const currentUrl = new URL(page.url()); + const runIdFromUrl = currentUrl.pathname.split('/').pop(); + if (!runIdFromUrl || !/^[0-9a-f]{32}$/i.test(runIdFromUrl)) { + throw new Error(`Run redirect URL did not include a run id: ${page.url()}`); + } + expect(runIdFromUrl).toBe(run.runId); + + await expect(page.getByTestId('run-summary-status')).toContainText(/finished/i, { timeout: 120000 }); + + const eventsList = page.getByTestId('run-events-list'); + await expect(eventsList).toBeVisible(); + const eventItems = eventsList.locator('[data-testid^="run-event-"]'); + await expect.poll(() => eventItems.count(), { timeout: 120000 }).toBeGreaterThanOrEqual(5); + + await expect(eventsList.getByRole('button', { name: /create_entities/ })).toBeVisible(); + await expect(eventsList.getByRole('button', { name: /list_directory/ })).toBeVisible(); + + const messageEvent = eventsList.getByRole('button', { name: /Message • Source/ }).first(); + await messageEvent.click(); + await expect(page.getByTestId('run-event-details-message-content')).toContainText(run.prompt); + + const llmEvents = eventsList.getByRole('button', { name: /LLM Call/ }); + const llmCount = await llmEvents.count(); + if (llmCount === 0) { + throw new Error('Expected at least one LLM call event in the timeline.'); } + await llmEvents.nth(llmCount - 1).click(); + await expect(page.getByTestId('run-event-details-llm-output')).toContainText(run.expectedResponse); }); }); diff --git a/suites/playwright-tracing-app/test/e2e/sign-in-helper.ts b/suites/playwright-tracing-app/test/e2e/sign-in-helper.ts index 219637e..ec31bf2 100644 --- a/suites/playwright-tracing-app/test/e2e/sign-in-helper.ts +++ b/suites/playwright-tracing-app/test/e2e/sign-in-helper.ts @@ -1,12 +1,10 @@ -import type { APIRequestContext, Locator, Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; -import { User, type IdTokenClaims } from 'oidc-client-ts'; -import { createHash, randomBytes } from 'node:crypto'; import { readOidcSession } from './oidc-helpers'; const defaultEmail = 'e2e-tester@agyn.test'; -type SeedOidcOptions = { +type SignInOptions = { onLoginPage?: (page: Page) => Promise; force?: boolean; email?: string; @@ -16,98 +14,56 @@ type SeedOidcOptions = { type BrowserLoginOptions = { onLoginPage?: (page: Page) => Promise; email?: string; + timeoutMs?: number; }; -type OidcRuntimeConfig = { - authority: string; - clientId: string; - scope: string; -}; - -type TokenResponse = { - access_token?: string; - id_token?: string; - refresh_token?: string; - token_type?: string; - scope?: string; - expires_in?: number; - session_state?: string; -}; - -function resolveBaseUrl(): string { - const baseUrl = process.env.E2E_BASE_URL; - if (!baseUrl) { - throw new Error('E2E_BASE_URL is required to run e2e tests.'); - } - return baseUrl; -} - -function stripTrailingSlash(value: string): string { - return value.replace(/\/+$/, ''); -} - -function base64UrlEncode(buffer: Buffer): string { - return buffer - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} - -function createPkcePair(): { codeVerifier: string; codeChallenge: string } { - const codeVerifier = base64UrlEncode(randomBytes(32)); - const codeChallenge = base64UrlEncode(createHash('sha256').update(codeVerifier).digest()); - return { codeVerifier, codeChallenge }; +function isTimeoutError(error: unknown): error is Error { + return error instanceof Error && error.name === 'TimeoutError'; } -function randomState(length = 16): string { - return base64UrlEncode(randomBytes(length)); +async function waitForLocator(locator: Locator, timeout: number): Promise { + try { + await locator.waitFor({ timeout }); + return true; + } catch (error) { + if (isTimeoutError(error)) { + return false; + } + throw error; + } } -function decodeJwtPayload(token: string): IdTokenClaims { - const parts = token.split('.'); - if (parts.length < 2) { - throw new Error('MockAuth id token is malformed.'); - } - const payload = parts[1]; - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); - const decoded = Buffer.from(padded, 'base64').toString('utf8'); - const parsed = JSON.parse(decoded); - if (!parsed || typeof parsed !== 'object') { - throw new Error('MockAuth id token payload is invalid.'); - } - const claims = parsed as Record; - const required: Array = ['sub', 'iss', 'aud', 'exp', 'iat']; - for (const key of required) { - if (typeof claims[key] === 'undefined') { - throw new Error(`MockAuth id token missing ${key}.`); +async function isLocatorVisible(locator: Locator, timeout: number): Promise { + try { + return await locator.isVisible({ timeout }); + } catch (error) { + if (isTimeoutError(error)) { + return false; } + throw error; } - return claims as IdTokenClaims; } export async function clearAuthState(page: Page): Promise { - await page.context().clearCookies(); - const expectedOrigin = new URL(resolveBaseUrl()).origin; - const clearedKey = 'e2e:oidc-cleared'; - const clearStorage = ({ origin, key }: { origin: string; key: string }) => { - if (window.location.origin !== origin) return; - if (window.sessionStorage.getItem(key)) return; + await page.evaluate(() => { window.sessionStorage.clear(); window.localStorage.clear(); - window.sessionStorage.setItem(key, 'true'); - }; - - await page.addInitScript(clearStorage, { origin: expectedOrigin, key: clearedKey }); + }); + await page.context().clearCookies(); +} - const currentOrigin = new URL(page.url()).origin; - if (currentOrigin === expectedOrigin) { - await page.evaluate(clearStorage, { origin: expectedOrigin, key: clearedKey }); - } +async function waitForLoginForm(page: Page, timeoutMs: number): Promise { + const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/i }); + const emailInput = page.getByTestId('login-email-input'); + const usernameInput = page.getByTestId('login-username-input'); + return Promise.race([ + waitForLocator(loginHeading, timeoutMs), + waitForLocator(emailInput, timeoutMs), + waitForLocator(usernameInput, timeoutMs), + ]); } -async function fillMockAuthLoginForm( +async function fillLoginForm( page: Page, expectedEmail: string, onLoginPage?: (page: Page) => Promise, @@ -133,234 +89,61 @@ async function fillMockAuthLoginForm( await expect(usernameInput).toBeVisible({ timeout: 5000 }); await usernameInput.fill(expectedEmail); } - await page.getByRole('button', { name: 'Continue' }).click(); -} - -function readEnvValue(body: string, key: string): string | undefined { - const matcher = new RegExp(`${key}:\\s*"([^"]*)"`); - const match = body.match(matcher); - return match ? match[1] : undefined; -} - -function isTimeoutError(error: unknown): error is Error { - return error instanceof Error && error.name === 'TimeoutError'; -} - -async function isLocatorVisible(locator: Locator, timeout: number): Promise { - try { - return await locator.isVisible({ timeout }); - } catch (error) { - if (isTimeoutError(error)) { - return false; - } - throw error; - } -} - -async function waitForLocator(locator: Locator, timeout: number): Promise { - try { - await locator.waitFor({ timeout }); - return true; - } catch (error) { - if (isTimeoutError(error)) { - return false; - } - throw error; - } -} - -async function resolveRuntimeEnv(request: APIRequestContext): Promise> { - const response = await request.get(new URL('/env.js', resolveBaseUrl()).toString()); - if (!response.ok()) { - throw new Error(`Failed to load runtime env.js (${response.status()}).`); - } - const body = await response.text(); - return { - OIDC_AUTHORITY: readEnvValue(body, 'OIDC_AUTHORITY'), - OIDC_CLIENT_ID: readEnvValue(body, 'OIDC_CLIENT_ID'), - OIDC_SCOPE: readEnvValue(body, 'OIDC_SCOPE'), - }; -} - -async function resolveOidcConfig(request: APIRequestContext): Promise { - const env = await resolveRuntimeEnv(request); - const authority = stripTrailingSlash(process.env.E2E_OIDC_AUTHORITY ?? env.OIDC_AUTHORITY ?? ''); - const clientId = process.env.E2E_OIDC_CLIENT_ID ?? env.OIDC_CLIENT_ID ?? ''; - const scope = process.env.E2E_OIDC_SCOPE ?? env.OIDC_SCOPE ?? ''; - - if (!authority || !clientId || !scope) { - throw new Error('OIDC config is missing (authority, client ID, or scope).'); - } - return { authority, clientId, scope }; -} - -export async function ensureMockAuthEmailStrategy(request: APIRequestContext): Promise { - const config = await resolveOidcConfig(request); - const mockAuthOrigin = new URL(config.authority).origin; - const response = await request.post(new URL('/api/test/client-auth-strategies', mockAuthOrigin).toString(), { - headers: { 'Content-Type': 'application/json' }, - data: { - clientId: config.clientId, - strategies: { - username: { enabled: true, subSource: 'entered' }, - email: { enabled: true, subSource: 'entered', emailVerifiedMode: 'true' }, - }, - }, - }); - if (response.status() === 404) { - const body = await response.text(); - console.warn(`MockAuth test routes disabled; skipping email strategy enablement. (${body})`); - return; - } - if (!response.ok()) { - const body = await response.text(); - throw new Error(`Failed to enable MockAuth email strategy (${response.status()}): ${body}`); - } -} - -function resolveRedirectUri(): string { - return new URL('/callback', resolveBaseUrl()).toString(); -} - -async function waitForRedirectResponse(page: Page, redirectUri: string) { - return page.waitForResponse((response) => { - if (response.status() < 300 || response.status() >= 400) return false; - const location = response.headers()['location']; - return Boolean(location && location.startsWith(redirectUri)); - }); -} -async function exchangeAuthCode( - config: OidcRuntimeConfig, - params: { code: string; codeVerifier: string; redirectUri: string }, -): Promise { - const tokenUrl = `${config.authority}/token`; - const body = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: config.clientId, - redirect_uri: params.redirectUri, - code: params.code, - code_verifier: params.codeVerifier, - }); - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`MockAuth token exchange failed (${response.status}): ${text}`); - } - return (await response.json()) as TokenResponse; + await page.getByRole('button', { name: 'Continue' }).click(); } -function buildUserStorage(config: OidcRuntimeConfig, tokens: TokenResponse): { storageKey: string; storageValue: string } { - if (!tokens.access_token || !tokens.id_token) { - throw new Error('MockAuth token response missing access or id token.'); - } - const profile = decodeJwtPayload(tokens.id_token); - const expiresAt = tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : undefined; - const user = new User({ - id_token: tokens.id_token, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - token_type: tokens.token_type ?? 'Bearer', - scope: tokens.scope, - profile, - expires_at: expiresAt, - session_state: tokens.session_state ?? null, - }); - return { - storageKey: `oidc.user:${config.authority}:${config.clientId}`, - storageValue: user.toStorageString(), - }; +async function waitForOidcSession(page: Page, timeoutMs: number): Promise { + await expect + .poll(async () => { + const session = await readOidcSession(page); + return session?.accessToken ?? ''; + }, { timeout: timeoutMs }) + .not.toBe(''); } -async function seedOidcSession( - page: Page, - tokens: TokenResponse, - config: OidcRuntimeConfig, - options: SeedOidcOptions, -): Promise { - const { storageKey, storageValue } = buildUserStorage(config, tokens); - const expectedOrigin = new URL(resolveBaseUrl()).origin; - - await page.addInitScript( - ({ key, value, origin }) => { - if (window.location.origin !== origin) return; - const seededKey = 'e2e:oidc-seeded'; - if (window.sessionStorage.getItem(seededKey)) return; - window.sessionStorage.setItem(key, value); - window.sessionStorage.setItem(seededKey, 'true'); - }, - { key: storageKey, value: storageValue, origin: expectedOrigin }, - ); - - const landingPath = options.landingPath ?? '/'; - await page.goto(landingPath); - const session = await readOidcSession(page); - if (!session?.accessToken) { - throw new Error('MockAuth session storage was not initialized.'); +export async function completeOidcLogin(page: Page, options: BrowserLoginOptions = {}): Promise { + const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; + const timeoutMs = options.timeoutMs ?? 30000; + const loginReady = await waitForLoginForm(page, timeoutMs); + if (!loginReady) { + return false; } + await fillLoginForm(page, expectedEmail, options.onLoginPage); + return true; } -export async function seedOidcSessionViaMockAuth(page: Page, options: SeedOidcOptions = {}): Promise { +export async function signInViaOidc(page: Page, options: SignInOptions = {}): Promise { const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; - const config = await resolveOidcConfig(page.context().request); - const redirectUri = resolveRedirectUri(); - const { codeVerifier, codeChallenge } = createPkcePair(); - const state = randomState(); - const nonce = randomState(); + const forceLogin = options.force ?? false; + const landingPath = options.landingPath ?? '/'; - if (options.force) { + await page.goto(landingPath); + if (forceLogin) { await clearAuthState(page); + await page.goto(landingPath); } - const authorizeUrl = new URL(`${config.authority}/authorize`); - authorizeUrl.searchParams.set('client_id', config.clientId); - authorizeUrl.searchParams.set('redirect_uri', redirectUri); - authorizeUrl.searchParams.set('response_type', 'code'); - authorizeUrl.searchParams.set('scope', config.scope); - authorizeUrl.searchParams.set('state', state); - authorizeUrl.searchParams.set('nonce', nonce); - authorizeUrl.searchParams.set('code_challenge', codeChallenge); - authorizeUrl.searchParams.set('code_challenge_method', 'S256'); - - const redirectResponsePromise = waitForRedirectResponse(page, redirectUri); - await page.goto(authorizeUrl.toString()); - - const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/ }); - const loginReady = await Promise.race([ - waitForLocator(loginHeading, 10000), - redirectResponsePromise.then(() => false), - ]); - - if (loginReady) { - await fillMockAuthLoginForm(page, expectedEmail, options.onLoginPage); + let loginReady = await waitForLoginForm(page, 10000); + if (!loginReady) { + loginReady = await waitForLoginForm(page, 15000); } - const redirectResponse = await redirectResponsePromise; - const location = redirectResponse.headers()['location']; - if (!location) { - throw new Error('MockAuth redirect missing location header.'); - } - const callback = new URL(location); - const code = callback.searchParams.get('code'); - const returnedState = callback.searchParams.get('state'); - if (!code || !returnedState) { - throw new Error('MockAuth callback missing code or state.'); - } - if (returnedState !== state) { - throw new Error('MockAuth callback state mismatch.'); + if (loginReady) { + const callbackPromise = page.waitForURL(/\/callback/, { timeout: 60000 }).catch((error) => { + if (isTimeoutError(error)) { + return null; + } + throw error; + }); + const completed = await completeOidcLogin(page, { email: expectedEmail, onLoginPage: options.onLoginPage }); + if (completed) { + await callbackPromise; + await waitForOidcSession(page, 60000); + } + return true; } - const tokens = await exchangeAuthCode(config, { code, codeVerifier, redirectUri }); - await seedOidcSession(page, tokens, config, options); -} - -export async function completeMockAuthLogin(page: Page, options: BrowserLoginOptions = {}): Promise { - const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; - const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/ }); - await expect(loginHeading).toBeVisible({ timeout: 30000 }); - await fillMockAuthLoginForm(page, expectedEmail, options.onLoginPage); + await waitForOidcSession(page, 30000); + return false; } diff --git a/suites/playwright/test/e2e/fixtures.ts b/suites/playwright/test/e2e/fixtures.ts index 0ac6d20..8902892 100644 --- a/suites/playwright/test/e2e/fixtures.ts +++ b/suites/playwright/test/e2e/fixtures.ts @@ -1,33 +1,17 @@ import type { Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; -import { ensureMockAuthEmailStrategy, signInViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; export { expect }; type TestFixtures = {}; -type WorkerFixtures = { - mockAuthReady: void; -}; - async function signInAndLoad(page: Page) { - await signInViaMockAuth(page); + await signInViaOidc(page); } -export const test = base.extend({ - mockAuthReady: [ - async ({ playwright }, use) => { - const request = await playwright.request.newContext(); - try { - await ensureMockAuthEmailStrategy(request); - await use(); - } finally { - await request.dispose(); - } - }, - { scope: 'worker' }, - ], - page: async ({ page, mockAuthReady: _mockAuthReady }, runPage) => { +export const test = base.extend({ + page: async ({ page }, runPage) => { page.on('console', (msg) => { if (msg.type() === 'error') { console.log('[browser-error]', msg.text()); diff --git a/suites/playwright/test/e2e/sign-in-helper.ts b/suites/playwright/test/e2e/sign-in-helper.ts index 3b827ac..ff71d3b 100644 --- a/suites/playwright/test/e2e/sign-in-helper.ts +++ b/suites/playwright/test/e2e/sign-in-helper.ts @@ -1,7 +1,5 @@ -import type { APIRequestContext, Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; -import { User, type IdTokenClaims } from 'oidc-client-ts'; -import { createHash, randomBytes } from 'node:crypto'; import { ensureClusterAdmin } from './console-api'; import { readOidcSession } from './oidc-helpers'; @@ -10,322 +8,171 @@ const defaultEmail = 'e2e-tester@agyn.test'; type SignInOptions = { onLoginPage?: (page: Page) => Promise; force?: boolean; + ensureAdmin?: boolean; }; -type SeedOidcOptions = SignInOptions & { +type BrowserLoginOptions = { + onLoginPage?: (page: Page) => Promise; email?: string; - landingPath?: string; -}; - -type OidcRuntimeConfig = { - authority: string; - clientId: string; - scope: string; -}; - -type TokenResponse = { - access_token?: string; - id_token?: string; - refresh_token?: string; - token_type?: string; - scope?: string; - expires_in?: number; - session_state?: string; + timeoutMs?: number; }; -function resolveBaseUrl(): string { - const baseUrl = process.env.E2E_BASE_URL; - if (!baseUrl) { - throw new Error('E2E_BASE_URL is required to run e2e tests.'); - } - return baseUrl; -} - -function stripTrailingSlash(value: string): string { - return value.replace(/\/+$/, ''); -} - -function base64UrlEncode(buffer: Buffer): string { - return buffer - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} - -function createPkcePair(): { codeVerifier: string; codeChallenge: string } { - const codeVerifier = base64UrlEncode(randomBytes(32)); - const codeChallenge = base64UrlEncode(createHash('sha256').update(codeVerifier).digest()); - return { codeVerifier, codeChallenge }; +function isTimeoutError(error: unknown): error is Error { + return error instanceof Error && error.name === 'TimeoutError'; } -function randomState(length = 16): string { - return base64UrlEncode(randomBytes(length)); +async function waitForLocator(locator: Locator, timeout: number): Promise { + try { + await locator.waitFor({ timeout }); + return true; + } catch (error) { + if (isTimeoutError(error)) { + return false; + } + throw error; + } } -function decodeJwtPayload(token: string): IdTokenClaims { - const parts = token.split('.'); - if (parts.length < 2) { - throw new Error('MockAuth id token is malformed.'); - } - const payload = parts[1]; - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); - const decoded = Buffer.from(padded, 'base64').toString('utf8'); - const parsed = JSON.parse(decoded); - if (!parsed || typeof parsed !== 'object') { - throw new Error('MockAuth id token payload is invalid.'); - } - const claims = parsed as Record; - const required: Array = ['sub', 'iss', 'aud', 'exp', 'iat']; - for (const key of required) { - if (typeof claims[key] === 'undefined') { - throw new Error(`MockAuth id token missing ${key}.`); +async function isLocatorVisible(locator: Locator, timeout: number): Promise { + try { + return await locator.isVisible({ timeout }); + } catch (error) { + if (isTimeoutError(error)) { + return false; } + throw error; } - return claims as IdTokenClaims; } async function clearAuthState(page: Page): Promise { - await page.context().clearCookies(); - await page.addInitScript(() => { + await page.evaluate(() => { window.sessionStorage.clear(); window.localStorage.clear(); }); + await page.context().clearCookies(); } -function readEnvValue(body: string, key: string): string | undefined { - const matcher = new RegExp(`${key}:\\s*"([^"]*)"`); - const match = body.match(matcher); - return match ? match[1] : undefined; -} - -async function resolveRuntimeEnv(request: APIRequestContext): Promise> { - const response = await request.get(new URL('/env.js', resolveBaseUrl()).toString()); - if (!response.ok()) { - throw new Error(`Failed to load runtime env.js (${response.status()}).`); +async function waitForAppReady(appReady: Locator, timeoutMs: number): Promise<'app' | null> { + try { + await appReady.waitFor({ timeout: timeoutMs }); + return 'app'; + } catch (error) { + if (isTimeoutError(error)) { + return null; + } + throw error; } - const body = await response.text(); - return { - OIDC_AUTHORITY: readEnvValue(body, 'OIDC_AUTHORITY'), - OIDC_CLIENT_ID: readEnvValue(body, 'OIDC_CLIENT_ID'), - OIDC_SCOPE: readEnvValue(body, 'OIDC_SCOPE'), - }; } -async function resolveOidcConfig(request: APIRequestContext): Promise { - const env = await resolveRuntimeEnv(request); - const authority = stripTrailingSlash(process.env.E2E_OIDC_AUTHORITY ?? env.OIDC_AUTHORITY ?? ''); - const clientId = process.env.E2E_OIDC_CLIENT_ID ?? env.OIDC_CLIENT_ID ?? ''; - const scope = process.env.E2E_OIDC_SCOPE ?? env.OIDC_SCOPE ?? ''; - - if (!authority || !clientId || !scope) { - throw new Error('OIDC config is missing (authority, client ID, or scope).'); - } - return { authority, clientId, scope }; +async function waitForLoginForm(page: Page, timeoutMs: number): Promise { + const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/i }); + const emailInput = page.getByTestId('login-email-input'); + const usernameInput = page.getByTestId('login-username-input'); + return Promise.race([ + waitForLocator(loginHeading, timeoutMs), + waitForLocator(emailInput, timeoutMs), + waitForLocator(usernameInput, timeoutMs), + ]); } -export async function ensureMockAuthEmailStrategy(request: APIRequestContext): Promise { - const config = await resolveOidcConfig(request); - const mockAuthOrigin = new URL(config.authority).origin; - const response = await request.post(new URL('/api/test/client-auth-strategies', mockAuthOrigin).toString(), { - headers: { 'Content-Type': 'application/json' }, - data: { - clientId: config.clientId, - strategies: { - username: { enabled: true, subSource: 'entered' }, - email: { enabled: true, subSource: 'entered', emailVerifiedMode: 'true' }, - }, - }, - }); - if (response.status() === 404) { - const body = await response.text(); - console.warn(`MockAuth test routes disabled; skipping email strategy enablement. (${body})`); - return; - } - if (!response.ok()) { - const body = await response.text(); - throw new Error(`Failed to enable MockAuth email strategy (${response.status()}): ${body}`); +async function fillLoginForm( + page: Page, + expectedEmail: string, + onLoginPage?: (page: Page) => Promise, +): Promise { + if (onLoginPage) { + await onLoginPage(page); } -} -function resolveRedirectUri(): string { - return new URL('/callback', resolveBaseUrl()).toString(); -} - -async function waitForRedirectResponse(page: Page, redirectUri: string) { - return page.waitForResponse((response) => { - if (response.status() < 300 || response.status() >= 400) return false; - const location = response.headers()['location']; - return Boolean(location && location.startsWith(redirectUri)); - }); -} - -async function exchangeAuthCode( - config: OidcRuntimeConfig, - params: { code: string; codeVerifier: string; redirectUri: string }, -): Promise { - const tokenUrl = `${config.authority}/token`; - const body = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: config.clientId, - redirect_uri: params.redirectUri, - code: params.code, - code_verifier: params.codeVerifier, - }); - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`MockAuth token exchange failed (${response.status}): ${text}`); + const strategyTabs = page.getByTestId('login-strategy-tabs'); + if (await isLocatorVisible(strategyTabs, 2000)) { + const emailTab = strategyTabs.getByRole('tab', { name: 'Email' }); + if (await isLocatorVisible(emailTab, 2000)) { + await emailTab.click(); + } } - return (await response.json()) as TokenResponse; -} -function buildUserStorage(config: OidcRuntimeConfig, tokens: TokenResponse): { storageKey: string; storageValue: string } { - if (!tokens.access_token || !tokens.id_token) { - throw new Error('MockAuth token response missing access or id token.'); + const emailInput = page.getByTestId('login-email-input'); + if ((await emailInput.count()) > 0) { + await expect(emailInput).toBeVisible({ timeout: 5000 }); + await emailInput.fill(expectedEmail); + } else { + const usernameInput = page.getByTestId('login-username-input'); + await expect(usernameInput).toBeVisible({ timeout: 5000 }); + await usernameInput.fill(expectedEmail); } - const profile = decodeJwtPayload(tokens.id_token); - const expiresAt = tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : undefined; - const user = new User({ - id_token: tokens.id_token, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - token_type: tokens.token_type ?? 'Bearer', - scope: tokens.scope, - profile, - expires_at: expiresAt, - session_state: tokens.session_state ?? null, - }); - return { - storageKey: `oidc.user:${config.authority}:${config.clientId}`, - storageValue: user.toStorageString(), - }; -} -async function seedOidcSession( - page: Page, - tokens: TokenResponse, - config: OidcRuntimeConfig, - options: SeedOidcOptions, -): Promise { - const { storageKey, storageValue } = buildUserStorage(config, tokens); - const expectedOrigin = new URL(resolveBaseUrl()).origin; + await page.getByRole('button', { name: 'Continue' }).click(); +} - await page.addInitScript( - ({ key, value, origin }) => { - if (window.location.origin !== origin) return; - const seededKey = 'e2e:oidc-seeded'; - if (window.sessionStorage.getItem(seededKey)) return; - window.sessionStorage.setItem(key, value); - window.sessionStorage.setItem(seededKey, 'true'); - }, - { key: storageKey, value: storageValue, origin: expectedOrigin }, - ); +async function waitForOidcSession(page: Page, timeoutMs: number): Promise { + await expect + .poll(async () => { + const session = await readOidcSession(page); + return session?.accessToken ?? ''; + }, { timeout: timeoutMs }) + .not.toBe(''); +} - const landingPath = options.landingPath ?? '/'; - await page.goto(landingPath); - const session = await readOidcSession(page); - if (!session?.accessToken) { - throw new Error('MockAuth session storage was not initialized.'); +export async function completeOidcLogin(page: Page, options: BrowserLoginOptions = {}): Promise { + const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; + const timeoutMs = options.timeoutMs ?? 30000; + const loginReady = await waitForLoginForm(page, timeoutMs); + if (!loginReady) { + return false; } + await fillLoginForm(page, expectedEmail, options.onLoginPage); + return true; } -export async function seedOidcSessionViaMockAuth(page: Page, options: SeedOidcOptions = {}): Promise { - const expectedEmail = options.email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; - const config = await resolveOidcConfig(page.context().request); - const redirectUri = resolveRedirectUri(); - const { codeVerifier, codeChallenge } = createPkcePair(); - const state = randomState(); - const nonce = randomState(); +export async function signInViaOidc(page: Page, email?: string, options: SignInOptions = {}): Promise { + const expectedEmail = email ?? process.env.E2E_OIDC_EMAIL ?? defaultEmail; + const forceLogin = options.force ?? false; + const ensureAdmin = options.ensureAdmin ?? true; - if (options.force) { + await page.goto('/'); + if (forceLogin) { await clearAuthState(page); + await page.goto('/'); } - const authorizeUrl = new URL(`${config.authority}/authorize`); - authorizeUrl.searchParams.set('client_id', config.clientId); - authorizeUrl.searchParams.set('redirect_uri', redirectUri); - authorizeUrl.searchParams.set('response_type', 'code'); - authorizeUrl.searchParams.set('scope', config.scope); - authorizeUrl.searchParams.set('state', state); - authorizeUrl.searchParams.set('nonce', nonce); - authorizeUrl.searchParams.set('code_challenge', codeChallenge); - authorizeUrl.searchParams.set('code_challenge_method', 'S256'); - - const redirectResponsePromise = waitForRedirectResponse(page, redirectUri); - await page.goto(authorizeUrl.toString()); + const pageTitle = page.getByTestId('page-title'); + const sidebarNav = page.getByTestId('console-sidebar'); + const noAccessState = page.getByTestId('console-no-access'); + const appReady = pageTitle.or(sidebarNav).or(noAccessState); - const loginHeading = page.getByRole('heading', { level: 1, name: /Log in to/ }); - const loginReady = await Promise.race([ - loginHeading.waitFor({ timeout: 10000 }).then(() => true).catch(() => false), - redirectResponsePromise.then(() => false), + let initialState: 'app' | 'login' | null = await Promise.race([ + waitForAppReady(appReady, 10000), + waitForLoginForm(page, 10000).then((ready) => (ready ? ('login' as const) : null)), ]); - if (loginReady) { - if (options.onLoginPage) { - await options.onLoginPage(page); + if (initialState !== 'login' && initialState !== 'app') { + const loginReady = await waitForLoginForm(page, 15000); + if (loginReady) { + initialState = 'login'; } + } - const strategyTabs = page.getByTestId('login-strategy-tabs'); - if (await strategyTabs.isVisible({ timeout: 2000 }).catch(() => false)) { - const emailTab = strategyTabs.getByRole('tab', { name: 'Email' }); - if (await emailTab.isVisible({ timeout: 2000 }).catch(() => false)) { - await emailTab.click(); + if (initialState === 'login') { + const callbackPromise = page.waitForURL(/\/callback/, { timeout: 60000 }).catch((error) => { + if (isTimeoutError(error)) { + return null; } + throw error; + }); + const completed = await completeOidcLogin(page, { email: expectedEmail, onLoginPage: options.onLoginPage }); + if (completed) { + await callbackPromise; + await waitForOidcSession(page, 60000); } - - const emailInput = page.getByTestId('login-email-input'); - if ((await emailInput.count()) > 0) { - await expect(emailInput).toBeVisible({ timeout: 5000 }); - await emailInput.fill(expectedEmail); - } else { - const usernameInput = page.getByTestId('login-username-input'); - await expect(usernameInput).toBeVisible({ timeout: 5000 }); - await usernameInput.fill(expectedEmail); - } - await page.getByRole('button', { name: 'Continue' }).click(); } - const redirectResponse = await redirectResponsePromise; - const location = redirectResponse.headers()['location']; - if (!location) { - throw new Error('MockAuth redirect missing location header.'); - } - const callback = new URL(location); - const code = callback.searchParams.get('code'); - const returnedState = callback.searchParams.get('state'); - if (!code || !returnedState) { - throw new Error('MockAuth callback missing code or state.'); - } - if (returnedState !== state) { - throw new Error('MockAuth callback state mismatch.'); - } - - const tokens = await exchangeAuthCode(config, { code, codeVerifier, redirectUri }); - await seedOidcSession(page, tokens, config, options); -} - -export async function signInViaMockAuth( - page: Page, - email?: string, - options: SignInOptions = {}, -): Promise { - await seedOidcSessionViaMockAuth(page, { - email, - onLoginPage: options.onLoginPage, - force: options.force, - }); - await ensureClusterAdmin(page); - const pageTitle = page.getByTestId('page-title'); - const sidebarNav = page.getByTestId('console-sidebar'); - const noAccessState = page.getByTestId('console-no-access'); - const appReady = pageTitle.or(sidebarNav).or(noAccessState); await page.goto('/'); await expect(appReady.first()).toBeVisible({ timeout: 30000 }); - return true; + + if (ensureAdmin) { + await ensureClusterAdmin(page); + } + + return initialState === 'login'; } diff --git a/suites/playwright/test/e2e/sign-in.spec.ts b/suites/playwright/test/e2e/sign-in.spec.ts index f53fa9f..6b54bcc 100644 --- a/suites/playwright/test/e2e/sign-in.spec.ts +++ b/suites/playwright/test/e2e/sign-in.spec.ts @@ -1,15 +1,15 @@ import { argosScreenshot } from '@argos-ci/playwright'; import { test, expect } from '@playwright/test'; -import { signInViaMockAuth } from './sign-in-helper'; +import { signInViaOidc } from './sign-in-helper'; import { readOidcSession } from './oidc-helpers'; const defaultEmail = 'e2e-tester@agyn.test'; const expectedEmail = process.env.E2E_OIDC_EMAIL ?? defaultEmail; test.describe('sign-in', { tag: ['@svc_console', '@smoke'] }, () => { - test('signs in via mockauth redirect flow', async ({ page }) => { + test('signs in via oidc redirect flow', async ({ page }) => { test.setTimeout(60_000); - const signedIn = await signInViaMockAuth(page, expectedEmail, { + await signInViaOidc(page, expectedEmail, { onLoginPage: async (loginPage) => { const loginHeading = loginPage.getByRole('heading', { level: 1 }); await expect(loginHeading).toContainText('Log in to'); @@ -19,11 +19,6 @@ test.describe('sign-in', { tag: ['@svc_console', '@smoke'] }, () => { const storedUser = await readOidcSession(page); - if (!signedIn) { - expect(storedUser).toBeNull(); - return; - } - expect(storedUser).not.toBeNull(); expect(storedUser?.accessToken).toBeTruthy(); }); diff --git a/suites/playwright/test/e2e/user-directory-api.spec.ts b/suites/playwright/test/e2e/user-directory-api.spec.ts index d992d9f..618f24a 100644 --- a/suites/playwright/test/e2e/user-directory-api.spec.ts +++ b/suites/playwright/test/e2e/user-directory-api.spec.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; -import { ensureMockAuthEmailStrategy, seedOidcSessionViaMockAuth } from './sign-in-helper'; +import { expect, test, type APIRequestContext, type Browser } from '@playwright/test'; +import { signInViaOidc } from './sign-in-helper'; import { readOidcSession } from './oidc-helpers'; const USERS_GATEWAY_PATH = '/api/agynio.api.gateway.v1.UsersGateway'; @@ -24,14 +24,20 @@ function requireAdminToken(): string { return token; } -async function getOidcAccessToken(page: Page, email: string): Promise { - await seedOidcSessionViaMockAuth(page, { email, force: true }); - const session = await readOidcSession(page); - const token = session?.accessToken; - if (!token) { - throw new Error(`OIDC access token missing for ${email}.`); +async function getOidcAccessToken(browser: Browser, email: string): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await signInViaOidc(page, email, { ensureAdmin: false }); + const session = await readOidcSession(page); + const token = session?.accessToken; + if (!token) { + throw new Error(`OIDC access token missing for ${email}.`); + } + return token; + } finally { + await context.close(); } - return token; } type UserDirectoryEntry = { @@ -241,11 +247,7 @@ async function createThreadByNickname( } test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { - test.beforeEach(async ({ request }) => { - await ensureMockAuthEmailStrategy(request); - }); - - test('non-admin SearchUsers redacts profile fields', async ({ request, page }) => { + test('non-admin SearchUsers redacts profile fields', async ({ request, browser }) => { const adminToken = requireAdminToken(); const suffix = randomSuffix(); const targetUsername = `e2e-search-${suffix}`; @@ -264,7 +266,7 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { username: callerUsername, }); - const callerToken = await getOidcAccessToken(page, callerEmail); + const callerToken = await getOidcAccessToken(browser, callerEmail); const me = await getMe(request, callerToken); expect(isClusterAdminRole(me.clusterRole)).toBe(false); const results = await searchUsers(request, callerToken, { prefix: targetUsername }); @@ -276,7 +278,7 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { expect(entry.photoUrl ?? '').toBe('https://example.com/photo.png'); }); - test('invite by username seeds org nickname on accept', async ({ request, page }) => { + test('invite by username seeds org nickname on accept', async ({ request, browser }) => { const adminToken = requireAdminToken(); const suffix = randomSuffix(); const inviterUsername = `e2e-inviter-${suffix}`; @@ -295,7 +297,7 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { name: 'Invitee User', }); - const inviterToken = await getOidcAccessToken(page, inviterEmail); + const inviterToken = await getOidcAccessToken(browser, inviterEmail); const organizationId = await createOrganization(request, inviterToken, `e2e-org-${suffix}`); const results = await searchUsers(request, inviterToken, { prefix: inviteeUsername }); expect(results).toHaveLength(1); @@ -311,7 +313,7 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { }); expect(isPendingMembershipStatus(membership.status)).toBe(true); - const inviteeToken = await getOidcAccessToken(page, inviteeEmail); + const inviteeToken = await getOidcAccessToken(browser, inviteeEmail); await acceptMembership(request, inviteeToken, { membershipId: membership.id }); const thread = await createThreadByNickname(request, inviterToken, { @@ -322,7 +324,7 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { expect(inviterId).not.toBe(inviteeId); }); - test('renaming username does not change existing org nickname', async ({ request, page }) => { + test('renaming username does not change existing org nickname', async ({ request, browser }) => { const adminToken = requireAdminToken(); const suffix = randomSuffix(); const inviterUsername = `e2e-inviter-rename-${suffix}`; @@ -342,14 +344,14 @@ test.describe('user-directory', { tag: ['@svc_console', '@issue140'] }, () => { name: 'Rename User', }); - const inviterToken = await getOidcAccessToken(page, inviterEmail); + const inviterToken = await getOidcAccessToken(browser, inviterEmail); const organizationId = await createOrganization(request, inviterToken, `e2e-org-rename-${suffix}`); const membership = await createMembership(request, inviterToken, { organizationId, identityId: inviteeId, }); expect(isPendingMembershipStatus(membership.status)).toBe(true); - const inviteeToken = await getOidcAccessToken(page, inviteeEmail); + const inviteeToken = await getOidcAccessToken(browser, inviteeEmail); await acceptMembership(request, inviteeToken, { membershipId: membership.id }); await updateUsername(request, inviteeToken, { username: newUsername });