mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(sharing): keep life events to their owner
Life events rode the profile sharing domain, so a profile share, a legacy whole-record share and a MANAGE grant all read them, and MANAGE could write them. No share level was ever worded for them, so in this release they are the owner's only. The life-event routes take a bare requireAuth, which refuses under an acting-account switch. The day section and the timeline's life lane map to no domain, so a delegate sees the section named not_shared and never gets the lane, its readiness row or the day index marks. The profile consent copy no longer names life events, the sharing guard and the mutation inventory drop the four entries, and the OpenAPI descriptions say owner-only.
This commit is contained in:
1 parent
e2ed62c74f
commit
f950dffad4
21 files changed
+323
-196
No files matched your search
-25
@@ -482,31 +482,6 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
// v1.42 (#613) — life events, MANAGE on all three verbs.
|
||||
handler(
|
||||
"app/api/life-events/route.ts",
|
||||
"/api/life-events",
|
||||
"POST",
|
||||
"profile",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/life-events/[id]/route.ts",
|
||||
"/api/life-events/[id]",
|
||||
"PATCH",
|
||||
"profile",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/life-events/[id]/route.ts",
|
||||
"/api/life-events/[id]",
|
||||
"DELETE",
|
||||
"profile",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/illness/episodes/[id]/day-logs/route.ts",
|
||||
"/api/illness/episodes/[id]/day-logs",
|
||||
|
||||
@@ -520,6 +520,170 @@ describe("life events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("life events are the owner's only", () => {
|
||||
async function seedOwnerWithEvent() {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("le-owner", { timeline: true });
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-03-10T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
||||
const event = await db.lifeEvent.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
category: "LOSS",
|
||||
startDate: "2026-03-10",
|
||||
precision: "DAY",
|
||||
titleEncrypted: encryptToBytes("Zqx private"),
|
||||
},
|
||||
});
|
||||
return { owner, event };
|
||||
}
|
||||
|
||||
async function manageWith(ownerId: string, delegateId: string) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
|
||||
const session = await signIn(delegateId);
|
||||
await switchSessionTo(session.id, ownerId);
|
||||
}
|
||||
|
||||
const grants: Array<
|
||||
[string, (ownerId: string, delegateId: string) => Promise<void>]
|
||||
> = [
|
||||
["a profile share", (o, d) => shareWith(o, d, ["profile", "measurements"])],
|
||||
["a whole-record share", (o, d) => shareWith(o, d, null)],
|
||||
["a MANAGE share", manageWith],
|
||||
];
|
||||
|
||||
for (const [label, grant] of grants) {
|
||||
it(`shows none to ${label}`, async () => {
|
||||
const { owner, event } = await seedOwnerWithEvent();
|
||||
const delegate = await makeUser("le-delegate");
|
||||
await grant(owner.id, delegate.id);
|
||||
|
||||
const routes = await import("@/app/api/life-events/route");
|
||||
const byId = await import("@/app/api/life-events/[id]/route");
|
||||
expect(
|
||||
(await call(routes.GET as Handler, "GET", "/api/life-events")).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await call(routes.POST as Handler, "POST", "/api/life-events", {
|
||||
category: "WORK",
|
||||
startDate: "2024-01-01",
|
||||
precision: "YEAR",
|
||||
title: "New job",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
byId.PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${event.id}`,
|
||||
{ category: "FAMILY" },
|
||||
{ id: event.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
byId.DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/life-events/${event.id}`,
|
||||
undefined,
|
||||
{ id: event.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
const day = await json<Day>(await getDay("2026-03-10"));
|
||||
expect(day.values.map((v) => v.value)).toEqual([80]);
|
||||
expect(JSON.stringify(day)).not.toMatch(/Zqx|"lifeEvent"/);
|
||||
expect(day.sections.lifeEvents).toEqual({
|
||||
available: false,
|
||||
reason: "not_shared",
|
||||
});
|
||||
|
||||
const { GET: index } = await import("@/app/api/day/index/route");
|
||||
const days = await json<{ days: Record<string, string[]> }>(
|
||||
await call(
|
||||
index as Handler,
|
||||
"GET",
|
||||
"/api/day/index?from=2026-03-01&to=2026-03-31",
|
||||
),
|
||||
);
|
||||
expect(days.days["2026-03-10"]).not.toContain("lifeEvents");
|
||||
|
||||
const { GET: timeline } = await import("@/app/api/timeline/route");
|
||||
const lanes = await call(
|
||||
timeline as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=year&to=2026-03-31",
|
||||
);
|
||||
if (lanes.status === 200) {
|
||||
const body =
|
||||
await json<import("@/lib/day/contract").TimelineResponse>(lanes);
|
||||
expect(body.lanes.map((l) => l.key)).not.toContain("life");
|
||||
expect(JSON.stringify(body)).not.toContain("Zqx");
|
||||
} else {
|
||||
expect(lanes.status).toBe(403);
|
||||
}
|
||||
|
||||
const { GET: readiness } =
|
||||
await import("@/app/api/timeline/readiness/route");
|
||||
const ready = await call(
|
||||
readiness as Handler,
|
||||
"GET",
|
||||
"/api/timeline/readiness",
|
||||
);
|
||||
if (ready.status === 200) {
|
||||
const body =
|
||||
await json<import("@/lib/day/contract").TimelineReadinessResponse>(
|
||||
ready,
|
||||
);
|
||||
expect(body.lanes.map((l) => l.key)).not.toContain("life");
|
||||
} else {
|
||||
expect(ready.status).toBe(403);
|
||||
}
|
||||
|
||||
// The event is untouched.
|
||||
const row = await getPrismaClient().lifeEvent.findUniqueOrThrow({
|
||||
where: { id: event.id },
|
||||
});
|
||||
expect([row.category, row.deletedAt]).toEqual(["LOSS", null]);
|
||||
});
|
||||
}
|
||||
|
||||
it("shows the owner their own events in the timeline", async () => {
|
||||
const { owner } = await seedOwnerWithEvent();
|
||||
await signIn(owner.id);
|
||||
const { GET: timeline } = await import("@/app/api/timeline/route");
|
||||
const body = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
timeline as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=year&to=2026-03-31",
|
||||
),
|
||||
);
|
||||
expect(body.lanes.map((l) => l.key)).toContain("life");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notable days", () => {
|
||||
it("marks a three-month high in the day, the index and the visit window", async () => {
|
||||
const db = getPrismaClient();
|
||||
|
||||
@@ -1875,89 +1875,6 @@ manageContract("DELETE /api/vaccinations/custom/[id]", {
|
||||
)?.deletedAt !== null,
|
||||
});
|
||||
|
||||
/* v1.42 (#613) — life events: MANAGE on all three verbs. */
|
||||
|
||||
async function makeLifeEvent(userId: string) {
|
||||
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
||||
return getPrismaClient().lifeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
category: "HOME",
|
||||
startDate: "2023-09-01",
|
||||
precision: "MONTH",
|
||||
titleEncrypted: encryptToBytes("Moved"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
manageContract("POST /api/life-events", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/life-events" && entry.action === "POST",
|
||||
)!,
|
||||
prepare: async (ownerId) => ({ ownerId }),
|
||||
act: async () => {
|
||||
const { POST } = await import("@/app/api/life-events/route");
|
||||
return call(POST as Handler, "POST", "/api/life-events", {
|
||||
category: "WORK",
|
||||
startDate: "2024-01-01",
|
||||
precision: "YEAR",
|
||||
title: "New job",
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "life_event.create",
|
||||
applied: async ({ ownerId }) =>
|
||||
(await getPrismaClient().lifeEvent.count({
|
||||
where: { userId: ownerId, category: "WORK" },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("PATCH /api/life-events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/life-events/[id]" && entry.action === "PATCH",
|
||||
)!,
|
||||
prepare: (ownerId) => makeLifeEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { PATCH } = await import("@/app/api/life-events/[id]/route");
|
||||
return call(
|
||||
PATCH as Handler,
|
||||
"PATCH",
|
||||
`/api/life-events/${row.id}`,
|
||||
{ category: "FAMILY" },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "life_event.update",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } }))
|
||||
?.category === "FAMILY",
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/life-events/[id]", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) =>
|
||||
entry.route === "/api/life-events/[id]" && entry.action === "DELETE",
|
||||
)!,
|
||||
prepare: (ownerId) => makeLifeEvent(ownerId),
|
||||
act: async (row) => {
|
||||
const { DELETE } = await import("@/app/api/life-events/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/life-events/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "life_event.delete",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().lifeEvent.findUnique({ where: { id: row.id } }))
|
||||
?.deletedAt !== null,
|
||||
});
|
||||
|
||||
proveWriteRoute("POST /api/vaccinations/[id]/booster", {
|
||||
route: "/api/vaccinations/[id]/booster",
|
||||
// The dose the booster is planned against. `makeVaccination` records a
|
||||
|
||||
Reference in new issue
Block a user