Files
HealthLog/e2e/account-sharing.spec.ts
T
Marc-André Bombeck d82e29737b Say what the level means on both screens that decide it
The invitation form gains the choice, and the option labels say what actually
ships rather than the word "write": a delegate can add readings, results,
observations, a medication and a marked dose, and can edit or delete nothing,
including what they entered a moment ago. That gap between what "write access"
sounds like and what it does is where a support case lives, so it is on the
card rather than in a reply.

The delegate reads the same thing before they accept. A pending write
invitation states its level and one sentence about what accepting means, on the
screen where they accept it, because acceptance is where the two consents meet
and it happens once.

Ending an access says what stays: the entries remain and the owner can remove
any of them, and the activity view keeps saying who entered what for as long as
the instance's audit window reaches. That number comes from the server, so an
instance running ninety days says ninety, and a browser that has not been told
says nothing rather than a made-up year.

The browser journey now invites at the wider level and reads the posted body
for it. That is the only check in the tree that can: the component convention
is server-rendered only, so no test under src/ runs the form's submit path, and
every one of them stays green with the control wired to a constant.
2026-08-03 10:12:22 +02:00

413 lines
18 KiB
TypeScript

/**
* 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();
});
/** A `YYYY-MM-DD` day the date input accepts, N days from now. */
function isoDayFromNow(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
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 });
// The lapse date, through the real form and onto the real wire.
//
// `expiresAt` was accepted, stored and annotated on by the route from the
// day the grant model shipped, while the card sent a hardcoded `null`. The
// component test cannot see that: it renders the control and checks the
// date mapping, and BOTH still pass with the card wired back to `null`.
// Only reading the posted body proves the value a person typed is the
// value the server receives, which is the half that was missing.
const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]');
await expect(lapse).toBeVisible();
const chosenDay = isoDayFromNow(30);
await lapse.fill(chosenDay);
// The level, through the real control and onto the real wire.
//
// Same class of gap as the lapse date above, and the reason it is checked
// here rather than in a component test is worth stating: the project's
// component convention is SSR-only, so no test under `src/` can run this
// form's submit path at all. Wire the radio back to a constant and every
// one of those stays green. Only the posted body can tell the level a
// person picked from the level the card sends.
const writeOption = ownerPage.locator(
'[data-slot="grant-invite-access-option"][data-access="WRITE"]',
);
await expect(writeOption).toBeVisible();
await writeOption.click();
await expect(writeOption).toHaveAttribute("data-selected", "true");
const invitePost = ownerPage.waitForRequest(
(req) =>
req.method() === "POST" && req.url().endsWith("/api/account/grants"),
);
await submit.click();
const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
expiresAt?: string | null;
access?: string;
};
expect(
posted.expiresAt,
"the invitation must carry the day the owner chose",
).toBeTruthy();
// End of the chosen day, not the midnight that starts it.
expect(posted.expiresAt).toContain(chosenDay.slice(0, 8));
expect(
posted.access,
"the invitation must carry the level the owner chose",
).toBe("WRITE");
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");
// And the level came back from the database on the list read, which is the
// far end of the same wire: form → POST → row → GET → attribute.
await expect(row).toHaveAttribute("data-grant-access", "WRITE");
});
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");
// The delegate's half of the consent: what they are being offered, and
// what accepting it will mean, on the screen where they accept it.
await expect(row).toHaveAttribute("data-grant-access", "WRITE");
await expect(
row.locator('[data-slot="grant-write-consent"]'),
).toBeVisible();
// 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");
});
});