mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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.
651 lines
21 KiB
TypeScript
651 lines
21 KiB
TypeScript
/**
|
|
* The account grant against real Postgres.
|
|
*
|
|
* Everything asserted here is a property of the DATABASE, which is why none of
|
|
* it is unit-testable: the partial unique index that allows history but not a
|
|
* second live grant, the conditional-update claims that make accept one-shot,
|
|
* and the two cascades. A fake Prisma client that ignored `where` would report
|
|
* every one of them as working.
|
|
*
|
|
* The three lifecycle paths a new persisted table has to join are proven here
|
|
* too, each against the real route rather than against a list that claims the
|
|
* route does it:
|
|
*
|
|
* 1. account deletion — BOTH directions, because a grant naming a user who
|
|
* no longer exists is a dangling authorization;
|
|
* 2. `DELETE /api/settings/data` — for the owner AND for the delegate, since
|
|
* a grant belongs to two accounts;
|
|
* 3. `POST /api/admin/backups/[id]/restore` — which must leave grants
|
|
* exactly as it found them. That third one is an inverse proof and is
|
|
* deliberate: `AccountGrant` is classified NOT_IN_BACKUP (see
|
|
* `src/lib/export/backup-plan.ts` for the reasoning), so the property
|
|
* worth holding is that a restore can neither resurrect a revoked grant
|
|
* nor be talked into creating one.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
import {
|
|
GrantError,
|
|
acceptGrant,
|
|
findActiveGrant,
|
|
grantAllows,
|
|
inviteGrant,
|
|
renounceGrant,
|
|
revokeGrant,
|
|
touchGrantUsage,
|
|
} from "@/lib/sharing/grants";
|
|
|
|
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),
|
|
}));
|
|
|
|
let counter = 0;
|
|
|
|
async function makeUser(label: string, role: "USER" | "ADMIN" = "USER") {
|
|
const suffix = `${label}-${counter++}`;
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username: `grant-${suffix}`,
|
|
email: `grant-${suffix}@example.test`,
|
|
role,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function signIn(userId: string) {
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session;
|
|
}
|
|
|
|
/** Owner and delegate, with the grant already invited and accepted. */
|
|
async function acceptedGrant() {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
const grant = await acceptGrant({
|
|
grantId: invited.id,
|
|
granteeId: delegate.id,
|
|
});
|
|
return { owner, delegate, grant };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
describe("account grant lifecycle", () => {
|
|
it("confers nothing until the delegate accepts", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
expect(invited.acceptedAt).toBeNull();
|
|
expect(invited.access).toBe("READ");
|
|
expect(
|
|
await findActiveGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
}),
|
|
).toBeNull();
|
|
|
|
const accepted = await acceptGrant({
|
|
grantId: invited.id,
|
|
granteeId: delegate.id,
|
|
});
|
|
expect(accepted.acceptedAt).not.toBeNull();
|
|
// The consent record says who and when, and deliberately not from where.
|
|
// A column re-added and written again would fail here rather than pass
|
|
// unnoticed, which is the point of asserting an absence.
|
|
expect(accepted).not.toHaveProperty("acceptedIp");
|
|
|
|
const active = await findActiveGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
});
|
|
expect(active?.id).toBe(invited.id);
|
|
});
|
|
|
|
it("stores the level the invitation offered, and confers it only once accepted", async () => {
|
|
// The column, not the argument: a write grant is the one thing in this
|
|
// feature that lets somebody put data into another person's record, so the
|
|
// proof that WRITE survives the round trip belongs against real Postgres
|
|
// rather than against a client fake.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "WRITE",
|
|
scope: null,
|
|
});
|
|
const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: invited.id },
|
|
});
|
|
expect(stored.access).toBe("WRITE");
|
|
// Offered is not accepted. Both consents or nothing.
|
|
expect(grantAllows(stored, "write")).toBe(false);
|
|
|
|
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
|
const live = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: invited.id },
|
|
});
|
|
expect(grantAllows(live, "write")).toBe(true);
|
|
expect(grantAllows(live, "read")).toBe(true);
|
|
});
|
|
|
|
it("refuses an account sharing its record with itself", async () => {
|
|
const solo = await makeUser("solo");
|
|
await expect(
|
|
inviteGrant({
|
|
grantorId: solo.id,
|
|
granteeId: solo.id,
|
|
access: "READ",
|
|
scope: null,
|
|
}),
|
|
).rejects.toMatchObject({ code: "self_grant" });
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("lets only the named delegate accept, exactly once", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const stranger = await makeUser("stranger");
|
|
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
|
|
await expect(
|
|
acceptGrant({ grantId: invited.id, granteeId: stranger.id }),
|
|
).rejects.toMatchObject({ code: "not_grantee" });
|
|
expect(
|
|
(
|
|
await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: invited.id },
|
|
})
|
|
).acceptedAt,
|
|
).toBeNull();
|
|
|
|
const first = await acceptGrant({
|
|
grantId: invited.id,
|
|
granteeId: delegate.id,
|
|
});
|
|
await expect(
|
|
acceptGrant({
|
|
grantId: invited.id,
|
|
granteeId: delegate.id,
|
|
}),
|
|
).rejects.toMatchObject({ code: "not_pending" });
|
|
|
|
// The second attempt did not re-stamp the acceptance: the consent record
|
|
// still says when the delegate actually agreed.
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: invited.id },
|
|
});
|
|
expect(row.acceptedAt).toEqual(first.acceptedAt);
|
|
});
|
|
|
|
it("cannot accept an invitation that already lapsed", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
});
|
|
|
|
await expect(
|
|
acceptGrant({ grantId: invited.id, granteeId: delegate.id }),
|
|
).rejects.toMatchObject({ code: "expired" });
|
|
});
|
|
|
|
it("stops conferring access the moment the grant expires", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
|
|
|
const pair = { grantorId: owner.id, granteeId: delegate.id };
|
|
expect(await findActiveGrant(pair)).not.toBeNull();
|
|
// Nothing swept, nothing changed the row: only the clock the resolver
|
|
// reads moved past the lapse instant.
|
|
expect(
|
|
await findActiveGrant(
|
|
pair,
|
|
getPrismaClient(),
|
|
new Date(Date.now() + 61_000),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("revokes without deleting, and says who ended it", async () => {
|
|
const { owner, delegate, grant } = await acceptedGrant();
|
|
|
|
const revoked = await revokeGrant({
|
|
grantId: grant.id,
|
|
grantorId: owner.id,
|
|
});
|
|
expect(revoked.revokedAt).not.toBeNull();
|
|
expect(revoked.revokedBy).toBe("GRANTOR");
|
|
|
|
// The row is the consent record. It survives, with the acceptance it
|
|
// carried before the revocation intact.
|
|
const row = await getPrismaClient().accountGrant.findUnique({
|
|
where: { id: grant.id },
|
|
});
|
|
expect(row).not.toBeNull();
|
|
expect(row!.acceptedAt).toEqual(grant.acceptedAt);
|
|
|
|
expect(
|
|
await findActiveGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("records a renouncement as the delegate's act, not the owner's", async () => {
|
|
const { delegate, grant } = await acceptedGrant();
|
|
|
|
const renounced = await renounceGrant({
|
|
grantId: grant.id,
|
|
granteeId: delegate.id,
|
|
});
|
|
expect(renounced.revokedBy).toBe("GRANTEE");
|
|
expect(
|
|
await getPrismaClient().accountGrant.count({ where: { id: grant.id } }),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("refuses a revocation from anyone but the parties named", async () => {
|
|
const { owner, delegate, grant } = await acceptedGrant();
|
|
const stranger = await makeUser("stranger");
|
|
|
|
await expect(
|
|
revokeGrant({ grantId: grant.id, grantorId: stranger.id }),
|
|
).rejects.toMatchObject({ code: "not_grantor" });
|
|
await expect(
|
|
renounceGrant({ grantId: grant.id, granteeId: stranger.id }),
|
|
).rejects.toMatchObject({ code: "not_grantee" });
|
|
|
|
// The delegate cannot revoke as the owner, nor the owner renounce as the
|
|
// delegate — the attribution is not a label the caller picks.
|
|
await expect(
|
|
revokeGrant({ grantId: grant.id, grantorId: delegate.id }),
|
|
).rejects.toMatchObject({ code: "not_grantor" });
|
|
await expect(
|
|
renounceGrant({ grantId: grant.id, granteeId: owner.id }),
|
|
).rejects.toMatchObject({ code: "not_grantee" });
|
|
|
|
expect(
|
|
(
|
|
await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grant.id },
|
|
})
|
|
).revokedAt,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("refuses a revocation the record cannot attribute", async () => {
|
|
// The database holds the halves together, because a revoked row with no
|
|
// revoker and a revoker with no revocation are both records that cannot
|
|
// answer the question the table exists to answer. The domain module always
|
|
// writes the pair; this is the floor under it.
|
|
const { grant } = await acceptedGrant();
|
|
const prisma = getPrismaClient();
|
|
|
|
await expect(
|
|
prisma.accountGrant.update({
|
|
where: { id: grant.id },
|
|
data: { revokedAt: new Date() },
|
|
}),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
prisma.accountGrant.update({
|
|
where: { id: grant.id },
|
|
data: { revokedBy: "GRANTOR" },
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
const row = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: grant.id },
|
|
});
|
|
expect(row.revokedAt).toBeNull();
|
|
expect(row.revokedBy).toBeNull();
|
|
});
|
|
|
|
it("refuses a second live grant for the same pair", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
|
|
await expect(
|
|
inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
}),
|
|
).rejects.toBeInstanceOf(GrantError);
|
|
await expect(
|
|
inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
}),
|
|
).rejects.toMatchObject({ code: "duplicate_live_grant" });
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(1);
|
|
});
|
|
|
|
it("re-invites after a revocation as a NEW row, leaving the old one intact", async () => {
|
|
const { owner, delegate, grant } = await acceptedGrant();
|
|
const first = await revokeGrant({
|
|
grantId: grant.id,
|
|
grantorId: owner.id,
|
|
});
|
|
|
|
const second = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(second.acceptedAt).toBeNull();
|
|
expect(second.revokedAt).toBeNull();
|
|
|
|
// Two rows for one pair: the ended one and the pending one. This is the
|
|
// whole reason the unique index is partial.
|
|
const all = await getPrismaClient().accountGrant.findMany({
|
|
where: { grantorId: owner.id, granteeId: delegate.id },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(all).toHaveLength(2);
|
|
expect(all[0].id).toBe(first.id);
|
|
expect(all[0].revokedAt).toEqual(first.revokedAt);
|
|
expect(all[0].revokedBy).toBe("GRANTOR");
|
|
|
|
// And the resolver still answers with the live row only.
|
|
expect(
|
|
await findActiveGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps history without limit — a third grant does not collide with two ended ones", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
for (let i = 0; i < 2; i++) {
|
|
const invited = await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
await revokeGrant({ grantId: invited.id, grantorId: owner.id });
|
|
}
|
|
await inviteGrant({
|
|
grantorId: owner.id,
|
|
granteeId: delegate.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
expect(
|
|
await getPrismaClient().accountGrant.count({
|
|
where: { grantorId: owner.id, granteeId: delegate.id },
|
|
}),
|
|
).toBe(3);
|
|
});
|
|
|
|
it("refuses to end a grant twice", async () => {
|
|
const { owner, grant } = await acceptedGrant();
|
|
await revokeGrant({ grantId: grant.id, grantorId: owner.id });
|
|
await expect(
|
|
revokeGrant({ grantId: grant.id, grantorId: owner.id }),
|
|
).rejects.toMatchObject({ code: "already_revoked" });
|
|
});
|
|
|
|
it("stamps lastUsedAt without failing the caller when the grant is gone", async () => {
|
|
const { grant } = await acceptedGrant();
|
|
await touchGrantUsage(grant.id);
|
|
expect(
|
|
(
|
|
await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grant.id },
|
|
})
|
|
).lastUsedAt,
|
|
).not.toBeNull();
|
|
|
|
// Fire-and-forget: a grant deleted between the read and the stamp must not
|
|
// turn a successful request into a failure.
|
|
await expect(touchGrantUsage("no-such-grant")).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("account grant lifecycle paths", () => {
|
|
it("is removed when EITHER account is deleted", async () => {
|
|
const prisma = getPrismaClient();
|
|
|
|
const byOwner = await acceptedGrant();
|
|
await prisma.user.delete({ where: { id: byOwner.owner.id } });
|
|
expect(
|
|
await prisma.accountGrant.count({ where: { id: byOwner.grant.id } }),
|
|
"deleting the owner left an authorization naming a user who no longer exists",
|
|
).toBe(0);
|
|
|
|
const byDelegate = await acceptedGrant();
|
|
await prisma.user.delete({ where: { id: byDelegate.delegate.id } });
|
|
expect(
|
|
await prisma.accountGrant.count({ where: { id: byDelegate.grant.id } }),
|
|
"deleting the delegate left an authorization naming a user who no longer exists",
|
|
).toBe(0);
|
|
});
|
|
|
|
it("is removed by Delete All Data, from either side of the grant", async () => {
|
|
const prisma = getPrismaClient();
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
|
|
const wipe = async (userId: string) => {
|
|
await signIn(userId);
|
|
const response = await DELETE(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}) as never,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
};
|
|
|
|
// The owner erases their record: the access they handed out goes with it.
|
|
const asOwner = await acceptedGrant();
|
|
await wipe(asOwner.owner.id);
|
|
expect(
|
|
await prisma.accountGrant.count({ where: { id: asOwner.grant.id } }),
|
|
"a delegate still holds read access to an account that erased everything",
|
|
).toBe(0);
|
|
|
|
// The delegate erases their record: the access they hold goes too. Wiping
|
|
// only the grantor side would leave this row behind.
|
|
const asDelegate = await acceptedGrant();
|
|
await wipe(asDelegate.delegate.id);
|
|
expect(
|
|
await prisma.accountGrant.count({ where: { id: asDelegate.grant.id } }),
|
|
"the wipe took only the grants this account made, not the ones it held",
|
|
).toBe(0);
|
|
});
|
|
|
|
it("survives an admin restore untouched, and cannot be created by one", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await makeUser("restore-admin", "ADMIN");
|
|
|
|
// The admin is also an owner: one live grant over their record, and one
|
|
// they revoked earlier. A restore must move neither.
|
|
const live = await makeUser("live-delegate");
|
|
const dropped = await makeUser("dropped-delegate");
|
|
|
|
const liveInvite = await inviteGrant({
|
|
grantorId: admin.id,
|
|
granteeId: live.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
const liveGrant = await acceptGrant({
|
|
grantId: liveInvite.id,
|
|
granteeId: live.id,
|
|
});
|
|
const droppedInvite = await inviteGrant({
|
|
grantorId: admin.id,
|
|
granteeId: dropped.id,
|
|
access: "READ",
|
|
scope: null,
|
|
});
|
|
await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id });
|
|
const revoked = await revokeGrant({
|
|
grantId: droppedInvite.id,
|
|
grantorId: admin.id,
|
|
});
|
|
|
|
// A payload that carries grants — including one for an account whose
|
|
// access the owner ended. `parseBackupPayload` passes unknown keys
|
|
// through, so this is what a hand-edited or future-shaped file looks like
|
|
// arriving at today's restore.
|
|
const stranger = await makeUser("stranger");
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "ACCOUNT_GRANT_RESTORE_TEST",
|
|
data: encrypt(
|
|
JSON.stringify({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-08-01T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [],
|
|
accountGrants: [
|
|
{
|
|
id: revoked.id,
|
|
grantorId: admin.id,
|
|
granteeId: dropped.id,
|
|
access: "READ",
|
|
acceptedAt: "2026-07-01T00:00:00.000Z",
|
|
revokedAt: null,
|
|
revokedBy: null,
|
|
},
|
|
{
|
|
grantorId: admin.id,
|
|
granteeId: stranger.id,
|
|
access: "WRITE",
|
|
acceptedAt: "2026-07-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
});
|
|
|
|
await signIn(admin.id);
|
|
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
|
|
const response = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
// The revoked grant stayed revoked. This is the failure the exclusion
|
|
// exists to prevent: access the owner ended coming back alive out of a
|
|
// file, with nobody deciding it and nobody told.
|
|
const after = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: revoked.id },
|
|
});
|
|
expect(after.revokedAt).toEqual(revoked.revokedAt);
|
|
expect(after.revokedBy).toBe("GRANTOR");
|
|
|
|
// The live grant is neither dropped nor re-stamped.
|
|
const stillLive = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: liveGrant.id },
|
|
});
|
|
expect(stillLive.revokedAt).toBeNull();
|
|
expect(stillLive.acceptedAt).toEqual(liveGrant.acceptedAt);
|
|
|
|
// And the payload could not mint one.
|
|
expect(
|
|
await prisma.accountGrant.count({ where: { granteeId: stranger.id } }),
|
|
"a backup file created an authorization over a health record",
|
|
).toBe(0);
|
|
expect(await prisma.accountGrant.count()).toBe(2);
|
|
});
|
|
});
|