Files
HealthLog/tests/integration/sharing-account-access.test.ts
T
Marc-André Bombeck 386e92e6f9 test(sharing): carry the two domain lists through the account-access contract
The account-access integration contract compares whole entries, so the
two lists the payload now publishes belong in its expected shapes. The
managed-record leg posts the mood entry with the logged-at instant the
create schema requires.
2026-09-09 20:06:48 +02:00

446 lines
16 KiB
TypeScript

/**
* 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);
});
});