Drive a delegated write through the browser, once the level control lands

The component suite runs without a DOM renderer, so it proves what a page
paints and never what a button does. That is the right shape for this work,
because the property that matters most is a control being absent — but it
leaves the other half unproven: a form that renders is not a form that
submits, and a write that reaches somebody else's record is exactly the thing
worth watching happen.

The journey stands down while the invitation form has no level control, since
minting a WRITE grant behind the UI's back would prove a path no person can
walk. It names the control it is waiting for, so a run that skips while the
control exists reads as a bug here rather than an absence upstream.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:17:15 +02:00
1 parent d5a9360c52
commit 59965e141e
1 file changed
+189
+189
View File
@@ -0,0 +1,189 @@
/**
* 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, which is another chunk's
* work. 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.
*
* **To enable this once the invite form ships its level control:** if it lands
* under a different `data-slot` than the constant below, change that one
* string. Nothing else in this file assumes anything about the control except
* that picking WRITE and submitting mints a WRITE grant.
*
* The skip is deliberately loud rather than silent: it names the missing
* control, so a run where the control exists and the journey still does not
* execute reads as a bug in this file rather than as an absence upstream.
*
* ## 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,
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-level";
/** 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 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 });
await ownerPage
.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`)
.selectOption(WRITE_LEVEL_VALUE);
// 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?.toLowerCase(),
"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();
await expect(banner).toContainText(E2E_OWNER.username);
// 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"),
);
await page.getByRole("button", { name: /add measurement/i }).click();
await page.locator('input[name="value"], #value').first().fill("71.5");
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("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);
});
});