mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
1113 lines
36 KiB
TypeScript
1113 lines
36 KiB
TypeScript
/**
|
|
* v1.42 (#959) — handing a managed profile over to the person it describes,
|
|
* through the real routes and a real Postgres.
|
|
*
|
|
* Every request body is composed by the CLIENT's own builder
|
|
* (`handoverCreateBody`, `claimBody`, `handoverDecisionBody`), so a renamed
|
|
* field on either end fails here rather than in production.
|
|
*
|
|
* What is proved, in the order a handover happens:
|
|
*
|
|
* - the link: shown once, stored only as a hash, at most one open per
|
|
* profile, withdrawable, expiring, single-use, dead with its creator;
|
|
* - the gate on minting it: cookie and a fresh second factor, never Bearer;
|
|
* - the claim: one transaction that sets credentials, clears the marker,
|
|
* withdraws pending invitations, applies each proposal, resets disclaimer,
|
|
* onboarding and AI consent, audits every step and consumes the token —
|
|
* and a failure anywhere in it leaves the profile exactly as it was;
|
|
* - the races that matter: two claims, a claim against a new link, a claim
|
|
* against the profile's deletion;
|
|
* - the anonymous surface: uniform 404, live-session 409, rate limit, and
|
|
* the password claim's refusal on a single-sign-on-only instance (the
|
|
* single-sign-on claim has its own file);
|
|
* - the new owner's decision afterwards, including a restored MANAGE and a
|
|
* row that moved since the claim and is left alone.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { verifyPassword } from "@/lib/auth/password";
|
|
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
|
import {
|
|
acceptGrant,
|
|
GrantError,
|
|
inviteGrant,
|
|
renounceGrantAndClearSwitch,
|
|
revokeManagedProfileGuardian,
|
|
} from "@/lib/sharing/grants";
|
|
import { deleteManagedProfile } from "@/lib/managed-profiles/lifecycle";
|
|
import {
|
|
claimManagedProfile,
|
|
createHandover,
|
|
} from "@/lib/managed-profiles/handover";
|
|
import { resolveManagedGuardianRecipientIds } from "@/lib/notifications/delivery-identity";
|
|
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
|
|
import { claimBody } from "@/lib/queries/use-profile-claim";
|
|
import { handoverDecisionBody } from "@/lib/queries/use-handover-decision";
|
|
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar, queuedSessionIds } =
|
|
await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => {
|
|
const snapshot = new Map(cookieJar);
|
|
const queuedSessionId = queuedSessionIds.shift();
|
|
if (queuedSessionId) {
|
|
snapshot.set("healthlog_session", queuedSessionId);
|
|
}
|
|
return {
|
|
get: (name: string) => {
|
|
const value = snapshot.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),
|
|
}));
|
|
|
|
// The breach corpus is a network call; the claim's own logic is the subject.
|
|
vi.mock("@/lib/password-breach-check", () => ({
|
|
checkPasswordBreachIfEnabled: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
// The outbox. Guardians are told on their own channels; what is sent, to
|
|
// whom, is read back from here instead of from a mail server.
|
|
const sent: {
|
|
userId: string;
|
|
titleKey: string;
|
|
messageKey: string;
|
|
params?: Record<string, unknown>;
|
|
}[] = [];
|
|
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
|
dispatchLocalisedNotification: vi.fn(
|
|
async (opts: {
|
|
userId: string;
|
|
titleKey: string;
|
|
messageKey: string;
|
|
params?: Record<string, unknown>;
|
|
}) => {
|
|
sent.push({
|
|
userId: opts.userId,
|
|
titleKey: opts.titleKey,
|
|
messageKey: opts.messageKey,
|
|
...(opts.params ? { params: opts.params } : {}),
|
|
});
|
|
},
|
|
),
|
|
}));
|
|
|
|
const PASSWORD = "correct horse battery staple 1959";
|
|
|
|
let sequence = 0;
|
|
|
|
interface Person {
|
|
id: string;
|
|
username: string;
|
|
sessionId: string;
|
|
}
|
|
|
|
/** An account with a second factor and a session that has just proved it. */
|
|
async function person(label: string, freshMfa = true): Promise<Person> {
|
|
const suffix = sequence++;
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: `${label}-${suffix}`,
|
|
email: `${label}-${suffix}@example.test`,
|
|
totpConfirmedAt: new Date(),
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
mfaVerifiedAt: freshMfa ? new Date() : null,
|
|
},
|
|
});
|
|
return { id: user.id, username: user.username, sessionId: session.id };
|
|
}
|
|
|
|
function signIn(who: Person): void {
|
|
headerJar.delete("authorization");
|
|
cookieJar.set("healthlog_session", who.sessionId);
|
|
}
|
|
|
|
function signOut(): void {
|
|
cookieJar.clear();
|
|
headerJar.delete("authorization");
|
|
}
|
|
|
|
/** A profile with two active Guardians: its creator and one more. */
|
|
async function household() {
|
|
const creator = await person("creator");
|
|
const second = await person("second");
|
|
const { profile, creatorGrant } = await createManagedProfile({
|
|
creatorId: creator.id,
|
|
displayName: "Robin",
|
|
dateOfBirth: null,
|
|
locale: "en",
|
|
timezone: "UTC",
|
|
});
|
|
const invitation = await inviteGrant({
|
|
grantorId: profile.id,
|
|
granteeId: second.id,
|
|
access: "MANAGE",
|
|
scope: null,
|
|
});
|
|
const secondGrant = await acceptGrant({
|
|
grantId: invitation.id,
|
|
granteeId: second.id,
|
|
});
|
|
return { creator, second, profile, creatorGrant, secondGrant };
|
|
}
|
|
|
|
const json = (body: unknown) => ({
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
async function mint(
|
|
profileId: string,
|
|
proposals: { grantId: string; proposal: HandoverAccess }[] = [],
|
|
expiresInDays: 1 | 7 | 14 = 7,
|
|
) {
|
|
const { POST } =
|
|
await import("@/app/api/managed-profiles/[id]/handover/route");
|
|
return POST(
|
|
new NextRequest(
|
|
`http://localhost/api/managed-profiles/${profileId}/handover`,
|
|
json(handoverCreateBody({ profileId, expiresInDays, proposals })),
|
|
),
|
|
{ params: Promise.resolve({ id: profileId }) },
|
|
);
|
|
}
|
|
|
|
async function mintToken(
|
|
profileId: string,
|
|
proposals: { grantId: string; proposal: HandoverAccess }[] = [],
|
|
): Promise<string> {
|
|
const response = await mint(profileId, proposals);
|
|
expect(response.status).toBe(201);
|
|
const { data } = await response.json();
|
|
return data.token as string;
|
|
}
|
|
|
|
async function status(profileId: string) {
|
|
const { GET } =
|
|
await import("@/app/api/managed-profiles/[id]/handover/route");
|
|
return GET(
|
|
new NextRequest(
|
|
`http://localhost/api/managed-profiles/${profileId}/handover`,
|
|
),
|
|
{ params: Promise.resolve({ id: profileId }) },
|
|
);
|
|
}
|
|
|
|
async function withdraw(profileId: string) {
|
|
const { DELETE } =
|
|
await import("@/app/api/managed-profiles/[id]/handover/route");
|
|
return DELETE(
|
|
new NextRequest(
|
|
`http://localhost/api/managed-profiles/${profileId}/handover`,
|
|
{ method: "DELETE" },
|
|
),
|
|
{ params: Promise.resolve({ id: profileId }) },
|
|
);
|
|
}
|
|
|
|
async function preview(token: string) {
|
|
const { POST } = await import("@/app/api/auth/claim/preview/route");
|
|
return POST(
|
|
new NextRequest("http://localhost/api/auth/claim/preview", json({ token })),
|
|
);
|
|
}
|
|
|
|
async function claim(token: string, overrides: { username?: string } = {}) {
|
|
const suffix = sequence++;
|
|
const { POST } = await import("@/app/api/auth/claim/route");
|
|
return POST(
|
|
new NextRequest(
|
|
"http://localhost/api/auth/claim",
|
|
json(
|
|
claimBody({
|
|
token,
|
|
username: overrides.username ?? `robin${suffix}`,
|
|
email: `robin-${suffix}@example.test`,
|
|
password: PASSWORD,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function readDecision() {
|
|
const { GET } = await import("@/app/api/account/handover-decision/route");
|
|
return GET(new NextRequest("http://localhost/api/account/handover-decision"));
|
|
}
|
|
|
|
async function decide(
|
|
decisions: { grantId: string; access: HandoverAccess }[],
|
|
) {
|
|
const { POST } = await import("@/app/api/account/handover-decision/route");
|
|
return POST(
|
|
new NextRequest(
|
|
"http://localhost/api/account/handover-decision",
|
|
json(handoverDecisionBody(decisions)),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function auditActions(userId: string): Promise<string[]> {
|
|
const rows = await getPrismaClient().auditLog.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: "asc" },
|
|
select: { action: true },
|
|
});
|
|
return rows.map((r) => r.action);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
sent.length = 0;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.$executeRawUnsafe(
|
|
'DROP TRIGGER IF EXISTS handover_claim_failure ON "managed_profile_handovers"',
|
|
);
|
|
await prisma.$executeRawUnsafe(
|
|
"DROP FUNCTION IF EXISTS fail_handover_claim()",
|
|
);
|
|
delete process.env.OIDC_ONLY;
|
|
delete process.env.OIDC_ISSUER_URL;
|
|
delete process.env.OIDC_CLIENT_ID;
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
});
|
|
|
|
describe("the handover link", () => {
|
|
it("is shown once and stored only as its hash", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
|
|
const response = await mint(profile.id);
|
|
expect(response.status).toBe(201);
|
|
const { data } = await response.json();
|
|
expect(data.token).toMatch(/^hlp_[0-9a-f]{64}$/);
|
|
expect(data.url).toMatch(new RegExp(`/claim/${data.token}$`));
|
|
|
|
const row = await getPrismaClient().managedProfileHandover.findFirstOrThrow(
|
|
{ where: { profileId: profile.id } },
|
|
);
|
|
expect(row.tokenHash).toBe(hashToken(data.token));
|
|
expect(JSON.stringify(row)).not.toContain(data.token);
|
|
|
|
// The status read never returns the link again.
|
|
const read = await (await status(profile.id)).json();
|
|
expect(read.data.open).toMatchObject({ createdByYou: true });
|
|
expect(JSON.stringify(read)).not.toContain(data.token);
|
|
|
|
// Nothing written to the audit trail carries it either.
|
|
const audit = await getPrismaClient().auditLog.findMany();
|
|
expect(JSON.stringify(audit)).not.toContain(data.token);
|
|
});
|
|
|
|
it("keeps at most one link open per profile", async () => {
|
|
const { creator, second, profile } = await household();
|
|
signIn(creator);
|
|
const first = await mintToken(profile.id);
|
|
signIn(second);
|
|
const replacement = await mintToken(profile.id);
|
|
|
|
const open = await getPrismaClient().managedProfileHandover.count({
|
|
where: { profileId: profile.id, usedAt: null, revokedAt: null },
|
|
});
|
|
expect(open).toBe(1);
|
|
|
|
signOut();
|
|
expect((await preview(first)).status).toBe(404);
|
|
expect((await preview(replacement)).status).toBe(200);
|
|
});
|
|
|
|
it("dies when a Guardian withdraws it", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
expect((await withdraw(profile.id)).status).toBe(200);
|
|
|
|
signOut();
|
|
expect((await preview(token)).status).toBe(404);
|
|
expect((await claim(token)).status).toBe(404);
|
|
expect(await auditActions(profile.id)).toContain(
|
|
"managed_profile.handover.revoked",
|
|
);
|
|
});
|
|
|
|
it("dies when it expires", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
await getPrismaClient().managedProfileHandover.updateMany({
|
|
where: { profileId: profile.id },
|
|
data: { expiresAt: new Date(Date.now() - 1000) },
|
|
});
|
|
|
|
signOut();
|
|
expect((await preview(token)).status).toBe(404);
|
|
expect((await claim(token)).status).toBe(404);
|
|
});
|
|
|
|
it("dies with its creator's access", async () => {
|
|
const { creator, second, profile, creatorGrant } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
|
|
await revokeManagedProfileGuardian({
|
|
profileId: profile.id,
|
|
guardianId: second.id,
|
|
grantId: creatorGrant.id,
|
|
});
|
|
|
|
signIn(second);
|
|
const read = await (await status(profile.id)).json();
|
|
expect(read.data.open).toBeNull();
|
|
signOut();
|
|
expect((await preview(token)).status).toBe(404);
|
|
});
|
|
|
|
it("is single use", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
|
|
signOut();
|
|
expect((await claim(token)).status).toBe(201);
|
|
signOut();
|
|
expect((await claim(token)).status).toBe(404);
|
|
expect((await preview(token)).status).toBe(404);
|
|
});
|
|
|
|
it("answers one 404 for every unusable token", async () => {
|
|
for (const token of [
|
|
"hlp_" + "0".repeat(64),
|
|
"not-a-token",
|
|
"hlv_" + "a".repeat(64),
|
|
]) {
|
|
const response = await preview(token);
|
|
expect(response.status).toBe(404);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"profile_claim.invalid",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("what the link shows before anyone signs in", () => {
|
|
it("names each guardian by display name only, never a login name or a grant id", async () => {
|
|
const { creator, second, profile, creatorGrant, secondGrant } =
|
|
await household();
|
|
await getPrismaClient().user.update({
|
|
where: { id: creator.id },
|
|
data: { displayName: "Alex Example" },
|
|
});
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id, [
|
|
{ grantId: creatorGrant.id, proposal: "manage" },
|
|
{ grantId: secondGrant.id, proposal: "read" },
|
|
]);
|
|
signOut();
|
|
|
|
const response = await preview(token);
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
const text = JSON.stringify(body);
|
|
expect(text).not.toContain(creatorGrant.id);
|
|
expect(text).not.toContain(secondGrant.id);
|
|
expect(text).not.toContain(creator.username);
|
|
expect(text).not.toContain(second.username);
|
|
expect(body.data.guardians).toEqual([
|
|
{ displayName: "Alex Example", proposal: "manage" },
|
|
{ displayName: null, proposal: "read" },
|
|
]);
|
|
});
|
|
|
|
it("tells the guardians without the new login name when the profile has no display name", async () => {
|
|
const { creator, profile, creatorGrant } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id, [
|
|
{ grantId: creatorGrant.id, proposal: "end" },
|
|
]);
|
|
signOut();
|
|
await getPrismaClient().user.update({
|
|
where: { id: profile.id },
|
|
data: { displayName: null },
|
|
});
|
|
const response = await claim(token, { username: "robinlogin" });
|
|
expect(response.status).toBe(201);
|
|
await vi.waitFor(() => expect(sent.length).toBeGreaterThan(0));
|
|
for (const message of sent) {
|
|
expect(message.titleKey).toBe(
|
|
"notifications.handover.unnamed.claimedTitle",
|
|
);
|
|
expect(message.params).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("minting the link", () => {
|
|
it("needs a fresh second factor", async () => {
|
|
const creator = await person("stale", false);
|
|
const { profile } = await createManagedProfile({
|
|
creatorId: creator.id,
|
|
displayName: "Robin",
|
|
dateOfBirth: null,
|
|
locale: "en",
|
|
timezone: "UTC",
|
|
});
|
|
signIn(creator);
|
|
const response = await mint(profile.id);
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).meta.errorCode).toBe("auth.stepup.required");
|
|
});
|
|
|
|
it("is unreachable with a Bearer token, even a wildcard one", async () => {
|
|
const { creator, profile } = await household();
|
|
const raw = `hlk_${"c".repeat(64)}`;
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: creator.id,
|
|
name: "handover-test",
|
|
tokenHash: hashToken(raw),
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
signOut();
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
expect((await mint(profile.id)).status).toBe(401);
|
|
expect((await status(profile.id)).status).toBe(401);
|
|
expect((await withdraw(profile.id)).status).toBe(401);
|
|
});
|
|
|
|
it("answers 404 to somebody who is not a Guardian", async () => {
|
|
const { profile } = await household();
|
|
const stranger = await person("stranger");
|
|
signIn(stranger);
|
|
expect((await mint(profile.id)).status).toBe(404);
|
|
expect((await status(profile.id)).status).toBe(404);
|
|
expect((await withdraw(profile.id)).status).toBe(404);
|
|
});
|
|
|
|
it("refuses a proposal for somebody who is not a Guardian", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const response = await mint(profile.id, [
|
|
{ grantId: "not-a-guardian", proposal: "manage" },
|
|
]);
|
|
expect(response.status).toBe(422);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"managed_profile.handover.unknown_guardian",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("claiming the profile", () => {
|
|
it("turns the profile into the person's own account, in one go", async () => {
|
|
const { creator, second, profile, creatorGrant, secondGrant } =
|
|
await household();
|
|
const prisma = getPrismaClient();
|
|
// The record's data, which must not move.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: profile.id,
|
|
type: "WEIGHT",
|
|
value: 41,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
// A disclaimer and an AI consent a Guardian gave on the person's behalf.
|
|
await prisma.user.update({
|
|
where: { id: profile.id },
|
|
data: {
|
|
disclaimerAcknowledgedAt: new Date(),
|
|
disclaimerAcknowledgedVersion: "1",
|
|
onboardingCompletedAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.consentReceipt.create({
|
|
data: {
|
|
userId: profile.id,
|
|
kind: "ai_full",
|
|
artefact: "{}",
|
|
signedAt: new Date(),
|
|
},
|
|
});
|
|
// The second Guardian is inside the record right now.
|
|
await prisma.session.update({
|
|
where: { id: second.sessionId },
|
|
data: { actingAsUserId: profile.id },
|
|
});
|
|
// A Guardian invitation nobody accepted yet.
|
|
const third = await person("third");
|
|
const pending = await inviteGrant({
|
|
grantorId: profile.id,
|
|
granteeId: third.id,
|
|
access: "MANAGE",
|
|
scope: null,
|
|
});
|
|
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id, [
|
|
{ grantId: creatorGrant.id, proposal: "manage" },
|
|
{ grantId: secondGrant.id, proposal: "end" },
|
|
]);
|
|
signOut();
|
|
|
|
const response = await claim(token, { username: "robin" });
|
|
expect(response.status).toBe(201);
|
|
expect((await response.json()).data).toEqual({
|
|
userId: profile.id,
|
|
username: "robin",
|
|
});
|
|
// A session for the new owner, with setup owed.
|
|
expect(cookieJar.get("healthlog_session")).toBeTruthy();
|
|
expect(cookieJar.get("hl_onboarding")).toBe("pending");
|
|
|
|
const account = await prisma.user.findUniqueOrThrow({
|
|
where: { id: profile.id },
|
|
});
|
|
expect(account.managedProfileAt).toBeNull();
|
|
expect(account.username).toBe("robin");
|
|
expect(account.email).toMatch(/@example\.test$/);
|
|
expect(await verifyPassword(account.passwordHash ?? "", PASSWORD)).toBe(
|
|
true,
|
|
);
|
|
expect(account.disclaimerAcknowledgedAt).toBeNull();
|
|
expect(account.disclaimerAcknowledgedVersion).toBeNull();
|
|
expect(account.onboardingCompletedAt).toBeNull();
|
|
|
|
// No data moved.
|
|
expect(
|
|
await prisma.measurement.count({ where: { userId: profile.id } }),
|
|
).toBe(1);
|
|
|
|
// The creator keeps MANAGE on the very same row; the second Guardian's
|
|
// row ended, ended by the record's owner, and their browser left it.
|
|
const kept = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: creatorGrant.id },
|
|
});
|
|
expect(kept.revokedAt).toBeNull();
|
|
const ended = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: secondGrant.id },
|
|
});
|
|
expect(ended.revokedBy).toBe("GRANTOR");
|
|
expect(
|
|
await prisma.accountGrant.count({
|
|
where: { grantorId: profile.id, granteeId: second.id, revokedAt: null },
|
|
}),
|
|
).toBe(0);
|
|
const secondSession = await prisma.session.findUniqueOrThrow({
|
|
where: { id: second.sessionId },
|
|
});
|
|
expect(secondSession.actingAsUserId).toBeNull();
|
|
|
|
// The pending invitation is withdrawn and can no longer be accepted.
|
|
const withdrawn = await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: pending.id },
|
|
});
|
|
expect(withdrawn.revokedAt).not.toBeNull();
|
|
await expect(
|
|
acceptGrant({ grantId: pending.id, granteeId: third.id }),
|
|
).rejects.toBeInstanceOf(GrantError);
|
|
|
|
// The consent a Guardian gave is withdrawn; the person gives their own.
|
|
expect(
|
|
await prisma.consentReceipt.count({
|
|
where: { userId: profile.id, revokedAt: null },
|
|
}),
|
|
).toBe(0);
|
|
|
|
// Reminders no longer fan out to the Guardians.
|
|
expect(
|
|
await resolveManagedGuardianRecipientIds({
|
|
userId: profile.id,
|
|
eventType: "MEDICATION_REMINDER",
|
|
title: "t",
|
|
message: "m",
|
|
} as Parameters<typeof resolveManagedGuardianRecipientIds>[0]),
|
|
).toBeNull();
|
|
|
|
// Every step is on the record's trail and on each Guardian's own.
|
|
const recordTrail = await auditActions(profile.id);
|
|
expect(recordTrail).toEqual(
|
|
expect.arrayContaining([
|
|
"managed_profile.handover.created",
|
|
"managed_profile.claimed",
|
|
"auth.claim",
|
|
]),
|
|
);
|
|
expect(await auditActions(creator.id)).toContain(
|
|
"managed_profile.handed_over",
|
|
);
|
|
expect(await auditActions(second.id)).toContain(
|
|
"managed_profile.handed_over",
|
|
);
|
|
|
|
// Each Guardian is told, on their own channels, what their access became.
|
|
await vi.waitFor(() => expect(sent).toHaveLength(2));
|
|
expect(sent).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
userId: creator.id,
|
|
titleKey: "notifications.handover.claimedTitle",
|
|
messageKey: "notifications.handover.claimedManage",
|
|
// The name the Guardians gave the profile, never the new login.
|
|
params: { name: "Robin" },
|
|
},
|
|
{
|
|
userId: second.id,
|
|
titleKey: "notifications.handover.claimedTitle",
|
|
messageKey: "notifications.handover.claimedEnd",
|
|
params: { name: "Robin" },
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("cuts a Guardian proposed view-only to a new READ row", async () => {
|
|
const { creator, second, profile, secondGrant } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id, [
|
|
{ grantId: secondGrant.id, proposal: "read" },
|
|
]);
|
|
signOut();
|
|
expect((await claim(token)).status).toBe(201);
|
|
|
|
const live = await getPrismaClient().accountGrant.findFirstOrThrow({
|
|
where: { grantorId: profile.id, granteeId: second.id, revokedAt: null },
|
|
});
|
|
expect(live.id).not.toBe(secondGrant.id);
|
|
expect(live.access).toBe("READ");
|
|
expect(live.acceptedAt).not.toBeNull();
|
|
expect(live.scopeJson).toBeNull();
|
|
// The creator, left out of the proposals, is proposed READ too.
|
|
const creatorLive = await getPrismaClient().accountGrant.findFirstOrThrow({
|
|
where: { grantorId: profile.id, granteeId: creator.id, revokedAt: null },
|
|
});
|
|
expect(creatorLive.access).toBe("READ");
|
|
});
|
|
|
|
it("leaves the profile untouched when the transaction fails part-way", async () => {
|
|
const { creator, profile, secondGrant } = await household();
|
|
const prisma = getPrismaClient();
|
|
const third = await person("third");
|
|
const pending = await inviteGrant({
|
|
grantorId: profile.id,
|
|
granteeId: third.id,
|
|
access: "MANAGE",
|
|
scope: null,
|
|
});
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id, [
|
|
{ grantId: secondGrant.id, proposal: "end" },
|
|
]);
|
|
signOut();
|
|
|
|
// Fail the claim's LAST write: everything before it has run by then.
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE FUNCTION fail_handover_claim() RETURNS trigger AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'injected handover failure';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
`);
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE TRIGGER handover_claim_failure
|
|
BEFORE UPDATE ON "managed_profile_handovers"
|
|
FOR EACH ROW
|
|
WHEN (NEW.proposals_json IS DISTINCT FROM OLD.proposals_json)
|
|
EXECUTE FUNCTION fail_handover_claim();
|
|
`);
|
|
|
|
// The route's error boundary turns the thrown transaction into a 500.
|
|
expect((await claim(token)).status).toBe(500);
|
|
|
|
const account = await prisma.user.findUniqueOrThrow({
|
|
where: { id: profile.id },
|
|
});
|
|
expect(account.managedProfileAt).not.toBeNull();
|
|
expect(account.username.startsWith("managed-")).toBe(true);
|
|
expect(account.passwordHash).toBeNull();
|
|
expect(account.email).toBeNull();
|
|
const row = await prisma.managedProfileHandover.findFirstOrThrow({
|
|
where: { profileId: profile.id },
|
|
});
|
|
expect(row.usedAt).toBeNull();
|
|
expect(
|
|
(
|
|
await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: secondGrant.id },
|
|
})
|
|
).revokedAt,
|
|
).toBeNull();
|
|
expect(
|
|
(
|
|
await prisma.accountGrant.findUniqueOrThrow({
|
|
where: { id: pending.id },
|
|
})
|
|
).revokedAt,
|
|
).toBeNull();
|
|
expect(await auditActions(profile.id)).not.toContain(
|
|
"managed_profile.claimed",
|
|
);
|
|
expect(cookieJar.get("healthlog_session")).toBeUndefined();
|
|
|
|
// With the fault gone, the same link still works.
|
|
await prisma.$executeRawUnsafe(
|
|
'DROP TRIGGER handover_claim_failure ON "managed_profile_handovers"',
|
|
);
|
|
expect((await claim(token)).status).toBe(201);
|
|
});
|
|
|
|
it("lets exactly one of two concurrent claims win", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
signOut();
|
|
|
|
const statuses = (await Promise.all([claim(token), claim(token)])).map(
|
|
(r) => r.status,
|
|
);
|
|
expect(statuses.sort()).toEqual([201, 404]);
|
|
});
|
|
|
|
it("orders a claim against a new link: the replaced link is dead", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const first = await mintToken(profile.id);
|
|
signOut();
|
|
|
|
// Through the service, not the routes: the two would otherwise share one
|
|
// cookie jar, and the race is between the two transactions, not cookies.
|
|
const [claimed, minted] = await Promise.allSettled([
|
|
claimManagedProfile({
|
|
link: { rawToken: first },
|
|
username: "racer",
|
|
email: "racer@example.test",
|
|
passwordHash: "hash",
|
|
}),
|
|
createHandover({
|
|
profileId: profile.id,
|
|
guardianId: creator.id,
|
|
expiresInDays: 7,
|
|
proposals: [],
|
|
}),
|
|
]);
|
|
// Either the claim went first (and the mint finds no managed profile), or
|
|
// the mint went first (and the claim finds a withdrawn link).
|
|
expect(
|
|
(claimed.status === "fulfilled" && minted.status === "rejected") ||
|
|
(claimed.status === "rejected" && minted.status === "fulfilled"),
|
|
).toBe(true);
|
|
const account = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: profile.id },
|
|
});
|
|
expect(account.managedProfileAt === null).toBe(
|
|
claimed.status === "fulfilled",
|
|
);
|
|
});
|
|
|
|
it("orders a claim against the profile's deletion", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
signOut();
|
|
|
|
const [claimed, deleted] = await Promise.allSettled([
|
|
claim(token),
|
|
deleteManagedProfile({ profileId: profile.id, guardianId: creator.id }),
|
|
]);
|
|
const account = await getPrismaClient().user.findUnique({
|
|
where: { id: profile.id },
|
|
});
|
|
if (account) {
|
|
// The claim won: the account stands, unmanaged, and the deletion was
|
|
// refused because there was no managed profile left to delete.
|
|
expect(claimed.status === "fulfilled" && claimed.value.status).toBe(201);
|
|
expect(account.managedProfileAt).toBeNull();
|
|
expect(deleted.status).toBe("rejected");
|
|
} else {
|
|
// The deletion won: the link went with the profile.
|
|
expect(claimed.status === "fulfilled" && claimed.value.status).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("refuses over a live session, and the password claim on a single-sign-on-only instance", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
|
|
const overSession = await claim(token);
|
|
expect(overSession.status).toBe(409);
|
|
expect((await overSession.json()).meta.errorCode).toBe(
|
|
"auth.already_authenticated",
|
|
);
|
|
|
|
process.env.OIDC_ISSUER_URL = "https://idp.example.test";
|
|
process.env.OIDC_CLIENT_ID = "client";
|
|
process.env.OIDC_CLIENT_SECRET = "secret";
|
|
process.env.OIDC_ONLY = "true";
|
|
// v1.43 (#959): the handover itself works there, through the identity
|
|
// provider (tests/integration/managed-profile-handover-sso.test.ts); only
|
|
// the password claim stays closed.
|
|
const status = await (
|
|
await (
|
|
await import("@/app/api/managed-profiles/[id]/handover/route")
|
|
).GET(
|
|
new NextRequest(
|
|
`http://localhost/api/managed-profiles/${profile.id}/handover`,
|
|
),
|
|
{ params: Promise.resolve({ id: profile.id }) },
|
|
)
|
|
).json();
|
|
expect(status.data.available).toBe(true);
|
|
expect(status.data.methods).toEqual(["sso"]);
|
|
signOut();
|
|
const shown = await preview(token);
|
|
expect(shown.status).toBe(200);
|
|
expect((await shown.json()).data.methods).toEqual(["sso"]);
|
|
const refused = await claim(token);
|
|
expect(refused.status).toBe(403);
|
|
expect((await refused.json()).meta.errorCode).toBe(
|
|
"profile_claim.oidc_only_unsupported",
|
|
);
|
|
// The profile is still managed: nothing was taken over.
|
|
const account = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: profile.id },
|
|
});
|
|
expect(account.managedProfileAt).not.toBeNull();
|
|
});
|
|
|
|
it("rate-limits claim attempts per address", async () => {
|
|
const codes: number[] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
codes.push((await claim(`hlp_${String(i).repeat(64)}`)).status);
|
|
}
|
|
expect(codes.slice(0, 5)).toEqual([404, 404, 404, 404, 404]);
|
|
expect(codes[5]).toBe(429);
|
|
});
|
|
|
|
it("refuses a username that is already taken, before using the link", async () => {
|
|
const { creator, profile } = await household();
|
|
signIn(creator);
|
|
const token = await mintToken(profile.id);
|
|
signOut();
|
|
|
|
const response = await claim(token, { username: creator.username });
|
|
expect(response.status).toBe(409);
|
|
expect((await response.json()).meta.errorCode).toBe("profile_claim.taken");
|
|
// The link survives a refused attempt.
|
|
expect((await preview(token)).status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("the new owner's decision", () => {
|
|
async function claimedHousehold(
|
|
proposals: (h: Awaited<ReturnType<typeof household>>) => {
|
|
grantId: string;
|
|
proposal: HandoverAccess;
|
|
}[],
|
|
) {
|
|
const h = await household();
|
|
signIn(h.creator);
|
|
const token = await mintToken(h.profile.id, proposals(h));
|
|
signOut();
|
|
const response = await claim(token);
|
|
expect(response.status).toBe(201);
|
|
sent.length = 0;
|
|
return h;
|
|
}
|
|
|
|
it("shows each Guardian's access and applies the owner's final word", async () => {
|
|
const h = await claimedHousehold((x) => [
|
|
{ grantId: x.creatorGrant.id, proposal: "manage" },
|
|
{ grantId: x.secondGrant.id, proposal: "end" },
|
|
]);
|
|
|
|
const before = await (await readDecision()).json();
|
|
expect(before.data.pending.guardians).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
grantId: h.creatorGrant.id,
|
|
proposal: "manage",
|
|
current: "manage",
|
|
decidable: true,
|
|
}),
|
|
expect.objectContaining({
|
|
grantId: h.secondGrant.id,
|
|
proposal: "end",
|
|
current: "end",
|
|
decidable: true,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const response = await decide([
|
|
{ grantId: h.creatorGrant.id, access: "end" },
|
|
{ grantId: h.secondGrant.id, access: "manage" },
|
|
]);
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).data).toEqual({ decided: true, changed: 2 });
|
|
|
|
const prisma = getPrismaClient();
|
|
expect(
|
|
await prisma.accountGrant.count({
|
|
where: {
|
|
grantorId: h.profile.id,
|
|
granteeId: h.creator.id,
|
|
revokedAt: null,
|
|
},
|
|
}),
|
|
).toBe(0);
|
|
const restored = await prisma.accountGrant.findFirstOrThrow({
|
|
where: {
|
|
grantorId: h.profile.id,
|
|
granteeId: h.second.id,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
expect(restored.access).toBe("MANAGE");
|
|
expect(restored.acceptedAt).not.toBeNull();
|
|
|
|
expect((await (await readDecision()).json()).data.pending).toBeNull();
|
|
const again = await decide([]);
|
|
expect(again.status).toBe(409);
|
|
expect((await again.json()).meta.errorCode).toBe(
|
|
"managed_profile.handover.no_pending",
|
|
);
|
|
|
|
expect(await auditActions(h.profile.id)).toContain(
|
|
"managed_profile.handover.decided",
|
|
);
|
|
await vi.waitFor(() => expect(sent).toHaveLength(2));
|
|
expect(sent.map((s) => s.messageKey).sort()).toEqual([
|
|
"notifications.handover.changedEnd",
|
|
"notifications.handover.changedManage",
|
|
]);
|
|
});
|
|
|
|
it("leaves alone a Guardian whose access moved since the claim", async () => {
|
|
const h = await claimedHousehold((x) => [
|
|
{ grantId: x.secondGrant.id, proposal: "read" },
|
|
]);
|
|
const prisma = getPrismaClient();
|
|
const readRow = await prisma.accountGrant.findFirstOrThrow({
|
|
where: {
|
|
grantorId: h.profile.id,
|
|
granteeId: h.second.id,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
// The Guardian steps away on their own.
|
|
await renounceGrantAndClearSwitch({
|
|
grantId: readRow.id,
|
|
granteeId: h.second.id,
|
|
});
|
|
|
|
const before = await (await readDecision()).json();
|
|
const row = before.data.pending.guardians.find(
|
|
(g: { grantId: string }) => g.grantId === h.secondGrant.id,
|
|
);
|
|
expect(row).toMatchObject({ decidable: false, current: "end" });
|
|
|
|
expect(
|
|
(await decide([{ grantId: h.secondGrant.id, access: "manage" }])).status,
|
|
).toBe(200);
|
|
// Their choice to leave stands.
|
|
expect(
|
|
await prisma.accountGrant.count({
|
|
where: {
|
|
grantorId: h.profile.id,
|
|
granteeId: h.second.id,
|
|
revokedAt: null,
|
|
},
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("refuses a decision about somebody who was not a Guardian", async () => {
|
|
await claimedHousehold(() => []);
|
|
const response = await decide([{ grantId: "stranger", access: "manage" }]);
|
|
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);
|
|
expect((await (await readDecision()).json()).data.pending).toBeNull();
|
|
});
|
|
});
|