Files
HealthLog/tests/integration/managed-profile-edit.test.ts
T
Marc-André Bombeck f521b03c35 feat: let a guardian edit a managed record, and record its sex
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).

PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.

The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.

Refs #939
2026-09-10 00:01:50 +02:00

354 lines
12 KiB
TypeScript

/**
* Reading and changing a managed record's identity, through the real routes
* and a real Postgres (#939).
*
* The card's controls are proved from a static render in
* `src/components/settings/access/__tests__/managed-profile-affordances.test.tsx`,
* which can say a control exists and nothing about what pressing it does. This
* file is the other half, and it posts the body the CLIENT composes
* (`managedProfileEditBody`) rather than a hand-written copy of it: a
* hand-written copy stays green while the form renames a field, and the
* assembly between the two ends is the part that drops.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import { managedProfileEditBody } from "@/lib/queries/use-managed-profiles";
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),
}));
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): 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: new Date(),
},
});
return { id: user.id, username: user.username, sessionId: session.id };
}
function signIn(who: Person): void {
headerJar.delete("authorization");
cookieJar.set("healthlog_session", who.sessionId);
}
async function readProfile(profileId: string) {
const { GET } = await import("@/app/api/managed-profiles/[id]/route");
return GET(
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
method: "GET",
}),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function patchProfile(profileId: string, body: unknown) {
const { PATCH } = await import("@/app/api/managed-profiles/[id]/route");
return PATCH(
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function guardianAndProfile() {
const guardian = await person("guardian");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Placeholder",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
return { guardian, profile };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("GET /api/managed-profiles/{id}", () => {
it("answers a Guardian with the record's own identity", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await readProfile(profile.id);
expect(response.status).toBe(200);
const { data } = await response.json();
// Asserted with `Object.keys` rather than by presence: a route that
// over-discloses passes every "contains" assertion ever written about it,
// and this one answers about an account with no self to consent.
expect(Object.keys(data).sort()).toEqual([
"dateOfBirth",
"displayName",
"gender",
"id",
"locale",
"recordKind",
"timezone",
]);
expect(data).toMatchObject({
id: profile.id,
displayName: "Placeholder",
dateOfBirth: null,
gender: null,
locale: "en",
timezone: "UTC",
recordKind: "managed",
});
});
it("answers somebody who is not a Guardian the unknown-record refusal", async () => {
const { profile } = await guardianAndProfile();
const stranger = await person("stranger");
signIn(stranger);
const response = await readProfile(profile.id);
// 404 rather than 403, and deliberately: "no such record" and "not yours"
// are byte-identical here so the route is not an enumeration oracle.
expect(response.status).toBe(404);
expect((await response.json()).meta?.errorCode).toBe(
"managed_profile.not_found",
);
});
});
describe("PATCH /api/managed-profiles/{id}", () => {
it("changes the name and the timezone a Guardian sends, and audits both", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(
profile.id,
managedProfileEditBody({
profileId: profile.id,
displayName: "Robin",
timezone: "Europe/Berlin",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toMatchObject({
displayName: "Robin",
timezone: "Europe/Berlin",
// Untouched by a two-field patch. Absence means "leave it", and a body
// that always sent five fields would overwrite these with whatever the
// form last read.
locale: "en",
dateOfBirth: null,
gender: null,
});
const prisma = getPrismaClient();
const row = await prisma.user.findUniqueOrThrow({
where: { id: profile.id },
select: { displayName: true, timezone: true, locale: true },
});
expect(row).toEqual({
displayName: "Robin",
timezone: "Europe/Berlin",
locale: "en",
});
// Filed under the RECORD with the Guardian named as the actor: the
// record's own trail is what an incoming Guardian reads to learn what
// happened to it, and it names only what actually moved.
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: "managed_profile.updated", userId: profile.id },
});
expect(audit.actorUserId).toBe(guardian.id);
expect(
((audit.details as { changed?: string[] } | null)?.changed ?? []).sort(),
).toEqual(["displayName", "timezone"]);
});
it("records the sex a Guardian sets, which is what the cycle module reads", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(
profile.id,
managedProfileEditBody({ profileId: profile.id, gender: "MALE" }),
);
expect(response.status).toBe(200);
expect((await response.json()).data.gender).toBe("MALE");
// Not a second column and not a mirror: `User.gender` is the one the cycle
// gate derives from, so the record it was recorded on is the record whose
// module map answers.
const { isModuleEnabled } = await import("@/lib/modules/gate");
await expect(isModuleEnabled(profile.id, "cycle")).resolves.toBe(false);
});
it("clears a date of birth on an explicit null and leaves it on an absent key", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
await patchProfile(profile.id, { dateOfBirth: "2015-04-02" });
const kept = await patchProfile(profile.id, { displayName: "Robin" });
expect((await kept.json()).data.dateOfBirth).toBe("2015-04-02");
const cleared = await patchProfile(profile.id, { dateOfBirth: null });
expect((await cleared.json()).data.dateOfBirth).toBeNull();
});
it("refuses a body that names nothing, rather than auditing a change nobody made", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(profile.id, {});
expect(response.status).toBe(422);
const rows = await getPrismaClient().auditLog.count({
where: { action: "managed_profile.updated" },
});
expect(rows).toBe(0);
});
it("refuses a field the schema does not name", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
// `.strict()`, so a helpful extra key is a refusal rather than an ignored
// one. `heightCm` is a real `User` column and the closest thing to a
// plausible mistake.
const response = await patchProfile(profile.id, {
displayName: "Robin",
heightCm: 120,
});
expect(response.status).toBe(422);
});
it("refuses somebody who is not a Guardian, and changes nothing", async () => {
const { profile } = await guardianAndProfile();
const stranger = await person("stranger");
signIn(stranger);
const response = await patchProfile(profile.id, { displayName: "Mine" });
expect(response.status).toBe(404);
expect((await response.json()).meta?.errorCode).toBe(
"managed_profile.not_found",
);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: profile.id },
select: { displayName: true },
});
expect(row.displayName).toBe("Placeholder");
});
it("refuses an adult MANAGE delegate on an ordinary record", async () => {
// A MANAGE grant on an adult's own record is not a route into their
// identity. The same refusal the deletion beside it answers with.
const owner = await person("owner");
const delegate = await person("delegate");
const grant = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
signIn(delegate);
const response = await patchProfile(owner.id, { displayName: "Mine" });
expect(response.status).toBe(404);
});
it("refuses a session that has not proved a second factor", async () => {
const { guardian, profile } = await guardianAndProfile();
const prisma = getPrismaClient();
await prisma.session.update({
where: { id: guardian.sessionId },
data: { mfaVerifiedAt: null },
});
signIn(guardian);
const response = await patchProfile(profile.id, { displayName: "Robin" });
// The gate creation and deletion carry, unconditionally. The card renders
// this as "confirm your second factor", not as a failure.
expect(response.status).toBe(401);
expect((await response.json()).meta?.errorCode).toBe(
"auth.stepup.required",
);
});
it("lets a second Guardian edit the record they were invited to", async () => {
const { guardian, profile } = await guardianAndProfile();
const second = await person("second");
const invitation = await inviteGrant({
grantorId: profile.id,
granteeId: second.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invitation.id, granteeId: second.id });
signIn(second);
const response = await patchProfile(profile.id, { displayName: "Robin" });
expect(response.status).toBe(200);
// The creator is not privileged over anybody else who looks after it.
expect(guardian.id).not.toBe(second.id);
});
});