fix(insights): scope generation guard to prompt fields, clear cache on restore

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:11:43 +02:00
1 parent 666a85d311
commit 3aff2c16db
104 files changed
+8082 -651

No files matched your search

@@ -33,6 +33,7 @@ vi.mock("@/lib/db", () => ({
dataBackup: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
toJson: <T>(value: T) => value,
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
@@ -20,6 +20,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { Buffer } from "node:buffer";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -48,6 +49,7 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
$transaction: mocks.transaction,
},
toJson: <T>(value: T) => value,
}));
// Only `decrypt` is stubbed — it stands in for reading the stored backup blob.
@@ -167,7 +169,34 @@ function sourceClient() {
illnessEpisode: empty,
allergy: empty,
familyHistoryEntry: empty,
workout: empty,
workout: {
findMany: vi.fn().mockResolvedValue([
{
id: "workout-fitbit-1",
userId: OWNER,
sportType: "other",
startedAt: new Date("2026-07-18T08:00:00.000Z"),
endedAt: new Date("2026-07-18T08:30:00.000Z"),
durationSec: 1800,
totalEnergyKcal: 180,
totalDistanceM: null,
avgHeartRate: 125,
maxHeartRate: null,
minHeartRate: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
source: "FITBIT",
externalId: "91002",
metadata: {
fitbitActivityName: "Activity Zeta",
fitbitActivityTypeId: 9002,
},
createdAt: new Date("2026-07-18T08:30:00.000Z"),
updatedAt: new Date("2026-07-18T08:30:00.000Z"),
},
]),
},
inboundDocument: empty,
// The two domains this stream adds.
userHealthProfile: {
@@ -179,10 +208,37 @@ function sourceClient() {
allergiesEncrypted: encryptToBytes("penicillin"),
coachFocusEncrypted: null,
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS"],
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
}),
},
healthProfileFactRevision: {
findMany: vi.fn().mockResolvedValue([
{
id: "fact-old",
userId: OWNER,
kind: "SMOKING_STATUS",
valueEncrypted: encryptToBytes("CURRENT"),
validFrom: new Date("2026-07-01T00:00:00.000Z"),
validUntil: new Date("2026-07-10T00:00:00.000Z"),
provenance: "USER_REPORTED",
supersededByRevisionId: "fact-current",
createdAt: new Date("2026-07-01T00:00:00.000Z"),
},
{
id: "fact-current",
userId: OWNER,
kind: "SMOKING_STATUS",
valueEncrypted: encryptToBytes("FORMER"),
validFrom: new Date("2026-07-10T00:00:00.000Z"),
validUntil: null,
provenance: "USER_CORRECTION",
supersededByRevisionId: null,
createdAt: new Date("2026-07-10T00:00:00.000Z"),
},
]),
},
// The hourly shape of a drained day. The per-sample rows it was folded
// from are already gone, so nothing but this row can rebuild it.
intradayCumulativeProfile: {
@@ -266,7 +322,17 @@ interface Written {
data: Record<string, unknown>;
}
function recordingTx(written: Written[]) {
/**
* `seed` lets a test answer `findUnique`/`findMany` reads with the live
* pre-restore state (the row set the restore's cache-invalidation check
* reads before it deletes and rebuilds), keyed by model name. Every other
* test leaves it empty, which reproduces the old "nothing lives here yet"
* behaviour exactly.
*/
function recordingTx(
written: Written[],
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
) {
// Symptom definitions the restore has written back, so link resolution can
// only succeed if the definitions actually rode the file.
const restoredSymptoms = new Map<string, string>();
@@ -282,14 +348,21 @@ function recordingTx(written: Written[]) {
if (model === "cycleSymptom") {
return [...restoredSymptoms].map(([key, id]) => ({ id, key }));
}
return [];
return seed[model]?.findMany ?? [];
}
if (op === "findFirst" || op === "findUnique") {
return seed[model]?.findUnique ?? null;
}
if (op === "findFirst" || op === "findUnique") return null;
const data = (args.data ?? args.create ?? {}) as Record<
string,
unknown
>;
if (op === "create" || op === "upsert" || op === "createMany") {
if (
op === "create" ||
op === "upsert" ||
op === "createMany" ||
op === "update"
) {
if (model === "cycleSymptom" && typeof data.key === "string") {
restoredSymptoms.set(data.key, (data.id as string) ?? "new-id");
}
@@ -314,27 +387,45 @@ function request(): NextRequest {
});
}
/** Build a real backup for the account above, then restore it. */
async function roundTrip(): Promise<{ res: Response; written: Written[] }> {
const { payload } = await buildFullBackupPayload(
sourceClient() as never,
OWNER,
{ purpose: "disaster-recovery" },
);
/**
* Build a real backup for the account above, then restore it.
*
* `payloadOverride` lets a test build the payload once, derive a "live"
* seed from its exact bytes, and restore that SAME payload — rebuilding it
* would re-encrypt every ciphertext field with a fresh random IV, making an
* unchanged-scope restore look changed for reasons that have nothing to do
* with scope.
*/
async function roundTrip(
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
payloadOverride?: Record<string, unknown>,
): Promise<{
res: Response;
written: Written[];
payload: Record<string, unknown>;
}> {
const payload =
payloadOverride ??
(
await buildFullBackupPayload(sourceClient() as never, OWNER, {
purpose: "disaster-recovery",
})
).payload;
// The file as it lands on disk: JSON, nothing else.
mocks.decrypt.mockReturnValue(JSON.stringify(payload));
const written: Written[] = [];
mocks.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>) => fn(recordingTx(written)),
async (fn: (tx: unknown) => Promise<unknown>) =>
fn(recordingTx(written, seed)),
);
const res = await (
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
)(request(), { params: Promise.resolve({ id: "b-1" }) });
return { res, written };
return { res, written, payload };
}
beforeEach(() => {
@@ -372,6 +463,29 @@ describe("backup round trip — export, wire schema, restore", () => {
expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array);
expect(profile!.data.conditionsEncrypted).toBeNull();
expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array);
expect(profile!.data.aiIncludedSections).toEqual([
"CONDITIONS",
"SMOKING_STATUS",
]);
});
it("writes every encrypted fact revision back", async () => {
const { written } = await roundTrip();
const facts = written.filter(
(write) =>
write.model === "healthProfileFactRevision" && write.op === "create",
);
expect(facts).toHaveLength(2);
expect(facts.map((write) => write.data.id)).toEqual([
"fact-old",
"fact-current",
]);
expect(facts[0].data.valueEncrypted).toBeInstanceOf(Uint8Array);
expect(facts[1].data.valueEncrypted).toBeInstanceOf(Uint8Array);
expect(facts[0].data.validUntil).toEqual(
new Date("2026-07-10T00:00:00.000Z"),
);
expect(facts[1].data.validUntil).toBeNull();
});
it("writes each custom metric back with its readings", async () => {
@@ -424,6 +538,25 @@ describe("backup round trip — export, wire schema, restore", () => {
);
});
it("writes unknown Fitbit workout provenance back with the workout", async () => {
const { written } = await roundTrip();
const write = written.find(
(entry) => entry.model === "workout" && entry.op === "createMany",
);
const rows = write!.data as unknown as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
source: "FITBIT",
sportType: "other",
externalId: "91002",
metadata: {
fitbitActivityName: "Activity Zeta",
fitbitActivityTypeId: 9002,
},
});
});
it("writes the day's cumulative shape back, every hour of it", async () => {
const { written } = await roundTrip();
@@ -470,4 +603,100 @@ describe("backup round trip — export, wire schema, restore", () => {
expect(res.status).toBe(200);
expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined();
});
describe("cached briefing invalidation on AI-scope change", () => {
// The source account's backup carries `aiIncludedSections: ["CONDITIONS",
// "SMOKING_STATUS"]` and the two SMOKING_STATUS fact revisions from
// `sourceClient()`. Reused below to build a byte-identical "live" seed so
// the unchanged-scope case is a genuine no-op, not a coincidence of two
// separately encrypted ciphertexts happening to differ.
function liveFactRowsFromPayload(payload: Record<string, unknown>) {
const facts = payload.healthProfileFacts as Array<{
id: string;
kind: string;
valueEncrypted: string;
validFrom: string;
validUntil: string | null;
provenance: string;
supersededByRevisionId: string | null;
}>;
return facts.map((fact) => ({
id: fact.id,
userId: OWNER,
kind: fact.kind,
valueEncrypted: Buffer.from(fact.valueEncrypted, "base64"),
validFrom: new Date(fact.validFrom),
validUntil: fact.validUntil ? new Date(fact.validUntil) : null,
provenance: fact.provenance,
supersededByRevisionId: fact.supersededByRevisionId,
}));
}
it("clears the cached briefing when the restored scope narrows", async () => {
// Live account currently has every section included — strictly wider
// than the backup's ["CONDITIONS", "SMOKING_STATUS"], so the restore
// narrows scope and must invalidate the pre-restore cache.
const { written } = await roundTrip({
userHealthProfile: {
findUnique: {
aiIncludedSections: [
"ABOUT_ME",
"CONDITIONS",
"ALLERGIES",
"COACH_FOCUS",
"FAMILY_HISTORY",
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
],
},
},
});
const userUpdate = written.find(
(w) => w.model === "user" && w.op === "update",
);
expect(
userUpdate,
"a scope-narrowing restore must clear the cached briefing",
).toBeDefined();
expect(userUpdate!.data).toEqual({
insightsCachedText: null,
insightsCachedAt: null,
});
});
it("leaves the cached briefing alone when the restored scope is unchanged", async () => {
const { payload } = await buildFullBackupPayload(
sourceClient() as never,
OWNER,
{ purpose: "disaster-recovery" },
);
const liveSeed = {
userHealthProfile: {
findUnique: {
aiIncludedSections: (
payload.healthProfile as { aiIncludedSections: string[] }
).aiIncludedSections,
},
},
healthProfileFactRevision: {
findMany: liveFactRowsFromPayload(payload),
},
};
// Restore that SAME payload — rebuilding it would re-encrypt every
// ciphertext field with a fresh IV, so the only variable this way is
// whether the restore over-invalidates on a genuine no-op.
const { written: writtenUnchanged } = await roundTrip(liveSeed, payload);
const userUpdate = writtenUnchanged.find(
(w) => w.model === "user" && w.op === "update",
);
expect(
userUpdate,
"an unchanged AI scope must not clear the cached briefing",
).toBeUndefined();
});
});
});
@@ -14,7 +14,7 @@
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { prisma, toJson } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
@@ -67,6 +67,7 @@ interface RestoreResponse {
workouts: number;
documents: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
correlationPatterns: number;
intradayProfiles: number;
@@ -1041,6 +1042,9 @@ const handler = apiHandler(
pauseDurationSec: workout.pauseDurationSec ?? null,
source: workout.source as never,
externalId: workout.externalId ?? null,
...(workout.metadata == null
? {}
: { metadata: toJson(workout.metadata) }),
...(workout.createdAt
? { createdAt: new Date(workout.createdAt) }
: {}),
@@ -1121,6 +1125,8 @@ const handler = apiHandler(
workouts: workouts.count,
documents: documents.count,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions:
profileCleared.healthProfileFactRevisions,
customMetrics: profileCleared.customMetrics,
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
@@ -1249,6 +1255,7 @@ const handler = apiHandler(
workouts: summary.workouts,
documents: summary.documents,
healthProfile: summary.healthProfile,
healthProfileFactRevisions: summary.healthProfileFactRevisions,
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
correlationPatterns: summary.correlationPatterns,