Answer stability round 1: 0 🔴, 2 🟡 (0 misread); the submit test pins manual redirects and the 5xx boundary, recovery gets its E2E, the Referer is sent

This commit is contained in:
2026-10-31 12:59:28 +01:00
parent 4779a9a947
commit 29d496a02e
4 changed files with 30 additions and 10 deletions
+1 -1
View File
@@ -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) | | `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 | | `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 | | `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) | | `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 ```bash
+1 -1
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # 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 # 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. # 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 # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
+23 -5
View File
@@ -291,21 +291,39 @@ async function mailedCode(to: string): Promise<string> {
return code; return code;
} }
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => { async function createIdentity(email: string): Promise<void> {
test.setTimeout(90_000);
const email = `verify-${suffix}@plainpages.local`;
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, { const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
body: JSON.stringify({ schema_id: "default", traits: { email } }), body: JSON.stringify({ schema_id: "default", traits: { email } }),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
expect(created.status).toBe(201); 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<void> {
await page.fill('input[name="email"]', email); await page.fill('input[name="email"]', email);
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await expect(page.locator('input[name="code"]')).toBeVisible(); 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(); 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(); 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");
});
+5 -3
View File
@@ -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. // Records each call so a test can assert URL/method/headers/body.
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { 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) => { const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ calls.push({
body: init?.body as string | undefined, body: init?.body as string | undefined,
headers: new Headers(init?.headers), headers: new Headers(init?.headers),
method: init?.method ?? "GET", method: init?.method ?? "GET",
redirect: init?.redirect,
url: String(input), url: String(input),
}); });
return handler(String(input), init); 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.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]!.url, `${BASE}/self-service/recovery?flow=f+1`);
assert.equal(calls[0]!.method, "POST"); 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("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("content-type"), "application/x-www-form-urlencoded");
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc"); assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
assert.equal(calls[0]!.body, "code=123456&method=code"); 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 () => { 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 }); 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 }); 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 () => { test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {