reconcile: fold in the outstanding hardening and data-integrity fixes

Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.

Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
  shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
  can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
  requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
  rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
  into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
  read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
  on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
  the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
  body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
  path-secret redaction and the Withings credentials delete.

Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
  job with a single-active-job admission gate. The future-date bound is
  re-applied at request validation and the per-user limiter is kept for
  parity with the bulk twin; the old synchronous-write test is replaced.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 17:15:21 +02:00
commit b1e36d9fb0
51 files changed
+2066 -228

No files matched your search

@@ -0,0 +1,167 @@
/**
* Integration guard: nutrient day totals ride the full backup.
*
* `NutrientIntakeDay` was in no export path at all — which contradicted the
* schema's own justification for denormalising the `unit` column ("rows stay
* self-describing in exports even if the catalog ever drifts"). A user who
* exported everything still lost every water and micronutrient row.
*
* `source` is part of the composite primary key, so it has to ride along too:
* without it a restore cannot tell a manual water entry from a synced day
* total, and the two collapse onto one row.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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();
});
async function seedUserSession(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username, email: `${username}@example.test`, role: "USER" },
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
interface BackupWithNutrients {
userId: string;
nutrientDays?: Array<{
day: string;
nutrient: string;
amount: number;
unit: string;
source: string;
}>;
}
describe("full backup — nutrient day totals", () => {
it("exports nutrient rows for the authed user, keeping unit and source", async () => {
const prisma = getPrismaClient();
const me = await seedUserSession("nutrient-backup-user");
const other = await prisma.user.create({
data: {
username: "nutrient-other",
email: "nutrient-other@example.test",
role: "USER",
},
});
await prisma.nutrientIntakeDay.createMany({
data: [
{
userId: me.id,
day: "2026-05-01",
nutrient: "water",
amount: 1500,
unit: "ml",
source: "MANUAL",
},
// Same (day, nutrient) under the other source — both must survive,
// which is the whole reason `source` joined the composite PK.
{
userId: me.id,
day: "2026-05-01",
nutrient: "water",
amount: 800,
unit: "ml",
source: "APPLE_HEALTH",
},
{
userId: me.id,
day: "2026-05-01",
nutrient: "vitamin_d",
amount: 12.5,
unit: "ug",
source: "APPLE_HEALTH",
},
{
userId: other.id,
day: "2026-05-01",
nutrient: "water",
amount: 9999,
unit: "ml",
source: "MANUAL",
},
],
});
const { GET } = await import("@/app/api/export/full-backup/route");
const res = await GET(
new Request("http://localhost/api/export/full-backup", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
expect(res.status).toBe(200);
const parsed = JSON.parse(await res.text()) as BackupWithNutrients;
expect(parsed.userId).toBe(me.id);
const rows = parsed.nutrientDays ?? [];
expect(rows).toHaveLength(3);
// No cross-tenant leak.
expect(rows.some((r) => r.amount === 9999)).toBe(false);
// Both source rows for the same (day, nutrient) survive independently.
const water = rows.filter((r) => r.nutrient === "water");
expect(water).toHaveLength(2);
expect(water.map((r) => r.source).sort()).toEqual([
"APPLE_HEALTH",
"MANUAL",
]);
// The denormalised unit rides along, per the schema's own contract.
const vitaminD = rows.find((r) => r.nutrient === "vitamin_d");
expect(vitaminD).toMatchObject({ unit: "ug", amount: 12.5 });
});
it("emits an empty array, not a missing key, for an account with no nutrient rows", async () => {
await seedUserSession("nutrient-empty-user");
const { GET } = await import("@/app/api/export/full-backup/route");
const res = await GET(
new Request("http://localhost/api/export/full-backup", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
expect(res.status).toBe(200);
const parsed = JSON.parse(await res.text()) as BackupWithNutrients;
expect(parsed.nutrientDays).toEqual([]);
});
});