From 15a8fed36a547e2f3dbf623d0ed1c2888f0717d3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 18:24:07 +0200 Subject: [PATCH] fix(portal): drive eSignet in the portal e2e sign-in The e2e helper clicked sign-in and asserted /services, which only holds under the mock provider. Against a hosted stack eSignet answers instead, so six of the eight tests failed on the redirect off-origin. The helper now reports which provider answered and completes an eSignet login when that is the one in front: it reissues the authorization while eSignet is still starting, reads the refusals eSignet returns as HTTP 200 rather than trusting the status, and grants claim consent when asked. The persona tests skip the handoff under eSignet, where the subject comes from UserInfo, and assert the inverse there instead. The hosted login smoke drove the same flow from its own copy, so it now imports the helper. Its output stays a fixed line: the helper names the step that failed and never the codes eSignet returned, and the sanitization test follows the code onto the helper. Signed-off-by: Jeremi Joslin --- justfile | 2 +- portal/e2e/persona.test.ts | 24 +++++- portal/e2e/portal.test.ts | 9 +-- portal/e2e/support/auth.ts | 139 ++++++++++++++++++++++++++++++++ portal/playwright.config.ts | 5 ++ scripts/smoke-esignet-login.mjs | 63 +++------------ scripts/test_smoke_esignet.py | 18 +++-- 7 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 portal/e2e/support/auth.ts 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)