Accept a verification or recovery code pasted with whitespace around it #199
@@ -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,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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user