mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A grant has meant one thing since it shipped: the whole record, to read or to add to. This lays the words for the two shapes the rest of the sharing work needs — part of a record, and management of one — while changing what no existing grant does. `ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`, clustered from the delegable read surfaces rather than invented, plus the distinguished `record` for the routes that read across them. It never converts to a `ModuleKey` and never imports one: two masks, two questions, no shared spelling, because the day they share a name is the day somebody makes one imply the other, and a module the owner enabled is not a section the owner shared. `account_grants.scope_json` is NULL on every row that exists, and NULL is the entire record — the answer those rows were consented as, so no backfill and no re-consent. A non-NULL value is read through one fail-closed normaliser: a blob it cannot parse opens nothing, including a section the blob happened to name. That is the deliberate inverse of the module preference normaliser, which fails open on purpose; both do the safe thing with a value nobody can read, and the safe thing differs because one answers an authorization question and the other a presentation question. `MANAGE` joins the level enum, ordered above WRITE so a write grant can never satisfy it, and carries no scope at all: management of part of a record promises a boundary that cannot survive an edit, so the invite path refuses to write one. `requireRecordAuth` takes the section as a second required argument. The requirement is the lever — a delegable route without a classification does not typecheck, so the set of classified routes cannot fall behind the set of delegable ones. All fifty-seven modules carry theirs. The structural guard freezes each against the production vocabulary, fails when a module's own arms disagree, and pins a non-zero count of resolved sections so a matcher that stopped reading the argument fails instead of agreeing with everything. `requireGuardianAuth` is exported and reaches nothing yet. It gates on the managed-profile marker rather than on the grant, so an adult who granted management of their own record cannot arrive at that record's settings by any argument about what MANAGE means: widening MANAGE would widen the record resolver and would not touch this one. Its frozen list lands empty, with the declaration site as the positive control that keeps "nothing reaches it" a measurement rather than a renamed symbol. `users.managed_profile_at` says since when a record is one somebody else runs. A timestamp and not a boolean, for a reason the schema has to keep possible: clearing it is the handover, and the grants and the activity trail span it untouched.
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
|
* What the audit trail records for a delegated action, and for a self action.
|
|
*
|
|
* The whole feature rests on one reading: `actorUserId` NULL means the account
|
|
* acted for itself. Every row written before the column existed means that,
|
|
* and every row written after it will mostly mean it too — so a threading rule
|
|
* that stamps an actor where none was acting does not add information, it
|
|
* changes what several years of history says. That reading is what these tests
|
|
* hold, against real rows.
|
|
*
|
|
* Round trips through the real routes, for the reason that cost this project a
|
|
* release: a unit stub proves the code ran, not what landed. Here the question
|
|
* is only ever "which row exists afterwards, and what is in its columns".
|
|
*/
|
|
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 { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
|
import { auditLog, DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit";
|
|
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
|
|
|
|
let counter = 0;
|
|
|
|
async function makeUser(label: string) {
|
|
const suffix = `${label}-${counter++}`;
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username: `audit-${suffix}`,
|
|
email: `audit-${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;
|
|
}
|
|
|
|
/**
|
|
* A delegable route that writes an audit row the way a migrated route does:
|
|
* `userId` from the resolved data scope, and nothing about the actor, because
|
|
* the actor is not the handler's business.
|
|
*/
|
|
const delegableAction: (request: NextRequest) => Promise<Response> = apiHandler(
|
|
async () => {
|
|
const { user } = await requireRecordAuth("read", "measurements");
|
|
await auditLog("measurement.read", {
|
|
userId: user.id,
|
|
details: { via: "test" },
|
|
});
|
|
return NextResponse.json({ data: { ok: true }, error: null });
|
|
},
|
|
);
|
|
|
|
/** The same handler's self-action case: no switch, same code path. */
|
|
function act(): Promise<Response> {
|
|
return delegableAction(
|
|
new NextRequest("http://localhost/api/test/delegable", { method: "GET" }),
|
|
);
|
|
}
|
|
|
|
async function household() {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
|
const session = await signIn(delegate.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: session.id },
|
|
data: { actingAsUserId: owner.id },
|
|
});
|
|
return { owner, delegate, session };
|
|
}
|
|
|
|
async function rowsFor(action: string) {
|
|
return getPrismaClient().auditLog.findMany({
|
|
where: { action },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
}
|
|
|
|
/** The resolver's access row is fire-and-forget; give it a tick to land. */
|
|
async function settle() {
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
describe("who the trail says did it", () => {
|
|
it("files a delegated action under the owner, naming the delegate", async () => {
|
|
const { owner, delegate } = await household();
|
|
|
|
expect((await act()).status).toBe(200);
|
|
|
|
const rows = await rowsFor("measurement.read");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].userId).toBe(owner.id);
|
|
expect(rows[0].actorUserId).toBe(delegate.id);
|
|
});
|
|
|
|
it("leaves the actor NULL when somebody acts on their own record", async () => {
|
|
// The load-bearing one. NULL is not "we did not know" — it is the
|
|
// statement every historical row already makes, and the same handler,
|
|
// unswitched, has to keep making it.
|
|
const solo = await makeUser("solo");
|
|
await signIn(solo.id);
|
|
|
|
expect((await act()).status).toBe(200);
|
|
|
|
const rows = await rowsFor("measurement.read");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].userId).toBe(solo.id);
|
|
expect(rows[0].actorUserId).toBeNull();
|
|
});
|
|
|
|
it("leaves the actor NULL on a row a delegate writes about themselves", async () => {
|
|
// A delegated request that files under the DELEGATE describes the
|
|
// delegate's own act. Stamping the actor there would say "somebody else
|
|
// did this to you" about a person acting on themselves.
|
|
const { delegate } = await household();
|
|
|
|
const handler: (request: NextRequest) => Promise<Response> = apiHandler(
|
|
async () => {
|
|
const { actor } = await requireRecordAuth("read", "measurements");
|
|
await auditLog("delegate.own.act", { userId: actor.id });
|
|
return NextResponse.json({ data: { ok: true }, error: null });
|
|
},
|
|
);
|
|
await handler(
|
|
new NextRequest("http://localhost/api/test/delegable", { method: "GET" }),
|
|
);
|
|
|
|
const rows = await rowsFor("delegate.own.act");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].userId).toBe(delegate.id);
|
|
expect(rows[0].actorUserId).toBeNull();
|
|
});
|
|
|
|
it("leaves the actor NULL on a refused delegation", async () => {
|
|
// The refusal row already exists (the resolver writes it). It is the
|
|
// caller's own act — an attempt they made as themselves — and it must not
|
|
// acquire an actor just because the request mentioned another account.
|
|
const delegate = await makeUser("delegate");
|
|
const stranger = await makeUser("stranger");
|
|
const session = await signIn(delegate.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: session.id },
|
|
data: { actingAsUserId: stranger.id },
|
|
});
|
|
|
|
expect((await act()).status).toBe(403);
|
|
await settle();
|
|
|
|
const rows = await rowsFor("sharing.access.denied");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].userId).toBe(delegate.id);
|
|
expect(rows[0].actorUserId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("delegated reads coalesce to one row a day", () => {
|
|
it("writes one row for many reads, and counts them", async () => {
|
|
const { owner, delegate } = await household();
|
|
|
|
await act();
|
|
await act();
|
|
await act();
|
|
await settle();
|
|
|
|
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].userId).toBe(owner.id);
|
|
expect(rows[0].actorUserId).toBe(delegate.id);
|
|
expect(JSON.parse(rows[0].details!)).toEqual({ accesses: 3 });
|
|
});
|
|
|
|
it("keeps a second delegate's reads on their own row", async () => {
|
|
// Coalescing is per (owner, delegate, day). Two delegates in one record on
|
|
// one day are two rows — merging them would answer "somebody was in your
|
|
// record" when the owner asked "who".
|
|
const { owner, delegate } = await household();
|
|
const second = await makeUser("second");
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: second.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
await acceptGrant({ grantId: invited.id, granteeId: second.id });
|
|
|
|
await act();
|
|
const secondSession = await signIn(second.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: secondSession.id },
|
|
data: { actingAsUserId: owner.id },
|
|
});
|
|
await act();
|
|
await settle();
|
|
|
|
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.map((r) => r.actorUserId).sort()).toEqual(
|
|
[delegate.id, second.id].sort(),
|
|
);
|
|
expect(rows.every((r) => r.userId === owner.id)).toBe(true);
|
|
});
|
|
|
|
it("starts a new row on the next day", async () => {
|
|
const { owner, delegate } = await household();
|
|
|
|
await act();
|
|
await settle();
|
|
|
|
// Age today's row by a day. The coalescing key is the row's own date, so
|
|
// this is exactly what tomorrow looks like to the next read — and if the
|
|
// key were "the latest row" rather than the day, the next read would still
|
|
// find this one and the trail would grow one row per delegate forever.
|
|
await getPrismaClient().$executeRaw`
|
|
UPDATE audit_logs
|
|
SET created_at = created_at - INTERVAL '1 day'
|
|
WHERE action = ${DELEGATED_ACCESS_ACTION}
|
|
`;
|
|
|
|
await act();
|
|
await settle();
|
|
|
|
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.every((r) => r.userId === owner.id)).toBe(true);
|
|
expect(rows.every((r) => r.actorUserId === delegate.id)).toBe(true);
|
|
});
|
|
|
|
it("writes nothing at all when nobody is delegating", async () => {
|
|
const solo = await makeUser("solo");
|
|
await signIn(solo.id);
|
|
|
|
await act();
|
|
await settle();
|
|
|
|
expect(await rowsFor(DELEGATED_ACCESS_ACTION)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("the lifecycle verbs leave their own trail", () => {
|
|
it("records the invitation, the acceptance and the revocation", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
await signIn(owner.id);
|
|
const { POST } = await import("@/app/api/account/grants/route");
|
|
const invited = await POST(
|
|
new NextRequest("http://localhost/api/account/grants", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ identifier: delegate.username }),
|
|
}),
|
|
);
|
|
const grantId = (await invited.json()).data.id;
|
|
|
|
await signIn(delegate.id);
|
|
const accept = await import("@/app/api/account/grants/[id]/accept/route");
|
|
await accept.POST(
|
|
new NextRequest(`http://localhost/api/account/grants/${grantId}/accept`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({ id: grantId }) },
|
|
);
|
|
|
|
await signIn(owner.id);
|
|
const revokeRoute = await import("@/app/api/account/grants/[id]/route");
|
|
await revokeRoute.DELETE(
|
|
new NextRequest(`http://localhost/api/account/grants/${grantId}`, {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({ id: grantId }) },
|
|
);
|
|
|
|
const trail = await getPrismaClient().auditLog.findMany({
|
|
where: { action: { startsWith: "sharing.grant." } },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(trail.map((r) => r.action)).toEqual([
|
|
"sharing.grant.invited",
|
|
"sharing.grant.accepted",
|
|
"sharing.grant.revoked",
|
|
]);
|
|
// Each row files under whoever performed it, acting as themselves. The
|
|
// owner's side of the acceptance is not an audit row at all — it is
|
|
// `acceptedAt` on the grant, which is the durable consent record.
|
|
expect(trail.map((r) => r.userId)).toEqual([
|
|
owner.id,
|
|
delegate.id,
|
|
owner.id,
|
|
]);
|
|
expect(trail.every((r) => r.actorUserId === null)).toBe(true);
|
|
});
|
|
});
|