From b2e225470bd6b4e4156ca269d04e47a69f35af51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 06:00:29 +0200 Subject: [PATCH] test: read the account payload as the release publishes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases in the accountAccess integration test were written against a contract this release rejected. They asserted an entry of seven fields and a block of three, both of which gained one; and they asserted that a MANAGE grant publishes `access: "manage"`. It does not, by design. `access` is the v1.36.0 name and its union is `read | write` — a client compiled against that release decodes it as a closed set. MANAGE therefore publishes as `write` there and as `manage` on `level`, and the entry schema raises a validation issue unless a manage entry carries exactly legacy write access and whole scope. The expectations move; the schema does not. `recordKind` is required on an entry and on the block. Both are asserted now, including the block-level value moving from `self` to `shared` across a switch, so a value stuck on either reads as two cases disagreeing. --- .../sharing-account-access.test.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts index bb0daef58..a14fb0f69 100644 --- a/tests/integration/sharing-account-access.test.ts +++ b/tests/integration/sharing-account-access.test.ts @@ -70,8 +70,14 @@ interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; - access: "read" | "write" | "manage"; + /** + * The legacy name, and deliberately narrower than `level`: it carries only + * `read` and `write`, because that is the union v1.36.0 published and + * shipped clients still decode. A MANAGE grant is a `write` here. + */ + access: "read" | "write"; level: "read" | "write" | "manage"; + recordKind: "shared" | "managed"; sections: string[] | null; canWrite: boolean; } @@ -79,6 +85,7 @@ interface AccountAccessEntry { interface AccountAccessBlock { accounts: AccountAccessEntry[]; active: AccountAccessEntry | null; + recordKind: "self" | "shared" | "managed"; canSwitch: boolean; } @@ -146,6 +153,9 @@ describe("accountAccess — what the payload publishes", () => { expect(accountAccess).toEqual({ accounts: [], active: null, + // The record in view is this account's own, and the block says so + // rather than leaving the client to infer it from `active` being null. + recordKind: "self", canSwitch: false, }); }); @@ -165,6 +175,10 @@ describe("accountAccess — what the payload publishes", () => { displayName: owner.displayName, access: "read", level: "read", + // An adult who shared their own record. `managed` is reserved for a + // profile that has no login of its own, and the two are not + // interchangeable on any surface that renders a name. + recordKind: "shared", sections: null, canWrite: false, }, @@ -260,11 +274,21 @@ describe("accountAccess — what the payload publishes", () => { expect(accountAccess.accounts[0].sections).toEqual([]); }); - it("publishes a manage grant as manage, and as writable", async () => { + it("publishes a manage grant as manage on level, and as write on the legacy name", async () => { // The shipped expression was `access === "WRITE" ? "write" : "read"`, // which would have published a grant that can delete entries as read-only // — the payload understating access is the direction that reads as safe // and is not, because the server would have gone on admitting the writes. + // + // The correction is on `level`, and only there. `access` is the v1.36.0 + // name, and its union is `read | write`: a client built against that + // release decodes it as a closed set and fails on a third value it has + // never seen. So MANAGE publishes as `write` on the legacy name — an + // understatement a shipped client can act on, beside the exact answer on + // `level` for a client that knows to read it. The narrowing is enforced + // rather than incidental: `accountAccessEntrySchema` raises an issue + // unless a `manage` entry carries exactly legacy write access and whole + // scope (`src/lib/sharing/account-access-schema.ts`). const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id, { access: "MANAGE" }); @@ -273,9 +297,10 @@ describe("accountAccess — what the payload publishes", () => { const { accountAccess } = await readMe(); expect(accountAccess.accounts[0].level).toBe("manage"); - expect(accountAccess.accounts[0].access).toBe("manage"); + expect(accountAccess.accounts[0].access).toBe("write"); expect(accountAccess.accounts[0].canWrite).toBe(true); expect(accountAccess.accounts[0].sections).toBeNull(); + expect(accountAccess.accounts[0].recordKind).toBe("shared"); }); it("never lists a grant this account GAVE — the block is about what it may open", async () => { @@ -311,12 +336,17 @@ describe("accountAccess — the active record", () => { displayName: owner.displayName, access: "read", level: "read", + recordKind: "shared", sections: null, canWrite: false, }); // The active entry is one of the listed ones, by construction. A banner // that had to join the two could render unnamed when they disagreed. expect(accountAccess.accounts).toContainEqual(accountAccess.active); + // The block-level kind moves with the switch. It reads `self` in the + // empty case above, so a value stuck on one of the two would show up as + // one of these two cases disagreeing with the session. + expect(accountAccess.recordKind).toBe("shared"); }); it("reads as not-switched when the stamp outlives the grant", async () => {