Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 22 additions & 2 deletions portal/e2e/persona.test.ts
Original file line number Diff line number Diff line change
@@ -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=<UIN>. The mock
// login must log in as that persona instead of always Elena Dela Cruz.
Expand All @@ -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.
Expand All @@ -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);
});
9 changes: 2 additions & 7 deletions portal/e2e/portal.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
139 changes: 139 additions & 0 deletions portal/e2e/support/auth.ts
Original file line number Diff line number Diff line change
@@ -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<AuthMode> {
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<AuthMode> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
5 changes: 5 additions & 0 deletions portal/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 9 additions & 54 deletions scripts/smoke-esignet-login.mjs
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
18 changes: 13 additions & 5 deletions scripts/test_smoke_esignet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading