diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index a7d966212..fdc54aac3 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -172,6 +172,13 @@ test.describe("account sharing", () => { 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"); @@ -187,7 +194,34 @@ test.describe("account sharing", () => { 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); + + 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; + }; + 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)); const row = ownerPage .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 952a5b7b1..8d798ce6a 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -519,20 +519,6 @@ const REGISTRY: DestructiveEntry[] = [ undetected: "Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.", }, - { - file: "components/settings/access/grant-invite-card.tsx", - destroys: - "nothing — it offers access, and the offer confers nothing until the other person accepts it", - recovery: "permanent", - confirm: [], - unconfirmed: [ - { - control: "the invitation form", - reason: - "D3 flags this file for `expiresAt: null` in the invite payload, reading it as a field being blanked. It is not: null means the grant runs until somebody ends it, which is the common household case and the absence of an expiry rather than the removal of one. The form creates a pending row and destroys nothing, and gating a creation behind a confirmation dialog would teach people to click through the dialogs that do guard a loss.", - }, - ], - }, // ── Admin ─────────────────────────────────────────────────────────────── {