From 29d496a02e79db5ea938c8301fa68fe70f944d3e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 12:59:28 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=202=20=F0=9F=9F=A1=20(0=20misread);=20the=20submi?= =?UTF-8?q?t=20test=20pins=20manual=20redirects=20and=20the=205xx=20bounda?= =?UTF-8?q?ry,=20recovery=20gets=20its=20E2E,=20the=20Referer=20is=20sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- e2e-tests/compose.full.yml | 2 +- e2e-tests/full-flow.spec.ts | 28 +++++++++++++++++++++++----- src/auth/kratos-public.test.ts | 8 +++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8e6b706..cfdb1ed 100644 --- a/README.md +++ b/README.md @@ -1522,7 +1522,7 @@ no Node/browsers on the host. Five suites, each on its own stack: | `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) | | `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared | | `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code | -| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout | +| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, emailed-code verification + recovery, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout | | `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) | ```bash diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index 0ff41c5..de71fb0 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -1,5 +1,5 @@ # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO -# login, emailed-code verification, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A +# login, emailed-code verification + recovery, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 27a4bbf..3ae774e 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -291,21 +291,39 @@ async function mailedCode(to: string): Promise { return code; } -test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => { - test.setTimeout(90_000); - const email = `verify-${suffix}@plainpages.local`; +async function createIdentity(email: string): Promise { const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, { body: JSON.stringify({ schema_id: "default", traits: { email } }), headers: { "content-type": "application/json" }, method: "POST", }); expect(created.status).toBe(201); +} - await page.goto("/verification"); +// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings. +async function enterMailedCode(page: Page, email: string): Promise { await page.fill('input[name="email"]', email); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.locator('input[name="code"]')).toBeVisible(); - await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `); // as a paste from an email often arrives + await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `); await page.getByRole("button", { name: "Continue" }).click(); +} + +test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => { + test.setTimeout(90_000); + const email = `verify-${suffix}@plainpages.local`; + await createIdentity(email); + await page.goto("/verification"); + await enterMailedCode(page, email); await expect(page.getByText("You successfully verified your email address.")).toBeVisible(); }); + +test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => { + test.setTimeout(90_000); + const email = `recover-${suffix}@plainpages.local`; + await createIdentity(email); + await page.goto("/recovery?locale=en-US"); + await enterMailedCode(page, email); + await expect(page).toHaveURL(/\/settings\?.*locale=en-US/); + await expect(page.locator("h1")).toHaveText("Account settings"); +}); diff --git a/src/auth/kratos-public.test.ts b/src/auth/kratos-public.test.ts index e7859ef..2c24726 100644 --- a/src/auth/kratos-public.test.ts +++ b/src/auth/kratos-public.test.ts @@ -17,12 +17,13 @@ function res(status: number, body?: unknown, setCookie: string[] = []): Response // Records each call so a test can assert URL/method/headers/body. function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { - const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = []; + const calls: { body: string | undefined; headers: Headers; method: string; redirect: string | undefined; url: string }[] = []; const fetchImpl = (async (input: unknown, init?: RequestInit) => { calls.push({ body: init?.body as string | undefined, headers: new Headers(init?.headers), method: init?.method ?? "GET", + redirect: init?.redirect, url: String(input), }); return handler(String(input), init); @@ -67,8 +68,9 @@ test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browse assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 }); assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`); assert.equal(calls[0]!.method, "POST"); - // Not JSON: Kratos then answers with the same 303s a browser posting to it directly would get. + assert.equal(calls[0]!.redirect, "manual"); assert.equal(calls[0]!.headers.get("accept"), "text/html"); + assert.equal(calls[0]!.headers.get("referer"), `${BASE}/`); assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded"); assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc"); assert.equal(calls[0]!.body, "code=123456&method=code"); @@ -77,7 +79,7 @@ test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browse test("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => { const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl }); assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 }); - await assert.rejects(k(502).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 502); + await assert.rejects(k(500).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 500); }); test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {