Files
HealthLog/tests/integration/managed-profile-edit.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

350 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);
},
};
}),
};
});
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);
// `auditLog` stores `details` as a JSON STRING, not as a Json column.
const details = JSON.parse(audit.details ?? "{}") as { changed?: string[] };
expect((details.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);
});
});