mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test: preserve sharing client compatibility
Runs the legacy whole-record decoder over the entries `resolveAccountAccess` actually produces, rather than over the fixture objects that describe them: the existing case proves both ends and never the assembly between them, which is the shape of test this project keeps rediscovering. Whole READ and WRITE decode, MANAGE decodes as write and never above the level it confers, a scoped grant is refused rather than read as the whole record, and a stored scope the server cannot parse reads as nothing on both ends. The canonical readers are driven over the same server-built entry, so `level`, `sections` and `recordKind` are proved to survive the trip to the capability resolver. The account-payload guard gains the pair for `recordSession`: a field whose only reader is the transport type is exactly what that guard exists to catch.
This commit is contained in:
1 parent
38766cd4ca
commit
cc316a7b85
2 files changed
+219
No files matched your search
@@ -206,6 +206,30 @@ describe("account payload consumer guard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("has a consumer that ADOPTS the record-session context, not only a type for it", () => {
|
||||
// `recordSession` is the field the fence hands the browser, and the whole
|
||||
// point of it is that a client adopts the server's value rather than
|
||||
// deriving one. The generic sweep above is satisfied by any read anywhere;
|
||||
// this leg names the two places that have to do the adopting, because a
|
||||
// payload field read only by the transport type is the shape the file's
|
||||
// opening paragraph is about.
|
||||
expect(fields).toContain("recordSession");
|
||||
|
||||
const adopters = [
|
||||
join(SRC, "hooks", "use-account-switch.ts"),
|
||||
join(SRC, "hooks", "use-record-capabilities.ts"),
|
||||
];
|
||||
let reads = 0;
|
||||
for (const file of adopters) {
|
||||
expect(stripComments(readFileSync(file, "utf8"))).toMatch(
|
||||
/recordSession/,
|
||||
);
|
||||
reads += 1;
|
||||
}
|
||||
expect(reads).toBe(adopters.length);
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has non-zero entry and active-record readers for additive access fields", () => {
|
||||
const entrySource = stripComments(
|
||||
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* v1.37.0 — what a client makes of the account payload, old and new.
|
||||
*
|
||||
* The sharing block gained three fields this release (`level`, `sections`,
|
||||
* `recordKind`) and kept the two the v1.36.0 contract published (`access`,
|
||||
* `canWrite`). Two questions follow from that, and this file answers both
|
||||
* against the payload the SERVER builds rather than against a copy of it:
|
||||
*
|
||||
* 1. a client that predates the release still decodes a whole-record grant,
|
||||
* and fails closed on a scoped one rather than reading it as the whole
|
||||
* record;
|
||||
* 2. the canonical readers consume the new fields off both the entry and the
|
||||
* active record, without a shim in between.
|
||||
*
|
||||
* ## Why it drives `resolveAccountAccess` and not a literal
|
||||
*
|
||||
* `src/lib/sharing/__tests__/account-access.test.ts` already runs the legacy
|
||||
* decoder over the FIXTURE objects, which carry an `access` and a `sections`
|
||||
* key of their own. That proves the decoder and proves the fixture; it cannot
|
||||
* prove that a payload the server actually emits decodes, because no server
|
||||
* code runs between them. This file resolves each fixture case through the real
|
||||
* resolver and hands the decoder the entry that comes out — both ends and the
|
||||
* pipe. The fixture's canonical fields are asserted against that entry too, so
|
||||
* a fixture that drifted from the server would fail here rather than quietly
|
||||
* describe something the product no longer does.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { accountGrant: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import {
|
||||
decodeLegacyWholeRecordAccess,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
LEGACY_ACCOUNT_PAYLOADS,
|
||||
type LegacyAccountPayloadFixture,
|
||||
} from "../../tests/fixtures/v137/legacy-account-payloads";
|
||||
|
||||
const OWNER = "record-owner";
|
||||
|
||||
/** The grant row behind one fixture case, as Prisma would return it. */
|
||||
function grantFor(fixture: LegacyAccountPayloadFixture) {
|
||||
return {
|
||||
id: `grant-${fixture.name}`,
|
||||
access:
|
||||
fixture.level === "manage"
|
||||
? "MANAGE"
|
||||
: fixture.level === "write"
|
||||
? "WRITE"
|
||||
: "READ",
|
||||
scopeJson: fixture.sections,
|
||||
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
grantor: {
|
||||
id: OWNER,
|
||||
username: "record-owner",
|
||||
displayName: "Record owner",
|
||||
managedProfileAt:
|
||||
fixture.recordKind === "managed"
|
||||
? new Date("2026-08-01T00:00:00.000Z")
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry and the active record the server publishes for one case. */
|
||||
async function publishedFor(fixture: LegacyAccountPayloadFixture): Promise<{
|
||||
entry: AccountAccessEntry;
|
||||
active: AccountAccessEntry | null;
|
||||
}> {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
grantFor(fixture),
|
||||
] as never);
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: OWNER },
|
||||
});
|
||||
return { entry: access.accounts[0], active: access.active };
|
||||
}
|
||||
|
||||
/** read < write < manage, so "never more than" is a comparison and not prose. */
|
||||
const RANK = { read: 0, write: 1, manage: 2 } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("what an older client makes of the v1.37.0 account payload", () => {
|
||||
it("covers every fixture case, and the fixtures describe the server", async () => {
|
||||
// Non-zero discovery, and the assertion that keeps the fixture honest: it
|
||||
// is a description of what the resolver emits, not a parallel invention.
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS.length).toBeGreaterThan(0);
|
||||
|
||||
for (const fixture of LEGACY_ACCOUNT_PAYLOADS) {
|
||||
const { entry } = await publishedFor(fixture);
|
||||
expect(entry, fixture.name).toEqual(
|
||||
expect.objectContaining({
|
||||
access: fixture.access,
|
||||
level: fixture.level,
|
||||
sections: fixture.sections,
|
||||
canWrite: fixture.canWrite,
|
||||
recordKind: fixture.recordKind,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(LEGACY_ACCOUNT_PAYLOADS)(
|
||||
"decodes $name the way the fixture says an old client would",
|
||||
async (fixture) => {
|
||||
const { entry } = await publishedFor(fixture);
|
||||
const decoded = decodeLegacyWholeRecordAccess(entry);
|
||||
|
||||
expect(decoded === null ? "deny" : "allow").toBe(fixture.legacyDecoder);
|
||||
if (fixture.legacyDecoder === "allow") {
|
||||
expect(decoded).toBe(fixture.access);
|
||||
// Never MORE than the grant confers. A MANAGE grant decodes as `write`
|
||||
// on an old client — under-reporting what it may do, which costs a
|
||||
// control and leaks nothing. The reverse would hand a client a level
|
||||
// the server will refuse.
|
||||
expect(RANK[decoded!]).toBeLessThanOrEqual(RANK[fixture.level]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a scoped grant rather than reading it as the whole record", async () => {
|
||||
const scoped = LEGACY_ACCOUNT_PAYLOADS.find(
|
||||
(fixture) => fixture.sections !== null,
|
||||
);
|
||||
expect(scoped, "the fixture set must carry a scoped case").toBeDefined();
|
||||
|
||||
const { entry } = await publishedFor(scoped!);
|
||||
expect(entry.sections).not.toBeNull();
|
||||
expect(decodeLegacyWholeRecordAccess(entry)).toBeNull();
|
||||
|
||||
// And the reason is the field, not the level: the same grant with the
|
||||
// sections removed decodes, so a client on the old contract is refused for
|
||||
// carrying a narrowing it cannot express rather than for being at READ.
|
||||
expect(decodeLegacyWholeRecordAccess({ ...entry, sections: null })).toBe(
|
||||
entry.access,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the canonical readers make of the same payload", () => {
|
||||
it.each(LEGACY_ACCOUNT_PAYLOADS)(
|
||||
"carries level, sections and recordKind through the active record for $name",
|
||||
async (fixture) => {
|
||||
const { active } = await publishedFor(fixture);
|
||||
expect(active, "the session is switched into the record").not.toBeNull();
|
||||
|
||||
const capabilities = resolveRecordCapabilities(active);
|
||||
expect(capabilities.level).toBe(fixture.level);
|
||||
expect(capabilities.sections).toEqual(fixture.sections);
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and MANAGE does not become `canManage` on the client — a
|
||||
// delegate's management reach is the server's answer, one request at a
|
||||
// time.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
expect(capabilities.canManage).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads a scope the server could not parse as nothing, on both ends", async () => {
|
||||
// The fail-closed reading: a stored scope this build cannot make sense of
|
||||
// resolves to an EMPTY section list rather than to the whole record. The
|
||||
// server refuses every section for it, and the client has to agree — an
|
||||
// empty array rendered as "everything" would paint the entire product for
|
||||
// a grant that opens none of it.
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
{ ...grantFor(LEGACY_ACCOUNT_PAYLOADS[0]), scopeJson: ["not-a-section"] },
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: OWNER },
|
||||
});
|
||||
|
||||
expect(access.accounts[0].sections).toEqual([]);
|
||||
expect(resolveRecordCapabilities(access.active).sections).toEqual([]);
|
||||
// An old client is refused outright rather than being handed the whole
|
||||
// record, which is the same fail-closed direction.
|
||||
expect(decodeLegacyWholeRecordAccess(access.accounts[0])).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user