diff --git a/justfile b/justfile index 0be2f77..a2f38cd 100644 --- a/justfile +++ b/justfile @@ -123,7 +123,7 @@ lifecycle-fixture-proof: uv run scripts/lifecycle_proof.py portal-live-e2e: - cd portal && SOLMARA_PORTAL_E2E_MODE=hosted PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}" pnpm e2e + cd portal && SOLMARA_PORTAL_E2E_MODE=hosted PLAYWRIGHT_BASE_URL="${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}}" pnpm e2e home-live-e2e: cd home && SOLMARA_HOME_E2E_MODE=live PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_HOME_PORT:-4301}" pnpm e2e diff --git a/portal/e2e/persona.test.ts b/portal/e2e/persona.test.ts index 1933496..59be332 100644 --- a/portal/e2e/persona.test.ts +++ b/portal/e2e/persona.test.ts @@ -1,4 +1,7 @@ import { test, expect } from '@playwright/test'; +import { beginSignIn, completeSignIn } from './support/auth'; + +const MOCK_ONLY = 'the persona handoff is mock-only: eSignet takes the subject from UserInfo'; // The visitor center hands a persona into the portal as /?persona=. The mock // login must log in as that persona instead of always Elena Dela Cruz. @@ -8,7 +11,9 @@ test('a persona handoff logs in as that persona', async ({ page }) => { // The landing acknowledges the handed-off persona before sign-in. await expect(page.getByTestId('persona-hint')).toContainText('Mateo Santos'); - await page.getByTestId('signin').click(); + const mode = await beginSignIn(page); + test.skip(mode === 'esignet', MOCK_ONLY); + await completeSignIn(page, mode); await expect(page).toHaveURL(/\/services$/); // The authenticated catalog greets the handed-off persona, not the default. @@ -22,7 +27,22 @@ test('an unknown persona hint falls back to the default session', async ({ page await page.goto('/?persona=9999999999'); await expect(page.getByTestId('persona-hint')).toHaveCount(0); - await page.getByTestId('signin').click(); + const mode = await beginSignIn(page); + test.skip(mode === 'esignet', MOCK_ONLY); + await completeSignIn(page, mode); await expect(page).toHaveURL(/\/services$/); await expect(page.getByRole('heading', { name: /Welcome, Elena Dela Cruz/ })).toBeVisible(); }); + +// The same hint under eSignet must not reach the session: the subject comes from +// UserInfo, so a query parameter cannot sign anyone in as someone else. +test('eSignet ignores a persona handoff', async ({ page }) => { + await page.goto('/?persona=2300010248'); + + const mode = await beginSignIn(page); + test.skip(mode === 'mock', 'the mock handoff is covered by the tests above'); + await completeSignIn(page, mode); + await expect(page).toHaveURL(/\/services$/); + + await expect(page.getByRole('heading', { name: /Welcome, Mateo Santos/ })).toHaveCount(0); +}); diff --git a/portal/e2e/portal.test.ts b/portal/e2e/portal.test.ts index 5f2a073..45c9659 100644 --- a/portal/e2e/portal.test.ts +++ b/portal/e2e/portal.test.ts @@ -1,10 +1,5 @@ -import { test, expect, type Page } from '@playwright/test'; - -async function signIn(page: Page) { - await page.goto('/'); - await page.getByTestId('signin').click(); - await expect(page).toHaveURL(/\/services$/); -} +import { test, expect } from '@playwright/test'; +import { signIn } from './support/auth'; // Happy path: landing -> sign in -> catalog -> farmer-voucher -> fields auto-resolve // from the authority -> a proof entry is visible. diff --git a/portal/e2e/support/auth.ts b/portal/e2e/support/auth.ts new file mode 100644 index 0000000..da2a208 --- /dev/null +++ b/portal/e2e/support/auth.ts @@ -0,0 +1,139 @@ +// The eSignet sign-in the portal e2e suite drives is the same one the hosted +// smoke checks, so both import this module. It stays free of runtime imports +// from @playwright/test: the smoke drives a browser it launched itself, outside +// the test runner. It also keeps every eSignet detail out of the errors it +// throws, because the smoke's output is a fixed line on a delivery record. +import type { Page, Response } from '@playwright/test'; + +// The portal's auth provider is server-side configuration the browser cannot +// read, so the suite discovers it from where the sign-in lands: the mock +// provider binds a persona session and continues straight to the catalog, while +// eSignet redirects to its own login and asks the citizen for an OTP. Driving +// whichever answers lets one suite run against a mock build and against a +// hosted deployment. +export type AuthMode = 'mock' | 'esignet'; + +// The synthetic demo identity eSignet is seeded with. 2300018263 is Elena Dela +// Cruz, the same persona the mock session binds, so every assertion that +// follows a sign-in reads the same in both modes. +const DEMO_SUBJECT = process.env.ESIGNET_DEMO_SUBJECT || '2300018263'; +const DEMO_OTP = process.env.ESIGNET_DEMO_OTP || '111111'; + +const CATALOG_PATH = '/services'; +const ESIGNET_LOGIN_PATH = '/login'; + +// One attempt to reach a provider, a few attempts before giving up, and the +// budget for the redirects back from eSignet. The sum has to stay inside the +// per-test timeout the config gives a live run, or a reissued request is cut +// off before it can answer. +const ATTEMPT_TIMEOUT_MS = 15_000; +const PROVIDER_TIMEOUT_MS = 60_000; +const LOGIN_TIMEOUT_MS = 30_000; + +/** Sign in from the landing page and report which provider answered. */ +export async function signIn(page: Page): Promise { + await page.goto('/'); + const mode = await beginSignIn(page); + await completeSignIn(page, mode); + return mode; +} + +/** + * Click the sign-in on the page already loaded and report which provider + * answered. Callers that only apply to one provider use this to decide before + * paying for a full login. + */ +export async function beginSignIn(page: Page): Promise { + await page.getByTestId('signin').click(); + const deadline = Date.now() + PROVIDER_TIMEOUT_MS; + for (;;) { + try { + await page.waitForURL( + (url) => url.pathname === CATALOG_PATH || url.pathname === ESIGNET_LOGIN_PATH, + { timeout: ATTEMPT_TIMEOUT_MS } + ); + return new URL(page.url()).pathname === CATALOG_PATH ? 'mock' : 'esignet'; + } catch (err) { + // eSignet refuses an authorization request while it is still starting and + // the refusal is terminal for that request, so the whole request has to be + // reissued rather than waited out. Past the deadline the timeout stands. + if (Date.now() >= deadline) throw err; + await page.goto('/auth/login'); + } + } +} + +/** Finish an eSignet sign-in. A mock sign-in is already complete. */ +export async function completeSignIn(page: Page, mode: AuthMode): Promise { + if (mode === 'mock') return; + + await page.getByRole('button', { name: 'Verify with OTP' }).click(); + await page.getByRole('textbox', { name: 'UIN/VID' }).fill(DEMO_SUBJECT); + const sendOtp = page.waitForResponse((response) => response.url().includes('/authorization/send-otp')); + await page.getByRole('button', { name: 'Get OTP' }).click(); + await requireNoErrors(await sendOtp, 'send the demo OTP'); + + await enterOtp(page); + const authenticate = page.waitForResponse((response) => response.url().includes('/authenticate')); + await page.getByRole('button', { name: /verify|continue/i }).click(); + await requireNoErrors(await authenticate, 'authenticate the demo identity'); + + // Leaving the eSignet login means the OTP was accepted. What follows is either + // the claim screen or the portal itself. + await page.waitForURL((url) => url.pathname !== ESIGNET_LOGIN_PATH, { timeout: LOGIN_TIMEOUT_MS }); + await grantConsentIfAsked(page); + await page.waitForURL((url) => url.pathname === CATALOG_PATH, { timeout: LOGIN_TIMEOUT_MS }); +} + +// eSignet reports a refused request as a 200 carrying an error list, so the +// response has to be read rather than trusted. An identity eSignet does not hold +// still passes send-otp and is only denied at authenticate, where the NIA Relay +// lookup answers, so both steps are checked. Naming the step is the whole +// message: the codes eSignet returns stay out of the smoke's output. +async function requireNoErrors(response: Response, step: string): Promise { + const body = (await response.json()) as { errors?: unknown[] }; + if (body.errors?.length) { + throw new Error(`eSignet refused to ${step}`); + } +} + +// This eSignet build renders one input per OTP digit and advances the focus +// itself as each digit arrives, so the digits are typed into the field the +// component currently owns and paced to let that focus move. An older build +// renders a single input instead. +async function enterOtp(page: Page): Promise { + const digits = page.locator('input[type="tel"]'); + await digits.first().waitFor({ timeout: ATTEMPT_TIMEOUT_MS }); + if ((await digits.count()) === 1) { + await digits.fill(DEMO_OTP); + return; + } + for (const digit of DEMO_OTP) { + await digits.first().press(digit); + await page.waitForTimeout(100); + } +} + +// eSignet asks for claim consent the first time a subject signs in to this +// client and replays the stored consent on every later sign-in, so the flow +// either stops on the consent screen or carries on to the portal by itself. +async function grantConsentIfAsked(page: Page): Promise { + const consent = page.getByRole('button', { name: /allow|consent|continue|accept/i }).first(); + const catalog = page.getByRole('heading', { name: /^Welcome, / }); + await consent.or(catalog).first().waitFor({ timeout: LOGIN_TIMEOUT_MS }); + if (!(await consent.isVisible())) return; + + // Grant every claim the portal asked for. These are the synthetic profile + // claims of the demo identity; the portal only keeps the subject and name. + const allClaims = page.getByRole('checkbox', { name: 'voluntary_claims' }); + if (await allClaims.count()) { + await allClaims.check({ force: true }); + } else { + const claims = page.locator('input[type="checkbox"]'); + for (let index = 0; index < (await claims.count()); index += 1) { + const claim = claims.nth(index); + if (!(await claim.isChecked())) await claim.check({ force: true }); + } + } + await consent.click(); +} diff --git a/portal/playwright.config.ts b/portal/playwright.config.ts index d7db893..a64a450 100644 --- a/portal/playwright.config.ts +++ b/portal/playwright.config.ts @@ -5,6 +5,11 @@ const hostedMode = process.env.SOLMARA_PORTAL_E2E_MODE === 'hosted'; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`; export default defineConfig({ + // A live stack may put eSignet in front of the portal, where signing in is a + // full OIDC round trip through another origin and can be reissued while + // eSignet is still starting. The mock build lands on the catalog immediately + // and keeps the default budget. + timeout: hostedMode ? 120_000 : 30_000, // The portal BFF is a single-process server whose proof feed is an in-process // store with a long-lived SSE feed on /proof/stream. Hosted sessions are scoped // per opaque cookie, but the e2e suite still runs serially so trace timing stays diff --git a/scripts/smoke-esignet-login.mjs b/scripts/smoke-esignet-login.mjs index e038f46..aec3fa4 100644 --- a/scripts/smoke-esignet-login.mjs +++ b/scripts/smoke-esignet-login.mjs @@ -1,66 +1,21 @@ #!/usr/bin/env node +// The login itself lives in the portal e2e helper so the smoke and the suite +// drive one flow. Node strips the helper's types on import. import { chromium } from '../portal/node_modules/@playwright/test/index.mjs'; +import { signIn } from '../portal/e2e/support/auth.ts'; const portalUrl = (process.env.SOLMARA_PORTAL_PUBLIC_BASE_URL || 'http://127.0.0.1:4300').replace(/\/$/, ''); -const subject = process.env.ESIGNET_DEMO_SUBJECT || '2300018263'; -const otp = process.env.ESIGNET_DEMO_OTP || '111111'; const browser = await chromium.launch({ headless: true }); try { - const page = await browser.newPage(); - const deadline = Date.now() + 60_000; - while (true) { - await page.goto(`${portalUrl}/auth/login`); - try { - await page.getByRole('button', { name: 'Verify with OTP' }).waitFor({ timeout: 5_000 }); - break; - } catch { - // eSignet may still be starting. Retry the whole authorization request. - } - if (Date.now() >= deadline) { - throw new Error('eSignet login did not become ready'); - } - await page.waitForTimeout(1_000); + const page = await browser.newPage({ baseURL: portalUrl }); + const mode = await signIn(page); + // A mock sign-in also reaches the catalog, so reaching it is not the check. + // This smoke passes only when eSignet is the provider that answered. + if (mode !== 'esignet') { + throw new Error('the portal signed in without eSignet'); } - await page.getByRole('button', { name: 'Verify with OTP' }).click(); - await page.getByRole('textbox', { name: 'UIN/VID' }).fill(subject); - const sendOtpResponse = page.waitForResponse((response) => response.url().includes('/authorization/send-otp')); - await page.getByRole('button', { name: 'Get OTP' }).click(); - const sendOtp = await (await sendOtpResponse).json(); - if (Array.isArray(sendOtp.errors) && sendOtp.errors.length) { - throw new Error('eSignet send-otp failed'); - } - const otpInputs = page.locator('input[type="tel"]'); - if (await otpInputs.count() === 1) { - await otpInputs.fill(otp); - } else { - for (const digit of otp) { - await otpInputs.first().press(digit); - await page.waitForTimeout(100); - } - } - await page.getByRole('button', { name: /verify|continue/i }).click(); - const servicesUrl = `${portalUrl}/services`; - await page.waitForURL((url) => url.href === servicesUrl || url.pathname.endsWith('/consent'), { - timeout: 60_000, - }); - if (page.url() !== servicesUrl) { - const consent = page.getByRole('button', { name: /allow|consent|continue|accept/i }); - await consent.first().waitFor({ timeout: 10_000 }); - const claimCheckboxes = page.locator('input[type="checkbox"]'); - const allClaims = page.getByRole('checkbox', { name: 'voluntary_claims' }); - if (await allClaims.count()) { - await allClaims.check({ force: true }); - } else { - for (let index = 0; index < await claimCheckboxes.count(); index += 1) { - const checkbox = claimCheckboxes.nth(index); - if (!(await checkbox.isChecked())) await checkbox.check({ force: true }); - } - } - await consent.first().click(); - } - await page.waitForURL(servicesUrl, { timeout: 60_000 }); console.log('smoke-esignet-login: PASS'); } finally { await browser.close(); diff --git a/scripts/test_smoke_esignet.py b/scripts/test_smoke_esignet.py index 75a7ba2..ea194a2 100644 --- a/scripts/test_smoke_esignet.py +++ b/scripts/test_smoke_esignet.py @@ -153,13 +153,21 @@ def test_browser_smoke_has_only_fixed_sanitized_output(self) -> None: source = (ROOT / "scripts" / "smoke-esignet-login.mjs").read_text() self.assertEqual(source.count("console.log("), 1) self.assertIn("console.log('smoke-esignet-login: PASS')", source) - self.assertIn("getByRole('checkbox', { name: 'voluntary_claims' })", source) - self.assertIn("allClaims.check({ force: true })", source) - self.assertIn("url.pathname.endsWith('/consent')", source) - self.assertIn("timeout: 60_000", source) - self.assertNotIn("error.errorCode", source) self.assertNotIn("page.title()", source) + # The smoke drives the portal e2e sign-in helper rather than its own copy + # of the login, so the guard follows the code: nothing the helper reads + # off eSignet may reach the smoke's output, whether printed or thrown. + helper = (ROOT / "portal" / "e2e" / "support" / "auth.ts").read_text() + self.assertNotIn("console.log(", helper) + self.assertNotIn("errorCode", helper) + self.assertNotIn("page.title()", helper) + self.assertIn("getByRole('checkbox', { name: 'voluntary_claims' })", helper) + self.assertIn("allClaims.check({ force: true })", helper) + # eSignet refuses authorization while it is still starting, so the helper + # keeps reissuing the request for as long as the smoke used to. + self.assertIn("PROVIDER_TIMEOUT_MS = 60_000", helper) + seed_source = (ROOT / "scripts" / "seed-esignet.py").read_text() self.assertNotIn("Local static OTP", seed_source) self.assertNotIn("Demo subject available", seed_source)