/** * The `accountAccess` block on `GET /api/auth/me`, over the real route, * against real Postgres. * * The block is the only thing either client is allowed to know about its own * sharing permissions, so what is tested here is not "the field is present" * but "the field says the same thing the request resolver would". Every case * below is a state the grant table can actually be in — pending, active, * expired, revoked, self — and the question each time is whether the payload * would let a client paint a switcher entry the server would then refuse. * * Nothing is mocked below the route. A unit test with a stubbed Prisma could * prove the route calls the resolver; it could not prove that a grant which * lapsed an hour ago drops out of the list, which is the property the whole * block exists to carry. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), cookies: vi.fn(async () => ({ get: (name: string) => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, set: () => {}, delete: () => {}, })), }; }); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); import { ACCOUNT_SELECTOR_HEADER } from "@/lib/api-handler"; import { hashToken } from "@/lib/auth/hmac"; let counter = 0; async function makeUser(label: string) { const suffix = `${label}-${counter++}`; return getPrismaClient().user.create({ data: { username: `access-${suffix}`, email: `access-${suffix}@example.test`, displayName: `Access ${suffix}`, role: "USER", onboardingCompletedAt: new Date(), }, }); } async function signIn(userId: string) { const session = await getPrismaClient().session.create({ data: { userId, expiresAt: new Date(Date.now() + 60_000) }, }); cookieJar.set("healthlog_session", session.id); return session; } interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; fullName: string | null; /** * 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; writableDomains: string[]; manageableDomains: string[]; } interface AccountAccessBlock { accounts: AccountAccessEntry[]; active: AccountAccessEntry | null; recordKind: "self" | "shared" | "managed"; canSwitch: boolean; } async function readMe(): Promise<{ status: number; accountAccess: AccountAccessBlock; id: string; }> { const { GET } = await import("@/app/api/auth/me/route"); const res = await GET(); const body = (await res.json()) as { data: { id: string; accountAccess: AccountAccessBlock }; }; return { status: res.status, accountAccess: body.data?.accountAccess, id: body.data?.id, }; } /** An accepted, never-ending grant from `grantorId` to `granteeId`. */ async function grantAccess( grantorId: string, granteeId: string, extra: { expiresAt?: Date | null; acceptedAt?: Date | null; access?: "READ" | "WRITE" | "MANAGE"; scopeJson?: unknown; } = {}, ) { return getPrismaClient().accountGrant.create({ data: { grantorId, granteeId, access: extra.access ?? "READ", invitedAt: new Date(), acceptedAt: extra.acceptedAt === undefined ? new Date() : extra.acceptedAt, expiresAt: extra.expiresAt ?? null, ...(extra.scopeJson === undefined ? {} : { scopeJson: extra.scopeJson as never }), }, }); } beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); }); describe("accountAccess — what the payload publishes", () => { it("is present and empty for an account nobody has shared with", async () => { const alone = await makeUser("alone"); await signIn(alone.id); const { status, accountAccess } = await readMe(); expect(status).toBe(200); // Present, not missing. A client that got no field could not tell "this // server does not do sharing" from "nobody shared anything with me", and // would have to guess which. 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, }); }); it("lists an accepted grant with the level resolved, and canWrite false", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts).toEqual([ { accountId: owner.id, username: owner.username, displayName: owner.displayName, fullName: owner.fullName, 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, writableDomains: [], manageableDomains: [], }, ]); expect(accountAccess.canSwitch).toBe(true); expect(accountAccess.active).toBeNull(); }); it("omits a pending invitation — being offered access is not having it", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id, { acceptedAt: null }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts).toEqual([]); expect(accountAccess.canSwitch).toBe(false); }); it("omits a grant that has lapsed, against the request clock", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id, { expiresAt: new Date(Date.now() - 60_000), }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts).toEqual([]); }); it("omits a revoked grant", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const grant = await grantAccess(owner.id, delegate.id); await getPrismaClient().accountGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts).toEqual([]); }); it("publishes the sections a scoped grant opens, exactly as stored", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id, { // Stored out of the consent screen's order on purpose: what the payload // publishes is a resolved value in the reading order, not the column. scopeJson: ["labs", "medications"], }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts[0].sections).toEqual(["medications", "labs"]); expect(accountAccess.accounts[0].level).toBe("read"); }); it("publishes the whole record as null, never as a list of eight", async () => { // Null is the answer every pre-v1.37.0 grant carries and the one an owner // who does not narrow still gives. Expanding it into all eight sections // would turn a first-class value into a choice nobody made, and would // start growing by release — a grant would quietly gain a section the // owner never agreed to. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts[0].sections).toBeNull(); }); it("publishes nothing at all for a scope it cannot read", async () => { // Fail-closed, end to end. A blob this build cannot parse resolves to the // empty set in the resolver, so the payload says the same: the grant // opens nothing. A payload that reported null here would paint a full // switcher entry for a grant the server refuses on every request. const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id, { scopeJson: "garbage" }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts[0].sections).toEqual([]); }); 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" }); await signIn(delegate.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts[0].level).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 () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); // The owner signs in. They gave the access; they did not receive any. await signIn(owner.id); const { accountAccess } = await readMe(); expect(accountAccess.accounts).toEqual([]); expect(accountAccess.canSwitch).toBe(false); }); }); describe("accountAccess — the active record", () => { it("resolves the stamped account to a full entry, not just an id", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); const session = await signIn(delegate.id); await switchSessionTo(session.id, owner.id); const { accountAccess, id } = await readMe(); // Still the delegate's own payload — the switch changes which record the // delegable routes read, never whose preferences /me returns. expect(id).toBe(delegate.id); expect(accountAccess.active).toEqual({ accountId: owner.id, username: owner.username, displayName: owner.displayName, fullName: owner.fullName, access: "read", level: "read", recordKind: "shared", sections: null, canWrite: false, writableDomains: [], manageableDomains: [], }); // 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 () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); const grant = await grantAccess(owner.id, delegate.id); const session = await signIn(delegate.id); await switchSessionTo(session.id, owner.id); // The grant lapses while the browser sits inside the record. Expiry has // no session cleanup — only revocation does — so the stamp survives it. await getPrismaClient().accountGrant.update({ where: { id: grant.id }, data: { expiresAt: new Date(Date.now() - 1000) }, }); const { accountAccess } = await readMe(); // The payload agrees with what the resolver will decide about the next // delegated read: there is no live grant, so there is no active record. // The client's job is to clear the stamp, which is what the // `sharing.access.denied` reset does. expect(accountAccess.active).toBeNull(); expect(accountAccess.accounts).toEqual([]); }); }); describe("accountAccess — the actor surface refuses a selector header", () => { it("refuses a Bearer caller that attaches an account selector", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); const raw = `hlk_${delegate.id}-token`.padEnd(20, "0"); await getPrismaClient().apiToken.create({ data: { userId: delegate.id, name: "account-access-test", tokenHash: hashToken(raw), permissions: ["*"], }, }); cookieJar.clear(); headerJar.set("authorization", `Bearer ${raw}`); headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id); const { GET } = await import("@/app/api/auth/me/route"); const res = await GET(); const body = (await res.json()) as { meta?: { errorCode?: string } }; // The account payload is DEFINED as the caller's own. A client that // attached a selector to it believes it is asking about the owner and is // not; answering correctly to the wrong question is how a support ticket // about "data that looks wrong" starts. expect(res.status).toBe(403); expect(body.meta?.errorCode).toBe("sharing.not_permitted"); }); it("serves a Bearer caller with no selector, and reports no active record", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); const raw = `hlk_${delegate.id}-plain`.padEnd(20, "0"); await getPrismaClient().apiToken.create({ data: { userId: delegate.id, name: "account-access-test", tokenHash: hashToken(raw), permissions: ["*"], }, }); cookieJar.clear(); headerJar.set("authorization", `Bearer ${raw}`); const { status, accountAccess } = await readMe(); expect(status).toBe(200); // The token transport carries its selector per request, so this payload // is never inside a record. `accounts` still answers "which records may // I open", which is what the native switcher renders. expect(accountAccess.active).toBeNull(); expect(accountAccess.accounts).toHaveLength(1); expect(accountAccess.canSwitch).toBe(true); }); });