test(e2e): sign in with a password and a passkey, and sign out, against the real server

The login spec stubbed the login route to prove the form shows an error. It
now spends a real 401, and three journeys run on the sign-in account of
their project: a password sign-in that lands on the dashboard as that
account, a sign-out whose old cookie is refused afterwards (with a positive
control before it), and a passkey registered in settings through Chromium's
virtual authenticator that signs the account back in.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:55:19 +02:00
1 parent b826642853
commit 518a381ff9
4 files changed
+245 -50

No files matched your search

+231 -47
View File
@@ -1,24 +1,89 @@
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
* Signing in and out, against the real server.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
* Every test starts without a session and signs in through the form, so what
* it reads is what the login route, the session table and the shell did, not
* a payload typed into the test. The account is the sign-in journey's own
* (one per project, see `critical-fixture.ts`): it gets no jar from global
* setup, because the tests here end the sessions they open and a jar captured
* once would be dead for a retry.
*
* The passkey half runs through Chromium's virtual authenticator, attached
* over CDP. It is a real authenticator as far as the page and the server are
* concerned: the registration and the assertion are signed, and the server
* verifies both against the origin and RP id the suite runs under.
*/
async function openPasswordForm(page: import("@playwright/test").Page) {
test.use({ storageState: { cookies: [], origins: [] } });
const SESSION_COOKIE = "healthlog_session";
async function openPasswordForm(page: Page): Promise<void> {
await page.goto("/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", {
name: /sign in with password|login with password|mit passwort/i,
})
.click();
// The login page opens in passkey mode; the password form mounts after
// this button. The button is server-rendered, so the click is retried
// until the form it mounts is there.
const usePassword = page.getByTestId("login-use-password");
const email = page.getByTestId("login-email");
await expect(async () => {
await usePassword.click({ timeout: 2_000 });
await expect(email).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
}
async function signInWithPassword(
page: Page,
account: CriticalAccount,
): Promise<void> {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
// The login route allows five attempts per IP per quarter hour and the
// whole suite is one IP; see `clearLoginBuckets`.
await clearLoginBuckets();
await page.getByTestId("login-password-submit").click();
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
}
async function sessionCookie(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(
(c) => c.name === SESSION_COOKIE,
);
expect(cookie, "the login answered without a session cookie").toBeTruthy();
return cookie!.value;
}
/** The username `/api/auth/me` answers for this context's session. */
async function signedInAs(page: Page): Promise<string | null> {
const res = await page.request.get("/api/auth/me");
if (res.status() !== 200) return null;
return ((await res.json()) as { data: { username: string } }).data.username;
}
async function signOutThroughTheMenu(page: Page): Promise<void> {
// The desktop sidebar and the phone top bar each carry a user menu; only
// one of them is on screen at a given width.
const trigger = page
.getByRole("button", { name: "User menu" })
.filter({ visible: true })
.first();
const signOut = page.getByRole("menuitem", { name: "Sign out" });
await expect(async () => {
await trigger.click({ timeout: 2_000 });
await expect(signOut).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await signOut.click();
await page.waitForURL((url) => url.pathname === "/auth/login");
}
test.describe("login page", () => {
@@ -27,47 +92,166 @@ test.describe("login page", () => {
}) => {
await openPasswordForm(page);
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
const username = page.getByTestId("login-email");
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.locator('input[type="password"]').first();
const password = page.getByTestId("login-password");
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("rejects an obviously-wrong credential pair", async ({ page }) => {
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await openPasswordForm(page);
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill("not-this-account-password");
await clearLoginBuckets();
const answer = page.waitForResponse(
(res) =>
res.url().endsWith("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
// The form has multiple buttons matching /sign in/ — both the
// passkey CTA and the password submit. Filter to the submit button
// so the click lands on the right one.
await page
.locator('button[type="submit"]')
.filter({ hasText: /login|anmelden|sign in/i })
.click();
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
expect((await answer).status()).toBe(401);
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
expect(
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
).toBe(false);
});
});
test.describe("signing in and out", () => {
test("a password sign-in lands on the dashboard as that account @critical", async ({
page,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
await expect(page.locator("main")).toBeVisible();
expect(await signedInAs(page)).toBe(account.username);
});
test("signing out ends the session on the server, not only in the browser @critical", async ({
page,
context,
playwright,
baseURL,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
const cookie = await sessionCookie(context);
// A second client holding the same cookie, the way a copied jar or a
// stolen cookie would.
const replay = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
});
try {
// Positive control: the replayed cookie is a working session before
// the sign-out, so the 401 below is about the sign-out.
expect((await replay.get("/api/auth/me")).status()).toBe(200);
await signOutThroughTheMenu(page);
// The browser dropped its cookie; that alone would pass with a server
// that kept the row. The proof is the old cookie being refused.
expect(await signedInAs(page)).toBeNull();
expect((await replay.get("/api/auth/me")).status()).toBe(401);
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
401,
);
} finally {
await replay.dispose();
}
// And the protected shell sends the browser back to the form.
await page.goto("/");
await page.waitForURL((url) => url.pathname === "/auth/login");
});
test("a passkey registered in settings signs the account back in @critical", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
// A retry, or the last run, may have left a credential on the account;
// the list below has to count only the one this test registers.
await resetCriticalAccount(account, [
"DELETE FROM passkeys WHERE user_id = $1",
]);
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send(
"WebAuthn.addVirtualAuthenticator",
{
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
},
);
try {
await signInWithPassword(page, account);
await test.step("register a passkey under the current password", async () => {
await page.goto("/settings/security");
await page.getByRole("button", { name: "Add passkey" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await dialog.locator('input[type="password"]').fill(account.password);
await dialog.getByRole("button", { name: "Verify and add" }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByTestId("passkeys-list").locator("li"),
).toHaveCount(1);
const stored = await cdp.send("WebAuthn.getCredentials", {
authenticatorId,
});
expect(stored.credentials).toHaveLength(1);
expect(stored.credentials[0].isResidentCredential).toBe(true);
});
await signOutThroughTheMenu(page);
expect(await signedInAs(page)).toBeNull();
await test.step("sign in with it", async () => {
await clearLoginBuckets();
await page
.getByRole("button", { name: "Sign in with Passkey" })
.click();
await page.waitForURL((url) => url.pathname === "/", {
timeout: 30_000,
});
expect(await signedInAs(page)).toBe(account.username);
// The server recorded the use against the stored credential.
const res = await page.request.get("/api/auth/passkeys");
expect(res.status()).toBe(200);
const passkeys = (
(await res.json()) as {
data: Array<{ lastUsedAt: string | null }>;
}
).data;
expect(passkeys).toHaveLength(1);
expect(passkeys[0].lastUsedAt).not.toBeNull();
});
} finally {
await cdp
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
.catch(() => undefined);
}
});
});
+10
View File
@@ -39,6 +39,16 @@ export const CRITICAL_JOURNEYS = [
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
/**
* Journeys that sign in through the form in every test and so get no jar.
* The sign-in journey ends the sessions it opens; a jar captured once in
* global setup would be dead for a retry, and a retry has to start from the
* same place as the first attempt.
*/
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
"auth",
]);
/** The projects the journeys run in; each gets its own account per journey. */
export const CRITICAL_PROJECTS = {
"chromium-desktop": "d",
+3 -2
View File
@@ -33,7 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { allCriticalAccounts } from "./critical-fixture";
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
@@ -1754,8 +1754,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
);
// The @critical journeys' jars. The sign-in journey signs in through the
// form, so its account gets a jar only for the steps that start signed in.
// form in every test, so its account gets none.
for (const account of allCriticalAccounts()) {
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
await capture(account, account.storageStatePath);
}
} finally {
@@ -49,7 +49,7 @@ const E2E = join(process.cwd(), "e2e");
* the set is the release gate, so a journey that loses its tag is a change
* somebody should see here.
*/
const CRITICAL_TEST_FLOOR = 7;
const CRITICAL_TEST_FLOOR = 10;
/** A test whose title literal carries the tag, however the call is spelled. */
const CRITICAL_TEST =