mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
350 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|