mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The GPS track of an outdoor workout was stored readable, and its first and last points are usually the person's front door. The phone number and address in the address book of doctors and practices were readable too, while the note beside them was already encrypted. Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus partial indexes on the backfill's discovery predicates. A track is sealed as one binary AES-256-GCM value bound to its purpose; the contact fields use the same Bytes codec as the note. The practitioner's name, practice and specialty stay readable, because the picker searches and sorts on them. Every writer stores ciphertext only: workout batch ingest, practitioner create and edit, and both restore formats. Readers prefer the ciphertext and fall back to the old column for rows not yet migrated: the workout detail (map, splits, GPX), the activity insight's climb figure, the practitioner DTO behind the address book, visits and vaccinations. The wire shape does not change. An edit's audit row now names a changed phone number or address without recording either value. free-text-encryption-backfill covers the three columns in the same pass, finding a route through its workout. All three are registered for key rotation (registry, CLI script, and the in-app rotation). A disaster-recovery backup carries the contact fields as ciphertext only; a portable export carries them readable. Workout routes are still not part of a backup, as before. The old columns are marked for removal in a later release.
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
/**
|
|
* Integration suite for `POST /api/workouts/batch` — the v1.5 iOS
|
|
* HealthKit ingest endpoint and the deferred Withings activity sync
|
|
* target. Asserts the contract the iOS client relies on against a real
|
|
* Postgres in a testcontainer:
|
|
*
|
|
* - Mixed batches with nested routes succeed end-to-end
|
|
* - Re-posting the same batch yields per-entry duplicates
|
|
* - Over-cap batches return 400 with the documented error code
|
|
* - Over-cap routes return 400 with the documented error code
|
|
* - Idempotency-Key replay returns the cached envelope
|
|
* - Rate-limit kicks in at the documented ceiling
|
|
* - Narrow-scope Bearer tokens are refused; the wildcard token iOS
|
|
* actually holds is admitted
|
|
*
|
|
* A separate file at this directory carries the concurrent-write race
|
|
* test so the contention assertion stays focused on the invariant that
|
|
* was the original target of the W10 fix-C reconciliation.
|
|
*/
|
|
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";
|
|
|
|
// The HMAC key must be present before any module that touches
|
|
// `@/lib/auth/hmac` is imported — `hashToken()` reads it lazily but
|
|
// only the first call's process state is exercised here.
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-workout-batch-integration-32-bytes-min-1234567890";
|
|
process.env.ENCRYPTION_KEY =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
|
|
const TEST_USER_ID = "user-workout-batch-test";
|
|
|
|
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),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "workout-batch",
|
|
email: "workout-batch@example.test",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
interface WorkoutFixture {
|
|
sportType?: string;
|
|
startedAt?: string;
|
|
endedAt?: string;
|
|
totalEnergyKcal?: number;
|
|
totalDistanceM?: number;
|
|
avgHeartRate?: number;
|
|
maxHeartRate?: number;
|
|
minHeartRate?: number;
|
|
stepCount?: number;
|
|
elevationM?: number;
|
|
pauseDurationSec?: number;
|
|
source?: string;
|
|
externalId?: string;
|
|
externalSourceVersion?: string;
|
|
metadata?: Record<string, unknown>;
|
|
route?: {
|
|
geometry: { type: "LineString"; coordinates: [number, number][] };
|
|
sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>;
|
|
};
|
|
samples?: Array<{
|
|
t: string;
|
|
hr?: number;
|
|
speedMs?: number;
|
|
power?: number;
|
|
cadence?: number;
|
|
}>;
|
|
}
|
|
|
|
function makeRequest(
|
|
body: { workouts: WorkoutFixture[] },
|
|
opts: {
|
|
idempotencyKey?: string;
|
|
bearer?: string;
|
|
contentLength?: number | null;
|
|
} = {},
|
|
): NextRequest {
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
};
|
|
if (opts.idempotencyKey) {
|
|
headers["idempotency-key"] = opts.idempotencyKey;
|
|
}
|
|
if (opts.bearer) {
|
|
headers["authorization"] = `Bearer ${opts.bearer}`;
|
|
}
|
|
if (opts.contentLength !== null && opts.contentLength !== undefined) {
|
|
headers["content-length"] = String(opts.contentLength);
|
|
}
|
|
const serialized = JSON.stringify(body);
|
|
return new NextRequest("http://localhost/api/workouts/batch", {
|
|
method: "POST",
|
|
headers,
|
|
body: serialized,
|
|
});
|
|
}
|
|
|
|
function baseWorkout(
|
|
externalId: string,
|
|
overrides: WorkoutFixture = {},
|
|
): WorkoutFixture {
|
|
return {
|
|
sportType: "running",
|
|
startedAt: "2026-05-14T06:30:00.000Z",
|
|
endedAt: "2026-05-14T07:15:00.000Z",
|
|
source: "APPLE_HEALTH",
|
|
externalId,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("POST /api/workouts/batch (real Postgres)", () => {
|
|
it("inserts a single workout with a nested route", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-route-001", {
|
|
totalEnergyKcal: 412,
|
|
totalDistanceM: 7800,
|
|
avgHeartRate: 154,
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[11.077, 49.452],
|
|
[11.078, 49.453],
|
|
[11.079, 49.454],
|
|
],
|
|
},
|
|
sampleTimestamps: [
|
|
{ t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 },
|
|
{ t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 },
|
|
{ t: "2026-05-14T06:30:10.000Z", speedMs: 3.1, hr: 146 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
};
|
|
|
|
const res = await POST(makeRequest(body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { processed: number; inserted: number; duplicates: number };
|
|
};
|
|
expect(json.data.processed).toBe(1);
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const workouts = await getPrismaClient().workout.findMany({
|
|
where: { userId: TEST_USER_ID },
|
|
include: { route: true },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
expect(workouts[0]?.sportType).toBe("running");
|
|
expect(workouts[0]?.totalDistanceM).toBe(7800);
|
|
expect(workouts[0]?.route).not.toBeNull();
|
|
expect(workouts[0]?.durationSec).toBe(45 * 60);
|
|
|
|
// v1.39.4 — the track is on disk sealed only. The readable column is SQL
|
|
// NULL, the ciphertext does not contain a coordinate, and it opens to the
|
|
// exact geometry the client sent.
|
|
const onDisk = await getPrismaClient().$queryRaw<
|
|
{ readable_is_null: boolean; leaks: boolean }[]
|
|
>`
|
|
SELECT geometry IS NULL AS readable_is_null,
|
|
position(convert_to('49.452', 'UTF8') IN geometry_encrypted) > 0
|
|
AS leaks
|
|
FROM workout_routes WHERE workout_id = ${workouts[0]!.id}`;
|
|
expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]);
|
|
const { decryptRouteGeometry } =
|
|
await import("@/lib/workouts/route-geometry-cipher");
|
|
expect(
|
|
decryptRouteGeometry(workouts[0]!.route!.geometryEncrypted!),
|
|
).toEqual(body.workouts[0]!.route!.geometry);
|
|
});
|
|
|
|
it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [
|
|
baseWorkout("hk-uuid-indoor-001", {
|
|
sportType: "strength",
|
|
// No route — an indoor session has no GPS geometry.
|
|
samples: [
|
|
{ t: "2026-05-14T06:30:00.000Z", hr: 110 },
|
|
{ t: "2026-05-14T06:30:05.000Z", hr: 118, power: 0, cadence: 0 },
|
|
{ t: "2026-05-14T06:30:10.000Z", hr: 124 },
|
|
],
|
|
}),
|
|
],
|
|
};
|
|
|
|
const res = await POST(makeRequest(body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { inserted: number };
|
|
};
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const workouts = await getPrismaClient().workout.findMany({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-indoor-001" },
|
|
include: { route: true, samples: true },
|
|
});
|
|
expect(workouts).toHaveLength(1);
|
|
// No route row — the series lives in the dedicated child table.
|
|
expect(workouts[0]?.route).toBeNull();
|
|
expect(workouts[0]?.samples).not.toBeNull();
|
|
expect(workouts[0]?.samples?.sampleCount).toBe(3);
|
|
expect((workouts[0]?.samples?.samples as unknown[]).length).toBe(3);
|
|
});
|
|
|
|
it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const workouts: WorkoutFixture[] = Array.from({ length: 100 }, (_, i) => {
|
|
const offsetMin = i * 60;
|
|
const start = new Date(2026, 4, 1, 6, offsetMin).toISOString();
|
|
const end = new Date(2026, 4, 1, 6, offsetMin + 30).toISOString();
|
|
const hasRoute = i % 5 === 0;
|
|
return baseWorkout(`hk-uuid-batch-${i}`, {
|
|
startedAt: start,
|
|
endedAt: end,
|
|
...(hasRoute
|
|
? {
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[11.077 + i * 1e-4, 49.452],
|
|
[11.078 + i * 1e-4, 49.453],
|
|
],
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
});
|
|
|
|
const res = await POST(makeRequest({ workouts }));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
data: { processed: number; inserted: number };
|
|
};
|
|
expect(json.data.processed).toBe(100);
|
|
expect(json.data.inserted).toBe(100);
|
|
|
|
const storedWorkouts = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(storedWorkouts).toBe(100);
|
|
const storedRoutes = await getPrismaClient().workoutRoute.count({
|
|
where: { workout: { userId: TEST_USER_ID } },
|
|
});
|
|
expect(storedRoutes).toBe(20);
|
|
});
|
|
|
|
it("returns 400 with workout.batch.too_large when workouts exceed the cap", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const workouts = Array.from({ length: 101 }, (_, i) =>
|
|
baseWorkout(`hk-uuid-cap-${i}`),
|
|
);
|
|
|
|
const res = await POST(makeRequest({ workouts }));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as {
|
|
error: string;
|
|
meta?: { errorCode?: string };
|
|
};
|
|
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("returns 400 when a single route exceeds the 20 000-point cap", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const coordinates: [number, number][] = [];
|
|
for (let i = 0; i < 20_001; i++) {
|
|
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
|
|
}
|
|
|
|
const res = await POST(
|
|
makeRequest(
|
|
{
|
|
workouts: [
|
|
baseWorkout("hk-uuid-route-cap", {
|
|
route: { geometry: { type: "LineString", coordinates } },
|
|
}),
|
|
],
|
|
},
|
|
// Skip the Content-Length pre-flight — the test wants to
|
|
// exercise the schema cap, not the byte cap.
|
|
{ contentLength: null },
|
|
),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as {
|
|
error: string;
|
|
meta?: { errorCode?: string };
|
|
};
|
|
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("preserves same-source workouts and surfaces exact re-posts as duplicates", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [baseWorkout("hk-uuid-dup-1"), baseWorkout("hk-uuid-dup-2")],
|
|
};
|
|
|
|
const first = await POST(makeRequest(body));
|
|
expect(first.status).toBe(200);
|
|
const firstJson = (await first.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
expect(firstJson.data.inserted).toBe(2);
|
|
expect(firstJson.data.duplicates).toBe(0);
|
|
|
|
const second = await POST(makeRequest(body));
|
|
expect(second.status).toBe(200);
|
|
const secondJson = (await second.json()) as {
|
|
data: {
|
|
inserted: number;
|
|
duplicates: number;
|
|
entries: Array<{ status: string }>;
|
|
};
|
|
};
|
|
expect(secondJson.data.inserted).toBe(0);
|
|
expect(secondJson.data.duplicates).toBe(2);
|
|
expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe(
|
|
true,
|
|
);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(2);
|
|
});
|
|
|
|
it("replays a cached response when the same Idempotency-Key is reused", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const body = {
|
|
workouts: [baseWorkout("hk-uuid-idem-1")],
|
|
};
|
|
|
|
const key = "ios-workout-batch-12345678";
|
|
const first = await POST(makeRequest(body, { idempotencyKey: key }));
|
|
expect(first.status).toBe(200);
|
|
const firstJson = (await first.json()) as { data: { inserted: number } };
|
|
expect(firstJson.data.inserted).toBe(1);
|
|
|
|
const second = await POST(makeRequest(body, { idempotencyKey: key }));
|
|
expect(second.status).toBe(200);
|
|
expect(second.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
const secondJson = (await second.json()) as { data: { inserted: number } };
|
|
// Same envelope as the first call — even though it would otherwise
|
|
// have surfaced as a duplicate.
|
|
expect(secondJson.data.inserted).toBe(1);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID, externalId: "hk-uuid-idem-1" },
|
|
});
|
|
expect(stored).toBe(1);
|
|
});
|
|
|
|
it("rate-limits a user at 60 batches per minute", async () => {
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
const cap = 60;
|
|
const resetAt = new Date(Date.now() + 60 * 1000);
|
|
await getPrismaClient().$executeRaw`
|
|
INSERT INTO rate_limits (key, count, reset_at)
|
|
VALUES (${`workouts:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt})
|
|
`;
|
|
|
|
const res = await POST(
|
|
makeRequest({ workouts: [baseWorkout("hk-uuid-rate-limit-1")] }),
|
|
);
|
|
expect(res.status).toBe(429);
|
|
const json = (await res.json()) as { error: string };
|
|
expect(json.error).toMatch(/too many/i);
|
|
|
|
const stored = await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(stored).toBe(0);
|
|
});
|
|
|
|
it("refuses a narrow-scope Bearer token, admits the wildcard one", async () => {
|
|
// Supersedes the W10 fix-C case, whose comment claimed "the iOS
|
|
// narrow-scope sync depends on" a narrow token being admitted here. It
|
|
// does not: `workouts:ingest` has no mint site anywhere in the tree, and
|
|
// iOS holds a `["*"]` token from login / passkey / refresh rotation. What
|
|
// that case actually pinned was the fail-open default — a token scoped to
|
|
// anything at all reaching a route that named no scope.
|
|
//
|
|
// Both halves are asserted together so the real iOS contract is the thing
|
|
// under test, not an inference from the deny case.
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
|
|
// Drop the cookie session so the Bearer path is the ONLY auth in play.
|
|
cookieJar.delete("healthlog_session");
|
|
|
|
const narrowToken =
|
|
"hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5";
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "narrow-scope-workouts",
|
|
tokenHash: hashToken(narrowToken),
|
|
permissions: ["workouts:ingest"], // NO wildcard
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${narrowToken}`);
|
|
|
|
const denied = await POST(
|
|
makeRequest(
|
|
{ workouts: [baseWorkout("hk-uuid-narrow-1")] },
|
|
{ bearer: narrowToken },
|
|
),
|
|
);
|
|
expect(denied.status).toBe(403);
|
|
expect(
|
|
await getPrismaClient().workout.count({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBe(0);
|
|
|
|
// The credential iOS actually holds.
|
|
const wildcardToken = "hlk_wildcard_workout_ingest_token_value_f6g7h8i9j0";
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
name: "wildcard-workouts",
|
|
tokenHash: hashToken(wildcardToken),
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${wildcardToken}`);
|
|
|
|
const admitted = await POST(
|
|
makeRequest(
|
|
{ workouts: [baseWorkout("hk-uuid-wildcard-1")] },
|
|
{ bearer: wildcardToken },
|
|
),
|
|
);
|
|
expect(admitted.status).toBe(200);
|
|
const json = (await admitted.json()) as { data: { inserted: number } };
|
|
expect(json.data.inserted).toBe(1);
|
|
});
|
|
});
|