Files
HealthLog/tests/integration/managed-profile-handover.test.ts
T

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();
});
});