Merge branch 'main' into fix/v1396-small-fixes

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:27:25 +02:00
commit 85d11bc364
39 files changed
+2981 -155

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
/**
* The restore preview is worked out while a copy is written and answered from
* the row, without opening the copy (#1031).
*
* Both ends and the pipe: the copies are written by the real weekly job and
* the real upload route, the preview route reads them back, and what it
* answers is held against a full read of the stored copy, which is how the
* preview used to be taken. A copy from before previews is read whole once
* and its preview kept; a key dropped after the copy was written is still
* named, because the verdict is taken again at every read.
*
* Mutations that must turn this red: drop the preview scanner from
* `storeBackupBlob` (no preview on the row, the route opens the copy), or
* answer from the stored verdict instead of taking it again (the dropped key
* is not named).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
import { Prisma } from "@/generated/prisma/client";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readStoredBackup } from "./stored-backup-read";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.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),
}));
const opened = vi.hoisted(() => ({ count: 0 }));
vi.mock("@/lib/export/stored-backup", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/export/stored-backup")>();
return {
...actual,
openStoredBackup: (...a: Parameters<typeof actual.openStoredBackup>) => {
opened.count += 1;
return actual.openStoredBackup(...a);
},
};
});
const saved = {
key: process.env.ENCRYPTION_KEY,
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
function useKeys(keys: Record<string, string>, active: string) {
process.env.ENCRYPTION_KEY = "";
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
_resetCryptoCacheForTests();
}
const OLD = "11".repeat(32);
const CUR = "22".repeat(32);
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
opened.count = 0;
useKeys({ old: OLD, cur: CUR }, "cur");
});
afterEach(() => {
process.env.ENCRYPTION_KEY = saved.key ?? "";
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
_resetCryptoCacheForTests();
});
async function seedAdmin() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "preview-admin",
email: "preview-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return admin;
}
async function preview(id: string) {
const { GET } = await import("@/app/api/admin/backups/[id]/summary/route");
const res = await GET(
new Request(`http://localhost/api/admin/backups/${id}/summary`) as never,
{ params: Promise.resolve({ id }) },
);
return {
status: res.status,
body: (await res.json()) as {
data: { summary: Record<string, unknown> } | null;
error: string | null;
meta?: { errorCode?: string; keyIds?: string[] };
},
};
}
/** The counts a full read of the stored copy gives: the old preview. */
async function fullReadSummary(id: string) {
const { parseBackupPayload, summarizeBackup } =
await import("@/lib/validations/backup");
const opensBefore = opened.count;
const payload = parseBackupPayload(
JSON.parse(await readStoredBackup(getPrismaClient(), id)),
);
opened.count = opensBefore;
return summarizeBackup(payload);
}
describe("restore preview kept with the copy", () => {
it("the weekly backup writes it, and the preview answers from it", async () => {
const prisma = getPrismaClient();
const admin = await seedAdmin();
await prisma.measurement.createMany({
data: Array.from({ length: 25 }, (_, i) => ({
userId: admin.id,
type: "PULSE" as const,
value: 60 + i,
unit: "bpm",
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)),
source: "MANUAL" as const,
})),
});
const { handleDataBackup } =
await import("@/lib/jobs/reminder/backup-handlers");
await handleDataBackup([]);
const row = await prisma.dataBackup.findFirstOrThrow({
where: { userId: admin.id, type: "WEEKLY_AUTO" },
});
expect(row.preview).toMatchObject({
version: 1,
copy: `chunks:${row.chunkStreamId}:${row.chunkCount}`,
summary: { measurements: 25 },
});
const res = await preview(row.id);
expect(res.status).toBe(200);
expect(opened.count).toBe(0);
expect(res.body.data!.summary).toEqual(await fullReadSummary(row.id));
expect(res.body.data!.summary.measurements).toBe(25);
});
it("an upload writes it, and a key dropped later is still named", async () => {
const prisma = getPrismaClient();
const admin = await seedAdmin();
// A note written under `old`, as a copy taken before a rotation carries.
useKeys({ old: OLD, cur: CUR }, "old");
const note = Buffer.from(encrypt("before"), "utf8").toString("base64");
useKeys({ old: OLD, cur: CUR }, "cur");
const file = JSON.stringify({
schemaVersion: "2",
exportedAt: "2026-09-20T00:00:00.000Z",
userId: admin.id,
measurements: Array.from({ length: 30 }, (_, i) => ({
id: `m-${i}`,
type: "PULSE",
value: 60 + (i % 30),
unit: "bpm",
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)).toISOString(),
source: "MANUAL",
...(i === 3 ? { notesEncrypted: note } : {}),
})),
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const uploaded = await POST(
new Request("http://localhost/api/admin/backups/upload", {
method: "POST",
headers: { "content-type": "application/json" },
body: file,
}) as never,
);
expect(uploaded.status).toBe(201);
const { data: upload } = (await uploaded.json()) as {
data: { id: string; summary: Record<string, unknown> };
};
const row = await prisma.dataBackup.findUniqueOrThrow({
where: { id: upload.id },
});
expect(row.preview).toMatchObject({ summary: { measurements: 30 } });
const res = await preview(upload.id);
expect(res.status).toBe(200);
expect(opened.count).toBe(0);
expect(res.body.data!.summary).toEqual(upload.summary);
expect(res.body.data!.summary).toEqual(await fullReadSummary(upload.id));
// The operator drops `old` after the copy was written.
useKeys({ cur: CUR }, "cur");
const refused = await preview(upload.id);
expect(refused.status).toBe(422);
expect(refused.body.meta).toMatchObject({
errorCode: "backup.key.missing",
keyIds: ["old"],
});
expect(opened.count).toBe(0);
});
it("a copy from before previews is read whole once, then answered from the row", async () => {
const prisma = getPrismaClient();
const admin = await seedAdmin();
await prisma.measurement.createMany({
data: Array.from({ length: 5 }, (_, i) => ({
userId: admin.id,
type: "PULSE" as const,
value: 60 + i,
unit: "bpm",
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)),
source: "MANUAL" as const,
})),
});
const { handleDataBackup } =
await import("@/lib/jobs/reminder/backup-handlers");
await handleDataBackup([]);
const row = await prisma.dataBackup.findFirstOrThrow({
where: { userId: admin.id },
});
// As an earlier release left it.
await prisma.dataBackup.update({
where: { id: row.id },
data: { preview: Prisma.DbNull },
});
const first = await preview(row.id);
expect(first.status).toBe(200);
expect(opened.count).toBe(1);
expect(first.body.data!.summary.measurements).toBe(5);
await vi.waitFor(async () => {
const kept = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(kept.preview).toMatchObject({ summary: { measurements: 5 } });
});
const second = await preview(row.id);
expect(second.status).toBe(200);
expect(opened.count).toBe(1);
expect(second.body.data!.summary).toEqual(first.body.data!.summary);
});
it("a replaced copy is described by its own preview", async () => {
const prisma = getPrismaClient();
const admin = await seedAdmin();
const { handleDataBackup } =
await import("@/lib/jobs/reminder/backup-handlers");
await handleDataBackup([]);
await prisma.measurement.create({
data: {
userId: admin.id,
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: new Date(Date.UTC(2026, 8, 2)),
source: "MANUAL",
},
});
await handleDataBackup([]);
const row = await prisma.dataBackup.findFirstOrThrow({
where: { userId: admin.id },
});
expect(row.preview).toMatchObject({
copy: `chunks:${row.chunkStreamId}:${row.chunkCount}`,
summary: { measurements: 1 },
});
const res = await preview(row.id);
expect(res.body.data!.summary.measurements).toBe(1);
expect(opened.count).toBe(0);
});
it("a single stored value keeps its preview when it is converted to pieces", async () => {
const prisma = getPrismaClient();
const admin = await seedAdmin();
// The oldest stored form: the whole file as one encrypted value.
const row = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_1",
data: encrypt(
JSON.stringify({
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
userId: admin.id,
measurements: [
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
},
],
}),
),
},
});
const first = await preview(row.id);
expect(first.status).toBe(200);
expect(opened.count).toBe(1);
await vi.waitFor(async () => {
const kept = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(kept.preview).toMatchObject({ summary: { measurements: 1 } });
});
const { convertSingleValueBackup } =
await import("@/lib/export/store-backup-blob");
expect(await convertSingleValueBackup(prisma, row.id)).toBe("converted");
const converted = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(converted.preview).toMatchObject({
copy: `chunks:${converted.chunkStreamId}:${converted.chunkCount}`,
summary: { measurements: 1 },
});
const second = await preview(row.id);
expect(second.status).toBe(200);
expect(opened.count).toBe(1);
expect(second.body.data!.summary).toEqual(first.body.data!.summary);
});
});
@@ -951,3 +951,173 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe
expect(row.userId).toBe(ownerId);
});
});
describe("B9 — the workout-ingest scope reaches the workout batch and nothing else", () => {
function postWorkouts(entries: Array<Record<string, unknown>>): NextRequest {
return new NextRequest("https://health.example/api/workouts/batch", {
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({ workouts: entries }),
} as never);
}
function bridgedRun(
externalId: string,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
sportType: "running",
startedAt: "2026-09-20T06:30:00.000Z",
endedAt: "2026-09-20T07:15:00.000Z",
externalId,
...extra,
};
}
it("admits the scope on POST /api/workouts/batch, attributed EXTERNAL", async () => {
await armToken(["workouts:write"], "wwrite1");
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(postWorkouts([bridgedRun("bridge-run-1")]));
expect(res.status).toBe(200);
const row = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: USER_ID, externalId: "bridge-run-1" },
});
// The body names no source; the schema would default it to MANUAL, and
// the route resolves EXTERNAL from the credential instead.
expect(row.source).toBe("EXTERNAL");
});
it("re-posting the same bridged workout is a duplicate, not a second row", async () => {
// The dedup probe has to look in the EXTERNAL namespace the insert wrote
// to. Probing under the body's defaulted MANUAL would miss the stored row
// and the unique index would be the only thing standing in the way.
await armToken(["workouts:write"], "wwrite1b");
const { POST } = await import("@/app/api/workouts/batch/route");
await POST(postWorkouts([bridgedRun("bridge-run-2")]));
const again = await POST(postWorkouts([bridgedRun("bridge-run-2")]));
expect(again.status).toBe(200);
expect((await again.json()).data.entries[0].status).toBe("duplicate");
expect(await getPrismaClient().workout.count()).toBe(1);
});
it.each(["APPLE_HEALTH", "MANUAL"])(
"refuses a batch in which an entry names %s",
async (source) => {
await armToken(["workouts:write"], `wwrite2-${source}`);
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(
postWorkouts([
bridgedRun("bridge-run-3"),
bridgedRun("bridge-run-4", { source }),
]),
);
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe(
"workout.batch.source_not_permitted",
);
expect(await getPrismaClient().workout.count()).toBe(0);
},
);
it("a measurements:write token is refused on the workout batch", async () => {
// Separate scopes on purpose: a token handed to a scale does not start
// accepting workouts.
await armToken(["measurements:write"], "wwrite3");
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(postWorkouts([bridgedRun("bridge-run-5")]));
expect(res.status).toBe(403);
expect(await getPrismaClient().workout.count()).toBe(0);
});
it("and a workouts:write token is refused on the measurement batch", async () => {
await armToken(["workouts:write"], "wwrite4");
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(
new NextRequest("https://health.example/api/measurements/batch", {
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 70,
unit: "kg",
startDate: new Date().toISOString(),
endDate: new Date().toISOString(),
externalId: "uuid-workouts-write-1",
},
],
}),
} as never),
);
expect(res.status).toBe(403);
expect(await getPrismaClient().measurement.count()).toBe(0);
});
it("is refused on the workout read legs", async () => {
await armToken(["workouts:write"], "wwrite5");
const { GET } = await import("@/app/api/workouts/route");
expect((await GET(req("/api/workouts"))).status).toBe(403);
await expect
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
.toBe("undeclared_scope");
});
it("cannot mint another token", async () => {
await armToken(["workouts:write"], "wwrite6");
const { POST } = await import("@/app/api/tokens/workouts/route");
const res = await POST(
new NextRequest("https://health.example/api/tokens/workouts", {
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({ name: "second" }),
} as never),
);
expect(res.status).toBe(401);
});
it("the mint issues exactly this scope, and the token it issues writes", async () => {
// Mint on a cookie session with a fresh proof, then use the raw token the
// response carries: the round trip a bridge operator makes.
await useCookieSession();
const { POST: mint } = await import("@/app/api/tokens/workouts/route");
const minted = await mint(
new NextRequest("https://health.example/api/tokens/workouts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "watch bridge" }),
} as never),
);
expect(minted.status).toBe(201);
const raw = (await minted.json()).data.token as string;
const row = await getPrismaClient().apiToken.findFirstOrThrow({
where: { name: "watch bridge" },
});
expect(row.permissions).toEqual(["workouts:write"]);
cookieJar.clear();
headerJar.set("authorization", `Bearer ${raw}`);
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(postWorkouts([bridgedRun("bridge-run-6")]));
expect(res.status).toBe(200);
const written = await getPrismaClient().workout.findFirstOrThrow({
where: { externalId: "bridge-run-6" },
});
expect(written.source).toBe("EXTERNAL");
});
});