Files
HealthLog/tests/integration/ecg-ingest-roundtrip.test.ts
T
Marc-André Bombeck 42e16a6e12 Stop the plaintext-waveform check passing on a coincidence
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
2026-08-01 19:35:16 +02:00

369 lines
12 KiB
TypeScript

/**
* `POST /api/insights/ecg` end to end against real Postgres.
*
* Everything here goes through the actual route handlers — the ingest, the
* metadata list, and the waveform detail — because the thing worth proving is
* the assembly between them, not that each end works in isolation. What a
* client receives is read out of the real GET responses, never rebuilt from a
* fixture.
*
* The cross-door case is the point of the second unique index: the same
* physical recording reaching the export-archive importer and the live route
* carries two different source ids, and must still be one row.
*/
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";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
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),
}));
import {
GET as getEcgList,
POST as postEcg,
} from "@/app/api/insights/ecg/route";
import { GET as getEcgDetail } from "@/app/api/insights/ecg/[id]/route";
import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import";
const USER_ID = "ecg-ingest-roundtrip";
const OTHER_USER_ID = "ecg-ingest-roundtrip-other";
const RECORDED_AT = "2026-07-18T08:14:03+02:00";
const SAMPLES = [12, -7, 3, 240, -180, 60];
type IngestBody = Record<string, unknown>;
function ingestPayload(overrides: IngestBody = {}): IngestBody {
return {
externalRecordingId: "8E1F3B0C-0000-4000-8000-000000000001",
recordedAt: RECORDED_AT,
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
classification: "NOT_DETECTED",
source: "APPLE_HEALTH",
...overrides,
};
}
async function ingest(body: IngestBody): Promise<{
status: number;
data: {
id: string;
status: string;
recordedAt: string;
sampleCount: number;
durationSeconds: number | null;
};
}> {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
return {
status: response.status,
data: (await response.json()).data,
};
}
async function readList(): Promise<{
recordings: Array<Record<string, unknown>>;
hasRecordings: boolean;
}> {
// The list handler declares no parameters; the wrapper still receives the
// request, so hand it one the way the route's own unit test does.
const callList = getEcgList as unknown as (
request: NextRequest,
) => Promise<Response>;
const response = await callList(
new NextRequest("http://localhost/api/insights/ecg"),
);
expect(response.status).toBe(200);
return (await response.json()).data;
}
async function readDetail(
id: string,
full = false,
): Promise<{ response: Response; body: Record<string, unknown> }> {
const url = full
? `http://localhost/api/insights/ecg/${id}?full=1`
: `http://localhost/api/insights/ecg/${id}`;
const response = await getEcgDetail(new NextRequest(url), {
params: Promise.resolve({ id }),
});
return { response, body: (await response.json()).data };
}
async function signIn(userId: string): Promise<void> {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await prisma.user.createMany({
data: [
{
id: USER_ID,
username: "ecg-roundtrip",
email: "ecg-roundtrip@example.test",
timezone: "Europe/Berlin",
},
{
id: OTHER_USER_ID,
username: "ecg-roundtrip-other",
email: "ecg-roundtrip-other@example.test",
timezone: "Europe/Berlin",
},
],
});
await signIn(USER_ID);
});
describe("POST /api/insights/ecg — round trip through the real routes", () => {
it("stores a posted recording and serves it back through the list and the detail", async () => {
const posted = await ingest(ingestPayload());
expect(posted.status).toBe(201);
expect(posted.data.status).toBe("inserted");
expect(posted.data.sampleCount).toBe(SAMPLES.length);
expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10);
const list = await readList();
expect(list.hasRecordings).toBe(true);
expect(list.recordings).toHaveLength(1);
expect(list.recordings[0]).toMatchObject({
id: posted.data.id,
samplingFrequency: 512,
sampleCount: SAMPLES.length,
averageHeartRate: 64,
lead: "I",
classification: "NOT_DETECTED",
source: "APPLE_HEALTH",
hasWaveform: true,
});
expect(
new Date(list.recordings[0].recordedAt as string).toISOString(),
).toBe(new Date(RECORDED_AT).toISOString());
const detail = await readDetail(posted.data.id, true);
expect(detail.response.status).toBe(200);
expect(detail.body.samples).toEqual(SAMPLES);
expect(detail.body.decimated).toBe(false);
expect(detail.body.classification).toBe("NOT_DETECTED");
});
it("never writes the waveform as plaintext", async () => {
const posted = await ingest(ingestPayload());
const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({
where: { id: posted.data.id },
});
const stored = Buffer.from(row.waveformEncrypted).toString("utf8");
// The column holds the `<keyId>.<base64>` envelope and nothing else. A
// serialised sample array cannot satisfy that shape — it carries
// brackets, commas and minus signs — so this rules plaintext out without
// asking whether some digit happens to appear in a base64 blob, which it
// routinely does.
expect(stored).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9+/=]+$/);
expect(stored).not.toContain(JSON.stringify(SAMPLES));
});
it("reports a re-post as updated and does not grow the table", async () => {
const first = await ingest(ingestPayload());
const second = await ingest(ingestPayload());
expect(second.status).toBe(200);
expect(second.data.status).toBe("updated");
expect(second.data.id).toBe(first.data.id);
expect(
await getPrismaClient().ecgRecording.count({
where: { userId: USER_ID },
}),
).toBe(1);
expect((await readList()).recordings).toHaveLength(1);
});
it("overwrites the stored recording in place when a re-post revises it", async () => {
const first = await ingest(ingestPayload());
const revised = [1, 2, 3, 4];
const second = await ingest(
ingestPayload({ samples: revised, classification: "IRREGULAR" }),
);
expect(second.data.status).toBe("updated");
expect(second.data.id).toBe(first.data.id);
const detail = await readDetail(first.data.id, true);
expect(detail.body.samples).toEqual(revised);
expect(detail.body.classification).toBe("IRREGULAR");
});
it("keeps one row when the same recording also arrives through the export archive", async () => {
const posted = await ingest(ingestPayload());
// The archive importer identifies the strip by a hash of its content, so
// it arrives under a completely different externalRecordingId.
const outcome = await importAppleHealthEcg({
userId: USER_ID,
ecg: {
recordedAt: new Date(RECORDED_AT),
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
rhythmClassification: "NOT_DETECTED",
},
prisma: getPrismaClient(),
});
expect(outcome).toBe("skipped");
const rows = await getPrismaClient().ecgRecording.findMany({
where: { userId: USER_ID },
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(posted.data.id);
expect((await readList()).recordings).toHaveLength(1);
});
it("reports duplicate when the export archive got there first", async () => {
await importAppleHealthEcg({
userId: USER_ID,
ecg: {
recordedAt: new Date(RECORDED_AT),
samplingFrequency: 512,
samples: [...SAMPLES],
lead: "I",
averageHeartRate: 64,
rhythmClassification: "NOT_DETECTED",
},
prisma: getPrismaClient(),
});
const archived = await getPrismaClient().ecgRecording.findFirstOrThrow({
where: { userId: USER_ID },
});
const posted = await ingest(ingestPayload());
expect(posted.status).toBe(200);
expect(posted.data.status).toBe("duplicate");
expect(posted.data.id).toBe(archived.id);
expect(
await getPrismaClient().ecgRecording.count({
where: { userId: USER_ID },
}),
).toBe(1);
// Nothing was written: the archive's own external id still owns the row.
expect(
(
await getPrismaClient().ecgRecording.findUniqueOrThrow({
where: { id: archived.id },
})
).externalRecordingId,
).toBe(archived.externalRecordingId);
});
it("stores distinct recordings distinctly", async () => {
await ingest(ingestPayload());
const other = await ingest(
ingestPayload({
externalRecordingId: "8E1F3B0C-0000-4000-8000-000000000002",
recordedAt: "2026-07-19T08:14:03+02:00",
}),
);
expect(other.data.status).toBe("inserted");
expect((await readList()).recordings).toHaveLength(2);
});
it("writes to the session's user and cannot reach another account's rows", async () => {
const mine = await ingest(ingestPayload());
cookieJar.clear();
await signIn(OTHER_USER_ID);
// Same recording id, same instant — a different account entirely.
const theirs = await ingest(ingestPayload());
expect(theirs.status).toBe(201);
expect(theirs.data.status).toBe("inserted");
expect(theirs.data.id).not.toBe(mine.data.id);
expect((await readList()).recordings).toHaveLength(1);
const foreign = await readDetail(mine.data.id, true);
expect(foreign.response.status).toBe(404);
const owners = await getPrismaClient().ecgRecording.findMany({
select: { userId: true },
});
expect(owners.map((r) => r.userId).sort()).toEqual(
[USER_ID, OTHER_USER_ID].sort(),
);
});
it("refuses a source a client is not allowed to claim", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ source: "WITHINGS" })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
it("refuses a userId in the body rather than honouring it", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ userId: OTHER_USER_ID })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
it("refuses an event verdict that cannot occur on an ECG", async () => {
const response = await postEcg(
new NextRequest("http://localhost/api/insights/ecg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ingestPayload({ classification: "VERY_LOW" })),
}),
);
expect(response.status).toBe(422);
expect(await getPrismaClient().ecgRecording.count()).toBe(0);
});
});