mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'main' into fix/v1396-small-fixes
This commit is contained in:
39 files changed
+2981
-155
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user