Files
HealthLog/e2e/delegated-writes.spec.ts
T
Marc-André Bombeck 97e75fd098 fix(sharing): stop preselecting a scope when inviting someone
The scope question defaulted to whole record, which reads as the form
nudging the choice. Preselect neither option: an untouched fieldset now
blocks the form with a quiet hint instead of falling through to a
whole-record grant. The wire contract is unchanged — scope-absent still
means the entire record on the server, so the unset state deliberately
does not omit scope, it blocks the client until the owner picks. The
empty-selection and manage floors stay fail-closed as before.
2026-08-08 21:05:10 +02:00

233 lines
9.4 KiB
TypeScript

/**
* A delegate who may write: what they are offered, and what they are not.
*
* The unit suite runs SSR-only, so it holds the PAINT and never the click: it
* can prove an add button is absent, which is the property that matters most,
* and it cannot prove that the one still standing submits. This spec is the
* other half — a real form, a real POST, a real row in somebody else's record,
* and the owner seeing it afterwards.
*
* ## Why it gates itself instead of seeding a WRITE grant directly
*
* The grant level is chosen in the invitation form. Rather than mint a WRITE
* row behind the UI's back — which would prove the journey works for a grant
* no person can create — the journey looks for the level control and stands
* down when it is not there yet.
*
* That guard was written against a `data-slot` the form never shipped under
* (`grant-invite-level`; the control landed as `grant-invite-access-option`),
* so from the day the control arrived until 2026-08-03 every test in this file
* skipped and the whole delegated-write journey ran nowhere. The file said out
* loud what to change and nobody changed it, which is the standing lesson
* about a check that cannot fail: the skip is quiet, and a quiet skip and a
* passing suite look identical in a CI summary.
*
* The constant is the real one now. If it moves again, change that one string:
* nothing else here assumes anything about the control except that choosing
* WRITE and submitting mints a WRITE grant.
*
* ## What this spec cannot cover
*
* The owner's activity view is asserted at the level of "a row appeared for
* something the delegate did", not at the level of the per-verb sentence. The
* verb lines live in `src/lib/record-activity/activity-verb.ts` with their own
* unit test; the one line that binds them into the activity card belongs to
* the file this chunk was told not to touch.
*/
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
DELEGATE_STORAGE_STATE_PATH,
E2E_OWNER_FULL_NAME,
E2E_USER,
OWNER_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
/**
* The invitation form's grant-level control. The journey runs when this is on
* the page and stands down when it is not. One string, one place.
*/
const GRANT_LEVEL_SLOT = "grant-invite-access-option";
/** The value the level control carries for a grant that may add entries. */
const WRITE_LEVEL_VALUE = "WRITE";
test.describe("delegated writes", () => {
// One journey in order, like the read-only sibling: each step is the next
// one's precondition, and the invitation endpoint is rate-limited.
test.describe.configure({ mode: "serial" });
// The `page` fixture is the DELEGATE throughout, on this journey's own
// cookie jar — the switch is stamped on the session row, so switching a
// shared jar would switch it for every spec holding the same cookie.
test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
// Desktop only: the journey mutates shared rows between two seeded accounts,
// so two projects running it at once race each other and the second
// invitation is refused as a duplicate.
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"shared-fixture journey — runs once",
);
});
let ownerContext: BrowserContext;
let ownerPage: Page;
let levelControlPresent = false;
test.beforeAll(async ({ browser }) => {
ownerContext = await browser.newContext({
storageState: OWNER_STORAGE_STATE_PATH,
});
ownerPage = await ownerContext.newPage();
await ownerPage.goto("/settings/access");
levelControlPresent =
(await ownerPage.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`).count()) >
0;
});
test.afterAll(async () => {
await ownerContext.close();
});
test.beforeEach(() => {
test.skip(
!levelControlPresent,
`no [data-slot="${GRANT_LEVEL_SLOT}"] in the invitation form — a WRITE grant cannot be created through the UI yet`,
);
});
test("the owner invites at a level that may add entries", async () => {
await ownerPage.goto("/settings/access");
const identifier = ownerPage.locator(
'[data-slot="grant-invite-identifier"]',
);
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
// The scope question preselects nothing, so the form blocks until the owner
// picks one. This invitation is unscoped — choose the whole record.
const wholeRecord = ownerPage.locator(
'[data-slot="grant-invite-scope-option"][data-scope="all"]',
);
await wholeRecord.click();
await expect(wholeRecord).toHaveAttribute("data-selected", "true");
// The controlled input keeps the submit disabled until React has attached,
// so retry the pair rather than waiting a fixed time and hoping.
await expect(async () => {
await identifier.fill(E2E_USER.username);
await expect(submit).toBeEnabled({ timeout: 1000 });
}).toPass({ timeout: 15_000 });
const writeOption = ownerPage.locator(
`[data-slot="${GRANT_LEVEL_SLOT}"][data-access="${WRITE_LEVEL_VALUE}"]`,
);
await writeOption.click();
await expect(writeOption).toHaveAttribute("data-selected", "true");
// Read the posted body: a level control that renders and sends a hardcoded
// level would pass every render assertion and ship a read-only grant.
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 {
access?: string;
};
expect(
posted.access,
"the invitation must carry the level the owner chose",
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record", async ({ page }) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
// The switcher lives inside the user menu, like its read-only sibling.
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();
await page.locator('[data-slot="account-switcher-entry"]').click();
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toBeVisible();
// v1.37.2 — the owner set a full name, so the banner names them by it.
await expect(banner).toContainText(E2E_OWNER_FULL_NAME);
// The banner drops its read-only clause on its own once the level says so.
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record", async ({ page }) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// The add path survives at WRITE. This is the click an SSR test cannot
// make: the button rendering and the button working are two facts.
const post = page.waitForResponse(
(res) =>
res.request().method() === "POST" &&
res.url().endsWith("/api/measurements"),
);
// Stable attributes and the form's real fields, neither of which this
// step had. It looked for a button named "Add measurement" (the header
// reads "Add") and then for a `value` input (the form opens on blood
// pressure, which has three). Both were wrong from the day they were
// written and nobody found out, because the whole file was skipping.
await page.locator('[data-slot="measurement-add"]').click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
expect((await post).status(), "the write must be accepted").toBeLessThan(
300,
);
// And the row it created is not theirs to change. Absent, not disabled:
// a `toBeDisabled()` assertion here would pass against the exact design
// this release exists to avoid.
await expect(
page.locator('[data-slot="selection-action-bar"]'),
).toHaveCount(0);
await expect(page.getByRole("button", { name: /^delete$/i })).toHaveCount(
0,
);
});
test("a deep link opens exactly what the level admits", async ({ page }) => {
// The gate binds to the level the server resolved, not to a blanket
// "somebody else's record" flag. Both halves matter and only a browser
// can show either: the SSR suite holds a component's paint, never a URL.
//
// Admitted: entering a reading, so `?add=` opens the same sheet the
// header button opens.
await page.goto("/measurements?add=WEIGHT");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="responsive-sheet-content"]').first(),
).toBeVisible();
// Also admitted: adding a medication with its schedule.
await page.goto("/medications?new=1");
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toBeVisible();
});
test("the owner sees that somebody else was in their record", async () => {
await ownerPage.goto("/settings/access");
const rows = ownerPage.locator('[data-slot="record-activity-row"]');
await expect(rows.first()).toBeVisible();
await expect(rows.first()).toContainText(E2E_USER.username);
});
});