test(sharing): hold every arm of the acting-account resolver

The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.

The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.

The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.

The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 11:32:13 +02:00
1 parent e9029ed3c2
commit 3f932040ad
3 files changed
+1133

No files matched your search

@@ -0,0 +1,135 @@
/**
* Structural guards on the acting-account boundary.
*
* The behavioural tests in `src/lib/__tests__/acting-account-resolver.test.ts`
* prove what today's code does. These prove what tomorrow's may not do, and
* they exist because the boundary has one shape that keeps it safe: the acting
* account is a value ONE function is allowed to act on, and the helpers that
* decide role and step-up never see it.
*
* Tripwires, not proofs. They cannot show the resolver is correct — only that
* the boundary has not moved without somebody editing this file. Every leg
* below asserts a non-zero match count first, because a guard whose matcher
* finds nothing reports success, and this repository has shipped several of
* those.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join, sep } from "node:path";
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
/** The source text of a top-level exported function, braces included. */
function functionBody(src: string, name: string): string {
const start = src.indexOf(`export async function ${name}(`);
if (start === -1) return "";
const open = src.indexOf("{", src.indexOf(")", start));
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth -= 1;
if (depth === 0) return src.slice(open, i + 1);
}
}
return "";
}
describe("the acting-account carrier is read in one place", () => {
/**
* Files allowed to touch `Session.actingAsUserId`.
*
* The column is a selector — it names an account, it does not authorise one.
* Anything that reads it and acts on the value without going through the
* resolver has invented a second answer to "whose record is this", and the
* copy that drifts is always the one nobody reads.
*
* Later items in this feature will extend this list, and that is the point:
* the switch endpoint (which writes it) and revocation cleanup (which clears
* it) arrive as a visible diff here rather than as a quiet new reader.
*
* The matcher reads source text and does not exempt comments, so a file that
* merely NAMES the column in prose trips it. Left that way deliberately: a
* guard over an authorization carrier that shouts at a mention is the right
* direction to be wrong in.
*/
const CARRIER_ALLOWLIST = [
// Projects it off the session row it already loaded.
"lib/auth/session.ts",
// The resolver. The only thing that acts on the value.
"lib/api-handler.ts",
].sort();
it("no other file reads or writes the carrier column", () => {
const readers = filesMatching(/actingAsUserId|acting_as_user_id/);
// Non-zero proof: an emptied allowlist must fail rather than agree with an
// emptied match set.
expect(readers.length).toBeGreaterThan(0);
expect(readers).toEqual(CARRIER_ALLOWLIST);
});
it("the selector header is named in one place", () => {
// A route that read the header itself would be deciding an authorisation
// question the resolver exists to answer once.
const namers = filesMatching(/x-healthlog-account/i);
expect(namers.length).toBeGreaterThan(0);
expect(namers).toEqual(["lib/api-handler.ts"]);
});
});
describe("the cookie-only helpers cannot see the acting account", () => {
/**
* `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` are safe under a
* switch for one reason: they resolve through `getSession()`, which answers
* who is CALLING, and they never consult the carrier. That is the property
* that keeps role checks and step-up gates structurally out of a delegate's
* reach, and it is one careless line away from being untrue.
*/
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
const body = functionBody(read("lib/api-handler.ts"), name);
// Non-zero proof, twice over: the slice found a function, and the slice is
// the function it claims to be. A renamed helper must fail here rather
// than silently assert against an empty string.
expect(body.length).toBeGreaterThan(0);
expect(body).toContain("getSession()");
expect(body).not.toContain("actingAsUserId");
expect(body).not.toContain("requireRecordAuth");
expect(body).not.toContain("readActingCarrier");
});
it("substitutes a data-scope user in exactly one function", () => {
const src = read("lib/api-handler.ts");
const record = functionBody(src, "requireRecordAuth");
expect(record).toContain("readActingCarrier");
// `readActingCarrier` is what turns a request into "acting as somebody
// else". Two callers: the bare path, which refuses on it, and the record
// path, which acts on it. A third would be a mode nobody declared.
const callers = (src.match(/await readActingCarrier\(/g) ?? []).length;
expect(callers).toBe(2);
});
});
@@ -0,0 +1,616 @@
/**
* v1.36.0 — the acting-account resolver, arm by arm.
*
* Three things are worth more here than anywhere else in the feature, because
* everything downstream trusts them:
*
* 1. **It fails closed.** A bare `requireAuth()` reached under an active
* switch refuses. It must never fall back to the caller's own account —
* a delegate who believes they are reading the owner's record being handed
* their own is the same data-mixing failure, pointed the other way.
* 2. **Refusals do not enumerate.** A selector naming an account that does
* not exist and one naming an account that granted nothing produce the
* same bytes. Asserted as bytes, not as "both were 403".
* 3. **Nothing is cached.** The grant is loaded on the request that uses it.
* Proven against a real database in
* `tests/integration/acting-account-resolver.test.ts`, where revoking
* between two requests is a thing that can actually happen.
*
* The grant module is deliberately NOT mocked. The resolver consuming S1's
* `isGrantActive` rather than its own idea of "active" is the property that
* keeps the panel and the resolver from disagreeing, and a mocked predicate
* would report that property working while it was broken. The Prisma fake below
* honours its `where` clause for the same reason: a fake that ignores `where`
* turns every scoping assertion into a check that cannot fail.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be hoisted before importing the module under test. ---
interface GrantRow {
id: string;
grantorId: string;
granteeId: string;
access: "READ" | "WRITE";
acceptedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date | null;
}
const grantRows: GrantRow[] = [];
const userRows = new Map<string, { id: string; role: string }>();
const touched: string[] = [];
vi.mock("@/lib/db", () => ({
prisma: {
accountGrant: {
// Honours the narrowing the real query performs. `findActiveGrant`
// narrows on `revokedAt: null` in SQL and leaves accepted/expired to the
// state machine; a fake that returned any row for any `where` would make
// the cross-user arms below pass no matter what the resolver did.
findFirst: vi.fn(
async ({
where,
}: {
where: {
grantorId: string;
granteeId: string;
revokedAt: null;
};
}) =>
grantRows.find(
(g) =>
g.grantorId === where.grantorId &&
g.granteeId === where.granteeId &&
(where.revokedAt === null ? g.revokedAt === null : true),
) ?? null,
),
update: vi.fn(async ({ where }: { where: { id: string } }) => {
touched.push(where.id);
return {};
}),
},
user: {
findUnique: vi.fn(
async ({ where }: { where: { id: string } }) =>
userRows.get(where.id) ?? null,
),
},
apiToken: { findUnique: vi.fn(), update: vi.fn() },
webauthnMfaCredential: { count: vi.fn(async () => 0) },
session: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
const headerJar = new Map<string, string>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// --- Imports use the mocked modules above. ---
import { NextRequest, NextResponse } from "next/server";
import {
ACCOUNT_SELECTOR_HEADER,
apiHandler,
requireActorAuth,
requireAdmin,
requireAuth,
requireCookieAuth,
requireFreshMfa,
requireRecordAuth,
type RecordAuthContext,
} from "../api-handler";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { emitIfSampled } from "@/lib/logging/transports";
import { prisma } from "@/lib/db";
import type { WideEvent } from "@/lib/logging/types";
const OWNER = "owner-1";
const DELEGATE = "delegate-1";
function user(id: string, role: "USER" | "ADMIN" = "USER") {
return { id, role, username: id, email: `${id}@example.test` };
}
/** Sign the delegate in over the cookie transport. */
function signedInCookie(
actingAsUserId: string | null = null,
role: "USER" | "ADMIN" = "USER",
): void {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId,
},
user: user(DELEGATE, role) as never,
});
}
/** Sign the delegate in over the Bearer transport. */
function signedInBearer(): void {
vi.mocked(getSession).mockResolvedValue(null);
headerJar.set("authorization", "Bearer hlk_" + "a".repeat(64));
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: DELEGATE,
permissions: ["*"],
revoked: false,
expiresAt: new Date(Date.now() + 3_600_000),
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
userRows.set(DELEGATE, user(DELEGATE));
}
function selector(value: string): void {
headerJar.set(ACCOUNT_SELECTOR_HEADER, value);
}
function liveGrant(overrides: Partial<GrantRow> = {}): GrantRow {
const row: GrantRow = {
id: "grant-1",
grantorId: OWNER,
granteeId: DELEGATE,
access: "READ",
acceptedAt: new Date(Date.now() - 60_000),
revokedAt: null,
expiresAt: null,
...overrides,
};
grantRows.push(row);
return row;
}
/**
* Run a resolver call the way a route runs it: inside `apiHandler`, against a
* real `NextRequest`, so the HTTP method the resolver reads is the one the
* request actually carried and the refusal is a real HTTP response.
*/
function route(
resolve: () => Promise<unknown>,
): (method?: string) => Promise<Response> {
// A zero-argument handler is assignable to the one-argument shape Next.js
// calls; naming the type here keeps the call site honest without an unused
// parameter.
const handler: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
await resolve();
return NextResponse.json({ data: null, error: null });
},
);
return (method = "GET") =>
handler(new NextRequest("http://localhost/api/test", { method }));
}
/**
* Resolve inside a route and hand back what the handler body would have seen.
* Every substitution assertion runs through here rather than calling the
* resolver bare, because the HTTP method is part of the decision and a bare
* call has none.
*/
async function resolveInRoute<T>(
resolve: () => Promise<T>,
method = "GET",
): Promise<T> {
let value: T | undefined;
const response = await route(async () => {
value = await resolve();
})(method);
expect(response.status).toBe(200);
return value as T;
}
/** The wide event the last run emitted. */
function lastEvent(): WideEvent {
const calls = vi.mocked(emitIfSampled).mock.calls;
return calls[calls.length - 1][0] as WideEvent;
}
function sharingAuditCalls() {
return vi
.mocked(auditLog)
.mock.calls.filter(([action]) => action === "sharing.access.denied");
}
beforeEach(() => {
vi.clearAllMocks();
grantRows.length = 0;
userRows.clear();
touched.length = 0;
headerJar.clear();
userRows.set(OWNER, user(OWNER));
userRows.set(DELEGATE, user(DELEGATE));
});
describe("do no harm — nothing is acting as anyone", () => {
it("resolves every mode to the caller, and never asks about a grant", async () => {
signedInCookie(null);
const bare = await resolveInRoute(() => requireAuth());
expect(bare.user.id).toBe(DELEGATE);
const actor = await resolveInRoute(() => requireActorAuth());
expect(actor.user.id).toBe(DELEGATE);
const record = await resolveInRoute(() => requireRecordAuth("read"));
expect(record.user.id).toBe(DELEGATE);
expect(record.actor.id).toBe(DELEGATE);
expect(record.grantId).toBeNull();
// No carrier means no authorization question was ever asked. If this
// fires, the resolver is doing work on the un-switched path — which is
// where 100% of today's traffic lives.
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
expect(sharingAuditCalls()).toHaveLength(0);
});
it("leaves the wide event's actor identity alone", async () => {
signedInCookie(null);
const call = route(() => requireRecordAuth("read"));
await call();
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
expect(lastEvent().auth?.acting_as).toBeUndefined();
});
});
describe("bare requireAuth refuses to run under a switch", () => {
it("refuses the session carrier without falling back to the caller", async () => {
signedInCookie(OWNER);
liveGrant();
const response = await route(() => requireAuth())();
expect(response.status).toBe(403);
const body = await response.json();
expect(body.meta.errorCode).toBe("sharing.not_permitted");
expect(body.data).toBeNull();
});
it("refuses even when the grant behind the switch is perfectly valid", async () => {
// The point of the fail-closed arm: the grant is not the question. The
// route never declared that it can be used on someone else's record.
signedInCookie(OWNER);
liveGrant();
await expect(requireAuth()).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
statusCode: 403,
});
});
it("refuses a Bearer selector, and records it", async () => {
signedInBearer();
selector(OWNER);
liveGrant();
await expect(requireAuth()).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
});
expect(sharingAuditCalls()).toHaveLength(1);
expect(sharingAuditCalls()[0][1]).toMatchObject({
userId: DELEGATE,
details: expect.objectContaining({
reason: "undeclared_mode",
carrier: "header",
target: OWNER,
}),
});
});
it("refuses a selector sent over the cookie transport", async () => {
// The header is a Bearer-only carrier. Ignoring it here would serve the
// caller's own record to a client that believes it asked for another's.
signedInCookie(null);
selector(OWNER);
await expect(requireAuth()).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
});
expect(sharingAuditCalls()[0][1]).toMatchObject({
details: expect.objectContaining({ carrier: "misplaced-header" }),
});
});
it("keeps the ambient web refusal out of the audit table", async () => {
// Every navigation and poll that touches a non-delegable route lands here
// while a switch is on. A row per request would bury the header refusals,
// which are the ones that mean something.
signedInCookie(OWNER);
await expect(requireAuth()).rejects.toThrow();
expect(sharingAuditCalls()).toHaveLength(0);
});
});
describe("requireActorAuth — always the caller's own rows", () => {
it("works under a session switch and answers as the caller", async () => {
signedInCookie(OWNER);
liveGrant();
const ctx = await requireActorAuth();
expect(ctx.user.id).toBe(DELEGATE);
// The switcher, the banner and the "whose records may I open" list all run
// while switched, and every one of them is about the delegate.
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
});
it("refuses a Bearer selector loudly", async () => {
signedInBearer();
selector(OWNER);
liveGrant();
await expect(requireActorAuth()).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
statusCode: 403,
});
expect(sharingAuditCalls()[0][1]).toMatchObject({
details: expect.objectContaining({ reason: "actor_surface" }),
});
});
it("refuses a selector on the cookie transport too", async () => {
signedInCookie(null);
selector(OWNER);
await expect(requireActorAuth()).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
});
});
});
describe("requireRecordAuth — substitution", () => {
it("substitutes the owner as the data scope and keeps the delegate as actor", async () => {
signedInCookie(OWNER);
const grant = liveGrant();
const resolved: RecordAuthContext = await resolveInRoute(() =>
requireRecordAuth("read"),
);
expect(resolved.user.id).toBe(OWNER);
expect(resolved.actor.id).toBe(DELEGATE);
expect(resolved.grantId).toBe(grant.id);
});
it("substitutes over the Bearer selector as well", async () => {
signedInBearer();
selector(OWNER);
liveGrant();
const resolved = await resolveInRoute(() => requireRecordAuth("read"));
expect(resolved.user.id).toBe(OWNER);
expect(resolved.actor.id).toBe(DELEGATE);
});
it("names the acting account on the wide event without moving the actor", async () => {
signedInCookie(OWNER);
liveGrant();
await route(() => requireRecordAuth("read"))();
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
expect(lastEvent().auth?.acting_as).toBe(OWNER);
});
it("stamps the grant as used without making the read wait on it", async () => {
signedInCookie(OWNER);
const grant = liveGrant();
await resolveInRoute(() => requireRecordAuth("read"));
await new Promise((r) => setTimeout(r, 0));
expect(touched).toEqual([grant.id]);
});
});
describe("requireRecordAuth — every way a grant can fail", () => {
const refused = async (): Promise<Response> => {
const response = await route(() => requireRecordAuth("read"))();
expect(response.status).toBe(403);
expect((await response.clone().json()).meta.errorCode).toBe(
"sharing.access.denied",
);
return response;
};
it("refuses a pending invitation", async () => {
signedInCookie(OWNER);
liveGrant({ acceptedAt: null });
await refused();
});
it("refuses a revoked grant", async () => {
signedInCookie(OWNER);
liveGrant({ revokedAt: new Date(Date.now() - 1000) });
await refused();
});
it("refuses an expired grant", async () => {
signedInCookie(OWNER);
liveGrant({ expiresAt: new Date(Date.now() - 1000) });
await refused();
});
it("refuses a grant made to somebody else", async () => {
signedInCookie(OWNER);
liveGrant({ granteeId: "other-delegate" });
await refused();
});
it("refuses a write method under a read grant", async () => {
signedInCookie(OWNER);
liveGrant();
const response = await route(() => requireRecordAuth("read"))("POST");
expect(response.status).toBe(403);
expect((await response.json()).meta.errorCode).toBe(
"sharing.access.denied",
);
});
it("refuses a declared write under a read grant", async () => {
signedInCookie(OWNER);
liveGrant();
const response = await route(() => requireRecordAuth("write"))();
expect(response.status).toBe(403);
});
it("admits a write method once the grant says WRITE", async () => {
// v1 never writes a WRITE row, so this arm is reached by the data rather
// than by a special case — which is what makes v2 enforcement work instead
// of a rewrite.
signedInCookie(OWNER);
liveGrant({ access: "WRITE" });
const response = await route(() => requireRecordAuth("write"))("POST");
expect(response.status).toBe(200);
});
it("refuses when the method cannot be known", async () => {
// Outside an event context there is no method to check, so we cannot prove
// the request is a read. Same fail-closed posture as the MCP audience
// guard.
signedInCookie(OWNER);
liveGrant();
await expect(requireRecordAuth("read")).rejects.toMatchObject({
errorCode: "sharing.access.denied",
});
});
it("refuses a selector too long to name an account, without querying", async () => {
signedInBearer();
selector("x".repeat(65));
await expect(requireRecordAuth("read")).rejects.toMatchObject({
errorCode: "sharing.access.denied",
});
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
});
it("refuses a selector sent over the cookie transport", async () => {
signedInCookie(null);
selector(OWNER);
liveGrant();
await expect(requireRecordAuth("read")).rejects.toMatchObject({
errorCode: "sharing.not_permitted",
});
});
it("refuses when the owner row is gone under a surviving grant", async () => {
signedInCookie(OWNER);
liveGrant();
userRows.delete(OWNER);
await refused();
expect(sharingAuditCalls()[0][1]).toMatchObject({
details: expect.objectContaining({ reason: "owner_missing" }),
});
});
});
describe("refusals do not enumerate", () => {
it("answers identically for an account that does not exist and one that granted nothing", async () => {
signedInBearer();
selector("no-such-account-at-all");
const missing = await route(() => requireRecordAuth("read"))();
const missingBody = await missing.text();
vi.clearAllMocks();
headerJar.clear();
signedInBearer();
// An account that exists, has a session, has data, and simply never
// granted this caller anything.
userRows.set("real-stranger", user("real-stranger"));
selector("real-stranger");
const present = await route(() => requireRecordAuth("read"))();
const presentBody = await present.text();
expect(missing.status).toBe(present.status);
// Bytes, not statuses. A difference of one word here is an account
// enumeration oracle for anyone holding a login.
expect(missingBody).toBe(presentBody);
});
it("answers identically for a revoked grant and a grant that never existed", async () => {
signedInBearer();
selector(OWNER);
liveGrant({ revokedAt: new Date(Date.now() - 1000) });
const revoked = await route(() => requireRecordAuth("read"))();
const revokedBody = await revoked.text();
vi.clearAllMocks();
headerJar.clear();
grantRows.length = 0;
signedInBearer();
selector(OWNER);
const never = await route(() => requireRecordAuth("read"))();
expect(revokedBody).toBe(await never.text());
});
it("keeps the reason off the wire and on the audit row", async () => {
signedInBearer();
selector(OWNER);
liveGrant({ expiresAt: new Date(Date.now() - 1000) });
const body = await (await route(() => requireRecordAuth("read"))()).text();
expect(body).not.toContain("expire");
expect(body).not.toContain("grant");
expect(sharingAuditCalls()[0][1]).toMatchObject({
details: expect.objectContaining({ reason: "no_active_grant" }),
});
});
});
describe("the cookie-only helpers stay out of reach of a switch", () => {
it("resolves requireAdmin against the actor, never the account being acted on", async () => {
signedInCookie(OWNER, "ADMIN");
liveGrant();
userRows.set(OWNER, user(OWNER, "USER"));
const ctx = await requireAdmin();
// The role came off the caller's row. Switching into somebody's record
// must confer nothing, and switching out of an admin's must take nothing
// away.
expect(ctx.user.id).toBe(DELEGATE);
});
it("resolves requireCookieAuth against the actor", async () => {
signedInCookie(OWNER);
liveGrant();
const ctx = await requireCookieAuth();
expect(ctx.user.id).toBe(DELEGATE);
});
it("resolves requireFreshMfa against the actor", async () => {
signedInCookie(OWNER);
liveGrant();
vi.mocked(getSession).mockResolvedValue({
session: {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId: OWNER,
},
user: {
...user(DELEGATE),
totpConfirmedAt: new Date(Date.now() - 86_400_000),
} as never,
});
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(),
} as never);
const ctx = await requireFreshMfa(300);
expect(ctx.user.id).toBe(DELEGATE);
});
});
@@ -0,0 +1,382 @@
/**
* The acting-account resolver against real Postgres.
*
* The unit suite proves the arms. This file proves the two things a unit suite
* cannot, because both are about which ROWS come back:
*
* * **Substitution.** Under a valid switch the handler's `where` clause is
* built from the owner's id and returns the owner's measurements — not a
* mock's idea of them, and demonstrably not the delegate's own, which are
* seeded alongside so a resolver that quietly served the caller their own
* record would show up as the wrong ids rather than as an empty list.
* * **Nothing is cached.** A revocation lands between two requests inside one
* test and the second one 403s. Anything memoised — on the session, in the
* process, on the token — would let the first request's verdict survive
* into the second, and the owner's revoke button would mean "at some point
* later" instead of "now".
*
* The do-no-harm arm runs against a REAL shipped route (`GET /api/measurements`,
* a bare-`requireAuth` handler nobody has migrated). A mock cannot fake that,
* and the arm is worth nothing unless the route it exercises is one somebody
* actually ships.
*
* The delegable handler is assembled here rather than imported because no route
* declares `requireRecordAuth` yet — S3 lands the frozen list empty on purpose,
* and S6 migrates the first route into it. Everything under it is real: the
* real `apiHandler`, the real resolver, the real grant module, the real
* database.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } 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: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import {
ACCOUNT_SELECTOR_HEADER,
apiHandler,
requireRecordAuth,
} from "@/lib/api-handler";
import { hashToken } from "@/lib/auth/hmac";
import { prisma } from "@/lib/db";
import { acceptGrant, inviteGrant, revokeGrant } from "@/lib/sharing/grants";
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `acting-${suffix}`,
email: `acting-${suffix}@example.test`,
role: "USER",
},
});
}
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;
}
async function mintToken(userId: string): Promise<string> {
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId,
name: "acting-account-test",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
return raw;
}
async function seedWeight(userId: string, value: number) {
return getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
measuredAt: new Date(),
source: "MANUAL",
},
});
}
/**
* A delegable read handler: the real wrapper, the real resolver, and a `where`
* clause built from the resolved data-scope user exactly as a migrated route
* builds one.
*/
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
const { user, actor, grantId } = await requireRecordAuth("read");
const rows = await prisma.measurement.findMany({
where: { userId: user.id },
orderBy: { value: "asc" },
});
return NextResponse.json({
data: {
scopeUserId: user.id,
actorUserId: actor.id,
grantId,
values: rows.map((r) => r.value),
},
error: null,
});
},
);
function call(method = "GET"): Promise<Response> {
return delegableRead(
new NextRequest("http://localhost/api/test/delegable", { method }),
);
}
/** Owner and delegate, an accepted grant, and one measurement each. */
async function household() {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
await seedWeight(delegate.id, 64);
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
});
const grant = await acceptGrant({
grantId: invited.id,
granteeId: delegate.id,
ip: "203.0.113.7",
});
return { owner, delegate, grant };
}
/** Point the delegate's browser session at the owner's record. */
async function switchTo(sessionId: string, ownerId: string | null) {
await getPrismaClient().session.update({
where: { id: sessionId },
data: { actingAsUserId: ownerId },
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("acting-account resolver — substitution", () => {
it("returns the OWNER's rows to a switched delegate, not the delegate's own", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
const body = await (await call()).json();
expect(body.data.scopeUserId).toBe(owner.id);
expect(body.data.actorUserId).toBe(delegate.id);
expect(body.data.grantId).toBe(grant.id);
// The delegate's own 64 kg row exists and must not be in this answer. An
// empty list would have passed a weaker assertion while hiding the exact
// failure this feature has to avoid.
expect(body.data.values).toEqual([81]);
});
it("substitutes over the Bearer selector too", async () => {
const { owner, delegate } = await household();
const raw = await mintToken(delegate.id);
headerJar.set("authorization", `Bearer ${raw}`);
headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id);
const body = await (await call()).json();
expect(body.data.scopeUserId).toBe(owner.id);
expect(body.data.values).toEqual([81]);
});
it("stamps the grant as used", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
await call();
// Fire-and-forget, so give the stamp a tick to land before reading it.
await new Promise((resolve) => setTimeout(resolve, 50));
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grant.id },
});
expect(row.lastUsedAt).not.toBeNull();
});
it("serves the caller their own rows when nothing is switched", async () => {
const { delegate } = await household();
await signIn(delegate.id);
const body = await (await call()).json();
expect(body.data.scopeUserId).toBe(delegate.id);
expect(body.data.grantId).toBeNull();
expect(body.data.values).toEqual([64]);
});
});
describe("acting-account resolver — the decision is never cached", () => {
it("refuses the request after the one that came before it succeeded", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
const first = await call();
expect(first.status).toBe(200);
expect((await first.json()).data.values).toEqual([81]);
await revokeGrant({ grantId: grant.id, grantorId: owner.id });
const second = await call();
expect(second.status).toBe(403);
expect((await second.json()).meta.errorCode).toBe("sharing.access.denied");
});
it("stops on expiry with nothing having swept anything", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
expiresAt: new Date(Date.now() + 400),
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
expect((await call()).status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 500));
// The row did not change. Only the clock the resolver reads moved.
expect((await call()).status).toBe(403);
});
it("refuses the moment the grant is only pending", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await inviteGrant({ grantorId: owner.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
expect((await call()).status).toBe(403);
});
});
describe("acting-account resolver — do no harm", () => {
it("leaves an un-switched request on a real shipped route exactly as it was", async () => {
const user = await makeUser("solo");
await seedWeight(user.id, 72);
await signIn(user.id);
const { GET } = await import("@/app/api/measurements/route");
const response = await GET(
new NextRequest("http://localhost/api/measurements?type=WEIGHT"),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(
body.data.measurements.map((m: { value: number }) => m.value),
).toEqual([72]);
});
it("refuses that same route while a switch is active, and serves nobody's rows", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
const { GET } = await import("@/app/api/measurements/route");
const response = await GET(
new NextRequest("http://localhost/api/measurements?type=WEIGHT"),
);
expect(response.status).toBe(403);
const body = await response.json();
expect(body.meta.errorCode).toBe("sharing.not_permitted");
expect(body.data).toBeNull();
// Not the owner's rows, and — the failure that matters — not the
// delegate's own either.
expect(JSON.stringify(body)).not.toContain("64");
});
});
describe("acting-account resolver — refusals do not enumerate", () => {
it("answers a nonexistent account and a stranger's account with the same bytes", async () => {
const { delegate } = await household();
const stranger = await makeUser("stranger");
await seedWeight(stranger.id, 99);
const raw = await mintToken(delegate.id);
headerJar.set("authorization", `Bearer ${raw}`);
headerJar.set(ACCOUNT_SELECTOR_HEADER, "cmnosuchaccount000000000x");
const missing = await call();
const missingBody = await missing.text();
headerJar.set(ACCOUNT_SELECTOR_HEADER, stranger.id);
const present = await call();
const presentBody = await present.text();
expect(missing.status).toBe(403);
expect(present.status).toBe(403);
expect(missingBody).toBe(presentBody);
});
});
describe("acting-account resolver — the cookie-only helpers", () => {
it("resolves the actor under a live switch, against the real session row", async () => {
// The unit suite asserts this with `getSession` mocked, which cannot catch
// the one edit that would break it: `getSession` itself substituting the
// owner. Here the row is real, the switch is real, and the grant behind it
// is valid — so the only reason these helpers answer with the delegate is
// that `getSession` still answers "who is calling".
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
const { requireAdmin, requireCookieAuth } =
await import("@/lib/api-handler");
const cookieCtx = await requireCookieAuth();
expect(cookieCtx.user.id).toBe(delegate.id);
// The delegate is not an admin; the refusal proves the role was read off
// the caller's row and not the record owner's.
await getPrismaClient().user.update({
where: { id: owner.id },
data: { role: "ADMIN" },
});
await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("acting-account resolver — the carrier column", () => {
it("puts the delegate's browser back in its own account when the owner is deleted", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
await getPrismaClient().user.delete({ where: { id: owner.id } });
const row = await getPrismaClient().session.findUnique({
where: { id: session.id },
});
// The session survives (it belongs to the delegate) with the switch
// cleared. A CASCADE here would have signed the delegate out of their own
// account because somebody else deleted theirs.
expect(row).not.toBeNull();
expect(row!.actingAsUserId).toBeNull();
const body = await (await call()).json();
expect(body.data.scopeUserId).toBe(delegate.id);
});
});