Files
HealthLog/e2e/login.spec.ts
T
Marc-André Bombeck 45a5d4e353 fix(pwa): never let the badge call hold up the worker's activation
The new worker cleared a leftover app badge inside its activation and
waited for it. A browser that never settles that call keeps the worker
activating, so it never takes over its pages and every offline path is
gone. The badge is now cleared beside the activation, best effort.

The sign-in journey expects the page's own sentence for a wrong
password, not the server's text.
2026-11-12 15:02:11 +01:00

259 lines
9.3 KiB
TypeScript

import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Signing in and out, against the real server.
*
* 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.
*/
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 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", () => {
test("renders username + password inputs with the right autoComplete", async ({
page,
}) => {
await openPasswordForm(page);
const username = page.getByTestId("login-email");
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.getByTestId("login-password");
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await openPasswordForm(page);
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();
expect((await answer).status()).toBe(401);
// The page says it in its own words, not the server's.
await expect(page.getByText("Login failed")).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);
}
});
});