mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(handover): keep a claimed handover when its Guardian deletes their account
created_by_id cascaded, so a Guardian who deleted their own account took every handover they had issued with them, including a claimed one whose decision the new owner had not made yet. The claim had already applied the proposal, possibly MANAGE for a co-Guardian, and the owner lost the one place that asks them to confirm it. The column is nullable now and cleared on delete (migration 0390). An unclaimed link without a creator is refused like any link whose creator lost access. A decision that names a Guardian whose account is gone is refused with 422 instead of failing on a grant for a missing account.
This commit is contained in:
1 parent
c7cee9c8a7
commit
98183700ce
4 files changed
+94
-6
No files matched your search
@@ -0,0 +1,14 @@
|
||||
-- v1.42 (#959): a handover outlives the Guardian who minted it.
|
||||
--
|
||||
-- `created_by_id` cascaded, so a Guardian deleting their own account deleted
|
||||
-- every handover they had issued, including a claimed one whose decision the
|
||||
-- new owner had not made yet. The claim had already applied the proposal
|
||||
-- (possibly MANAGE for a co-Guardian), and the owner lost the one place that
|
||||
-- asks them to confirm it. The creator is now cleared instead; an unclaimed
|
||||
-- link without a creator is refused by the claim like any link whose creator
|
||||
-- lost access.
|
||||
ALTER TABLE "managed_profile_handovers" ALTER COLUMN "created_by_id" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "managed_profile_handovers" DROP CONSTRAINT "managed_profile_handovers_created_by_id_fkey";
|
||||
|
||||
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -3927,8 +3927,11 @@ model ManagedProfileHandover {
|
||||
id String @id @default(cuid())
|
||||
/// The managed profile (`User` row with `managedProfileAt` set).
|
||||
profileId String @map("profile_id")
|
||||
/// The guardian who minted the link.
|
||||
createdById String @map("created_by_id")
|
||||
/// The guardian who minted the link. Cleared when that account is deleted
|
||||
/// (0390): a claimed handover keeps the new owner's pending decision, and
|
||||
/// an unclaimed link without a creator is refused like any link whose
|
||||
/// creator lost access.
|
||||
createdById String? @map("created_by_id")
|
||||
/// HMAC-SHA256 of the token, unique; the claim and its preview look the
|
||||
/// link up by it (`src/lib/managed-profiles/handover.ts`).
|
||||
tokenHash String @unique @map("token_hash")
|
||||
@@ -3947,8 +3950,8 @@ model ManagedProfileHandover {
|
||||
/// creator lost access.
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([profileId])
|
||||
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
|
||||
|
||||
@@ -199,9 +199,12 @@ function proposalFor(state: HandoverState, grantId: string): HandoverAccess {
|
||||
async function isActiveGuardian(
|
||||
db: Pick<Transaction, "accountGrant">,
|
||||
profileId: string,
|
||||
guardianId: string,
|
||||
guardianId: string | null,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
// A link whose creator's account is gone (`created_by_id` cleared) has no
|
||||
// Guardian behind it.
|
||||
if (guardianId === null) return false;
|
||||
const grant = await db.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profileId,
|
||||
@@ -793,7 +796,23 @@ export async function decideHandover(input: {
|
||||
throw new HandoverError("no_pending");
|
||||
}
|
||||
const claim = state.claim;
|
||||
const known = new Set(claim.guardians.map((e) => e.grantId));
|
||||
// A former Guardian whose account is gone has nothing left to decide:
|
||||
// the pending read leaves them out, and a decision that names them
|
||||
// anyway is refused like one about a stranger, rather than failing on
|
||||
// a grant written for an account that no longer exists.
|
||||
const present = new Set(
|
||||
(
|
||||
await tx.user.findMany({
|
||||
where: { id: { in: claim.guardians.map((e) => e.guardianId) } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((u) => u.id),
|
||||
);
|
||||
const known = new Set(
|
||||
claim.guardians
|
||||
.filter((e) => present.has(e.guardianId))
|
||||
.map((e) => e.grantId),
|
||||
);
|
||||
if (input.decisions.some((d) => !known.has(d.grantId))) {
|
||||
throw new HandoverError("unknown_guardian");
|
||||
}
|
||||
@@ -807,6 +826,16 @@ export async function decideHandover(input: {
|
||||
}[] = [];
|
||||
const guardians: StoredClaimEntry[] = [];
|
||||
for (const entry of claim.guardians) {
|
||||
if (!present.has(entry.guardianId)) {
|
||||
guardians.push(entry);
|
||||
outcome.push({
|
||||
grantId: entry.grantId,
|
||||
access: entry.applied,
|
||||
changed: false,
|
||||
skipped: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const target =
|
||||
input.decisions.find((d) => d.grantId === entry.grantId)?.access ??
|
||||
entry.applied;
|
||||
|
||||
@@ -990,6 +990,48 @@ describe("the new owner's decision", () => {
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
it("keeps the decision waiting when the issuing Guardian deletes their account", async () => {
|
||||
const h = await claimedHousehold((x) => [
|
||||
{ grantId: x.creatorGrant.id, proposal: "end" },
|
||||
{ grantId: x.secondGrant.id, proposal: "manage" },
|
||||
]);
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.delete({ where: { id: h.creator.id } });
|
||||
|
||||
const handover = await prisma.managedProfileHandover.findFirstOrThrow({
|
||||
where: { profileId: h.profile.id },
|
||||
});
|
||||
expect(handover.createdById).toBeNull();
|
||||
|
||||
const pending = (await (await readDecision()).json()).data.pending;
|
||||
expect(pending).not.toBeNull();
|
||||
expect(pending.guardians).toEqual([
|
||||
expect.objectContaining({
|
||||
grantId: h.secondGrant.id,
|
||||
proposal: "manage",
|
||||
current: "manage",
|
||||
decidable: true,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(await decide([{ grantId: h.secondGrant.id, access: "read" }])).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a decision about a Guardian whose account is gone", async () => {
|
||||
const h = await claimedHousehold((x) => [
|
||||
{ grantId: x.secondGrant.id, proposal: "end" },
|
||||
]);
|
||||
await getPrismaClient().user.delete({ where: { id: h.second.id } });
|
||||
const response = await decide([
|
||||
{ grantId: h.secondGrant.id, access: "manage" },
|
||||
]);
|
||||
expect(response.status).toBe(422);
|
||||
expect((await response.json()).meta.errorCode).toBe(
|
||||
"managed_profile.handover.unknown_guardian",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers no pending decision to an account that never was a profile", async () => {
|
||||
const someone = await person("someone");
|
||||
signIn(someone);
|
||||
|
||||
Reference in new issue
Block a user