Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
60 files changed
+5648 -174

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
/**
* Account sharing, end to end: invite → accept → switch → banner → revoke.
*
* Two browser contexts, two real accounts, one real database. The owner
* invites, the delegate accepts and opens the record, and the owner takes it
* back — each step through the shipped UI rather than through the API, because
* the half of this feature that can go wrong is the half a person touches.
*
* ## The stale-paint check
*
* The assertion this spec exists for is a NEGATIVE one, and it is deliberately
* not "the new record's data arrives". A check that waits for the new data
* proves nothing about what was on screen while it was arriving, and the
* failure being guarded against is exactly that in-between frame: a dashboard
* hydrated from the previous record's IndexedDB snapshot, painted under a
* banner naming somebody else.
*
* So an init script installs a MutationObserver before any page script runs,
* on every navigation including the full reload the switch performs, and it
* latches the moment the delegate's own marker weight appears in the document.
* The spec then reads the latch. Nothing about it is sampled or timed.
*
* The observer's positive control runs first: with the same script installed,
* the delegate's own measurements page must trip the latch. Without that, "the
* marker never appeared" and "the observer never worked" are the same sentence.
*
* ### How much this assertion can be made to fail, measured rather than assumed
*
* Three controls stand behind it, and they were removed one at a time and
* together against a real build to see which of them this spec can actually
* detect. The honest answer is: none of them individually, and that is worth
* writing down rather than leaving as an implication.
*
* - Delete the switch-time cache wipe, the persister's restore guard and the
* record-scoped query hash — all three at once — and this spec stays
* GREEN. The reason is a rule that predates this feature: the restore
* refuses to clobber a query the live cache already holds, and the
* dashboard's own query is created during render, before any effect runs.
* So the IndexedDB snapshot never reaches the screen on this route at all.
* - Delete the full reload and the spec goes RED, but on the banner line
* rather than the marker line: without a reload the account payload never
* re-resolves and the banner never paints, so the run stops before the
* latch is read.
*
* What that means, plainly: this spec is a regression net over the whole
* journey, not a proof of any single control. The controls are proven where
* they can be broken and observed — `query-persister-shared-record.test.ts`
* for the two disk guards, `record-scope.test.ts` for the cache dimension —
* and each of those was verified red before being kept.
*
* ## What this spec cannot cover, stated rather than implied
*
* - **The service worker.** The Playwright context runs with
* `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's
* `healthlog-data-*` cache — the other layer that survives a reload — is not
* exercised here at all. The switch wipes it through the same helper the
* IndexedDB snapshot goes through, and that helper is covered in
* `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`.
* - **The owner's data appearing.** No route declares the delegable mode yet,
* so every read inside a shared record is refused by design. That is the
* current, correct state of the tree; the pages a delegate can open show
* their refusal state and, crucially, still show nothing of the delegate's
* own. The positive half of that assertion belongs with the route migration.
*/
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
DELEGATE_STORAGE_STATE_PATH,
E2E_DELEGATE_MARKER_KG,
E2E_OWNER,
E2E_USER,
OWNER_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG);
/**
* Latch the first appearance of a string anywhere in the document, from before
* the first script of every page load.
*
* A MutationObserver rather than a poll: a poll samples, and the frame this is
* looking for can be one paint long. `characterData` is on because a hydration
* pass can fill an existing text node in place rather than inserting one.
*/
async function watchForText(page: Page, needle: string): Promise<void> {
await page.addInitScript((text: string) => {
const w = window as unknown as { __sawText?: boolean };
w.__sawText = false;
const seen = () => document.body?.innerText?.includes(text) ?? false;
const check = () => {
if (seen()) w.__sawText = true;
};
const start = () => {
check();
new MutationObserver(check).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
}, needle);
}
async function sawText(page: Page): Promise<boolean> {
return page.evaluate(
() => (window as unknown as { __sawText?: boolean }).__sawText === true,
);
}
/** Open the user menu and the switcher inside it. */
async function openSwitcher(page: Page): Promise<void> {
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
}
test.describe("account sharing", () => {
// One journey, in order: each step is the precondition of the next, so
// splitting them into independent tests would mean re-running the invitation
// three times against a rate-limited endpoint.
test.describe.configure({ mode: "serial" });
// The `page` fixture throughout is the DELEGATE. The owner gets an explicit
// context below, because a grant needs two signed-in sides at once and the
// login endpoint is IP-rate-limited.
//
// The delegate's jar is this journey's OWN, not the shared one every other
// authenticated spec reads, and that is load-bearing rather than tidy. The
// switch is stamped on the session row (see "the nav offers only what
// sharing covers" below, which relies on exactly that), so switching the
// shared jar would switch it for every spec holding the same cookie — and
// the suite runs fully parallel. The damage lands somewhere else entirely:
// a settings surface painting "Not part of shared access", an axe scan
// reading a banner its spec never asked for. Same account, different
// session, no reach outside this file.
test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
// Desktop only, and for a reason that is about the fixture rather than the
// layout: this journey mutates shared rows — one grant between two seeded
// accounts — so two projects running it at once race each other, and the
// second one's invitation is refused as a duplicate of the first's. Running
// it once keeps every step's precondition true. The mobile bar reads the
// same destination model as the sidebar, and that agreement is a unit
// contract (`nav-model-shared-record.test.ts`) rather than something two
// browser runs of the same script would establish.
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"shared-fixture journey — runs once",
);
});
let ownerContext: BrowserContext;
let ownerPage: Page;
test.beforeAll(async ({ browser }) => {
ownerContext = await browser.newContext({
storageState: OWNER_STORAGE_STATE_PATH,
});
ownerPage = await ownerContext.newPage();
});
test.afterAll(async () => {
await ownerContext.close();
});
test("the owner invites, and the invitation confers nothing yet", async () => {
await ownerPage.goto("/settings/access");
// The submit button is disabled until the controlled input holds a value,
// so a `fill()` that lands before React has attached its listeners leaves
// the form looking filled and behaving empty. Retry the pair until the
// button agrees, rather than waiting a fixed time and hoping.
const identifier = ownerPage.locator(
'[data-slot="grant-invite-identifier"]',
);
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
await expect(async () => {
await identifier.fill(E2E_USER.username);
await expect(submit).toBeEnabled({ timeout: 1000 });
}).toPass({ timeout: 15_000 });
await submit.click();
const row = ownerPage
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
.first();
await expect(row).toBeVisible();
// Pending, not active. The state is the server's answer, rendered.
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
});
test("the delegate is offered nothing until they accept", async ({
page,
}) => {
await page.goto("/settings/access");
const row = page
.locator('[data-slot="grants-received-list"] [data-slot="grant-row"]')
.first();
await expect(row).toBeVisible();
await expect(row).toHaveAttribute("data-grant-state", "PENDING");
// No switcher yet: `accountAccess.canSwitch` is false while the only
// grant is pending, and the menu binds that boolean.
await page.getByRole("button", { name: "User menu" }).first().click();
await expect(
page.locator('[data-slot="account-switcher-trigger"]'),
).toHaveCount(0);
await page.keyboard.press("Escape");
await page.locator('[data-slot="grant-accept"]').click();
await expect(row).toHaveAttribute("data-grant-state", "ACTIVE");
});
test("the switcher appears once the grant is live", async ({ page }) => {
await page.goto("/settings/access");
await openSwitcher(page);
const entry = page.locator('[data-slot="account-switcher-entry"]');
await expect(entry).toHaveCount(1);
await expect(entry).toContainText(E2E_OWNER.username);
// The way home is in the same menu as the way in.
await expect(
page.locator('[data-slot="account-switcher-own"]'),
).toBeVisible();
});
test("switching shows the banner and never the delegate's own data", async ({
page,
}) => {
await watchForText(page, DELEGATE_MARKER);
// Positive control. If the observer cannot see the marker on the page
// that definitely shows it, its silence later means nothing.
await page.goto("/measurements");
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
expect(await sawText(page)).toBe(true);
// Let the offline persister flush the delegate's own dashboard to
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
// whole assertion is aimed at — without it the check would pass against a
// disk that was empty anyway.
await page.goto("/");
await page.waitForTimeout(2000);
await openSwitcher(page);
await page.locator('[data-slot="account-switcher-entry"]').click();
// Wait for CONTENT, and for content the assertion is not about: the
// banner naming the owner. A skeleton does not satisfy it, and it says
// nothing about the marker either way.
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toBeVisible();
await expect(banner).toContainText(E2E_OWNER.username);
// The document showing the banner has never shown the delegate's own
// number. The latch was armed by that document's own init script, so this
// covers its whole life rather than the instant it is read. See the
// header for what this line can and cannot be made to fail on.
expect(await sawText(page)).toBe(false);
// And it stays false across a navigation inside the record.
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
expect(await sawText(page)).toBe(false);
});
test("the nav offers only what sharing covers", async ({ page }) => {
// A fresh page fixture, and still inside the record: the switch lives on
// the session row rather than in the tab, so every tab of that browser is
// switched at once. That is the property this navigation quietly proves.
await page.goto("/");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// The record itself is still there…
await expect(page.locator('a[href="/measurements"]').first()).toBeVisible();
// …and everything that configures the account around it is not.
await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0);
await expect(page.locator('a[href="/coach"]')).toHaveCount(0);
await expect(page.locator('a[href="/insights"]')).toHaveCount(0);
});
test("a surface sharing does not cover says so", async ({ page }) => {
// Reached by bookmark: the nav entry is gone, the URL still resolves.
await page.goto("/settings/account");
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="shared-record-unavailable-leave"]'),
).toBeVisible();
});
test("revoking from the owner's browser drops the delegate out", async ({
page,
}) => {
await page.goto("/");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// The owner ends it from their own session, in their own context.
await ownerPage.goto("/settings/access");
await ownerPage.locator('[data-slot="grant-revoke"]').first().click();
await ownerPage
.getByRole("button", { name: "End access", exact: true })
.last()
.click();
await expect(
ownerPage
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
.first(),
).toHaveAttribute("data-grant-state", "REVOKED");
// The delegate's next navigation lands back in their own account rather
// than on a wall of refusals: the revoke cleared their acting session in
// the same transaction that stamped the row.
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveCount(0);
// Their own record, back — which is also the proof that the page did not
// simply fail to load.
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
});
test("the ended grant stays on the record as history", async () => {
await ownerPage.goto("/settings/access");
const row = ownerPage
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
.first();
// Revocation never deletes. "Who had access, from when to when, and who
// ended it" is the question the panel exists to answer.
await expect(row).toHaveAttribute("data-grant-state", "REVOKED");
await expect(row).toContainText("You ended it");
});
});
+204 -48
View File
@@ -50,6 +50,61 @@ export const E2E_USER = {
role: "ADMIN",
} as const;
/**
* v1.36.0 — the SECOND account, so account sharing has two sides to test.
*
* Sharing is the one feature in the product that cannot be exercised by one
* person: somebody has to own a record and somebody else has to be given it.
* This account owns the record; `E2E_USER` is the delegate throughout the
* sharing spec.
*/
export const E2E_OWNER = {
email: "e2e-owner@healthlog.test",
username: "e2e-owner",
password: "Qv7bR2n!Ks9wLd4T",
role: "USER",
} as const;
/**
* Weights seeded one per account, chosen so neither can be mistaken for the
* other in rendered markup and neither collides with a value any other spec
* writes.
*
* The delegate's number is the whole point of the stale-paint check: after
* switching into the owner's record it must appear NOWHERE, at no moment. A
* spec that only waited for the owner's data to arrive would prove nothing
* about what was on screen in between.
*/
export const E2E_DELEGATE_MARKER_KG = 77.7;
export const E2E_OWNER_MARKER_KG = 63.3;
export const OWNER_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateOwner.json",
);
/**
* A SECOND cookie jar for the same delegate account, and the reason it exists
* is the one property that makes account switching work at all: the switch is
* stamped on the session row, not held in the tab. Every session opened from
* `STORAGE_STATE_PATH` is therefore the same session — so a spec that switches
* that jar into somebody else's record switches it for every spec that
* authenticates with it, whichever of them happens to be mid-navigation on the
* other worker.
*
* The symptom is a wandering one: `/settings/ai` painting "Not part of shared
* access", an axe scan reading a banner nobody asked for, a `waitForResponse`
* on a request the refused surface never sends. Nothing in the failing spec is
* wrong and nothing in its file explains it.
*
* So the journey that switches gets its own login, and the shared jar is never
* pointed anywhere. Same account, different session row, no overlap.
*/
export const DELEGATE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateDelegate.json",
);
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
@@ -179,6 +234,85 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[cuid(), now, E2E_USER.username],
);
// ── v1.36.0 — the sharing fixture ────────────────────────────────────
//
// A second account, one distinctive weight in each record, and a clean
// grant table. The reset matters as much as the seed: grants and switch
// stamps outlive a run, and a second run starting with the delegate
// already inside the owner's record would skip the invitation half of
// the journey while still going green.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
[
cuid(),
E2E_OWNER.username,
E2E_OWNER.email,
await hashPassword(E2E_OWNER.password),
now,
],
);
// Every grant between the pair, and any switch stamp either of them is
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
// lifecycle transition, and a revoked row would still show in the panel.
await pool.query(
`DELETE FROM account_grants
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`UPDATE sessions SET acting_as_user_id = NULL
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'sharing:%'`,
);
// The login bucket, for the same reason. This setup signs in THREE times
// now (the shared jar, the owner, and the sharing journey's own delegate
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
// local runs in a row would otherwise end with a 429 from the fixture
// rather than a failure from the product. Only the auth surfaces' own
// buckets are cleared; nothing else in the table is touched.
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
// One marker weight per record. Re-seeded by delete-then-insert so a
// re-run cannot accumulate duplicates and so an edited constant takes
// effect rather than sitting beside the old value.
for (const [username, kg] of [
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
] as const) {
await pool.query(
`DELETE FROM measurements
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND external_id = 'e2e-sharing-marker'`,
[username],
);
await pool.query(
`INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at,
external_id, created_at, updated_at)
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
'e2e-sharing-marker', $3, $3
FROM users u WHERE u.username = $4`,
[cuid(), kg, now, username],
);
}
// Console (instead of structured logging) is intentional here —
// global-setup runs outside the app's logging context, and the
// line is useful when debugging a CI failure where the seed didn't
@@ -190,60 +324,82 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
await pool.end();
}
// Login once and persist the resulting cookie jar so every spec can
// reuse it via `playwright.config.ts → use.storageState`. This is the
// documented Playwright pattern (`docs/auth.md`) and the only way to
// avoid the per-spec login + rate-limit dance.
// Log the fixture accounts in here and persist their cookie jars so specs
// can reuse them via `test.use({ storageState })`. This is the documented
// Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec
// login + rate-limit dance.
const baseURL =
config.projects[0]?.use.baseURL ??
process.env.E2E_BASE_URL ??
"http://localhost:3000";
// The shared jar every authenticated spec reads.
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
// is logged in here rather than inside the spec.
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
// The delegate's own jar for that journey. Same account as the shared one,
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
// what happens when the journey switches the shared row instead.
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
}
/**
* Log one account in and write its cookie jar to `path`.
*
* Each call mints a fresh session row, so two jars for the same account are
* two independent browser sessions — which is the whole point of the delegate
* jar above.
*
* Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added
* `hl_onboarding`: the login route sets it to "pending" for a new account and
* DELETES it for an already-onboarded one, so for these pre-seeded accounts it
* is absent and the proxy passes the dashboard through.
*
* v1.27.11 — the locale cookie is pinned to English rather than inherited. The
* server honours `User.locale` when the cookie is absent (the ITP fix), and
* the locale-switch spec mirrors its German pick into the SHARED account's
* profile — without this cookie every spec running after it would render
* German and the English-string assertions would fail. The cookie sits at the
* top of the resolution ladder, so each spec context stays deterministically
* English; locale-switch overrides it inside its own context only.
*/
async function captureAuthState(
baseURL: string,
account: { username: string; password: string },
path: string,
): Promise<void> {
const ctx = await playwrightRequest.newContext({ baseURL });
const res = await ctx.post("/api/auth/login", {
data: {
email: E2E_USER.username,
password: E2E_USER.password,
},
});
if (res.status() !== 200) {
const body = await res.text();
throw new Error(
`[e2e/global-setup] login failed: HTTP ${res.status()} — ${body.slice(0, 200)}`,
try {
const res = await ctx.post("/api/auth/login", {
data: { email: account.username, password: account.password },
});
if (res.status() !== 200) {
const body = await res.text();
throw new Error(
`[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`,
);
}
const state = await ctx.storageState();
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "hl_onboarding"].includes(c.name),
);
state.cookies.push({
name: "healthlog-locale",
value: "en",
domain: new URL(baseURL).hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(path, JSON.stringify(state, null, 2));
console.log(`[e2e/global-setup] auth state captured to ${path}`);
} finally {
await ctx.dispose();
}
const state = await ctx.storageState();
// Strip any cookies we don't recognise so storageState only carries
// what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the
// login route sets it to "pending" for new users and DELETES it for
// already-onboarded ones, so for our pre-seeded completed e2e user
// the cookie is absent and the proxy passes the dashboard through.
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
);
// v1.27.11 — pin the shared auth state to English via an explicit locale
// cookie. The server now honours `User.locale` when the cookie is absent
// (the ITP fix), and the locale-switch spec mirrors its German pick into
// the SHARED e2e user's profile — without this cookie every spec running
// after it would render German and the English-string assertions fail.
// The cookie sits at the top of the resolution ladder, so each spec
// context stays deterministically English; locale-switch overrides it
// inside its own context only.
const base = new URL(baseURL);
state.cookies = state.cookies.filter((c) => c.name !== "healthlog-locale");
state.cookies.push({
name: "healthlog-locale",
value: "en",
domain: base.hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
await ctx.dispose();
console.log(
`[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`,
);
}
+9 -1
View File
@@ -6,4 +6,12 @@
* specific to the spec-side (e.g. multi-user scenarios that need
* fresh logins) have a natural home.
*/
export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup";
export {
E2E_USER,
E2E_OWNER,
E2E_DELEGATE_MARKER_KG,
E2E_OWNER_MARKER_KG,
STORAGE_STATE_PATH,
OWNER_STORAGE_STATE_PATH,
DELEGATE_STORAGE_STATE_PATH,
} from "./global-setup";