mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b826642853
commit
518a381ff9
4 files changed
+245
-50
No files matched your search
+231
-47
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user