mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
commit
b1e36d9fb0
51 files changed
+2066
-228
No files matched your search
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Structural guard for two Coach streaming-lifecycle invariants.
|
||||
*
|
||||
* These are STRUCTURAL assertions over the source text, not behavioural ones.
|
||||
* The repo ships no `@testing-library/react` / `renderHook`, so an unmount
|
||||
* cleanup and a tray-switch callback cannot be driven behaviourally here. A
|
||||
* structural guard is weaker than a behavioural test — it proves the code is
|
||||
* present, not that it works — but both defects were *deletions of a call*,
|
||||
* which is exactly what a structural guard does catch. Matches the existing
|
||||
* `*-guard.test.ts` precedent in this directory.
|
||||
*
|
||||
* Invariant 1 — `useSendCoachMessage` aborts its in-flight reader on unmount.
|
||||
* Without it, the full-page `/coach` surface (which passes no `registerReset`,
|
||||
* so the reset effect early-returns) left the SSE reader loop alive on a
|
||||
* route change: the provider kept generating and billing, and trailing
|
||||
* setState fired on an unmounted component. The drawer worked around this via
|
||||
* `handleOpenChange`; route-change unmount had no exit at all.
|
||||
*
|
||||
* Invariant 2 — switching conversations resets the send state. Without it the
|
||||
* previous thread's last assistant/error bubble rendered appended to the newly
|
||||
* selected thread until the next send, because `streamingActive` stays true
|
||||
* while that messageId is absent from the new thread's messages.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
function read(relative: string): string {
|
||||
return readFileSync(join(ROOT, relative), "utf8");
|
||||
}
|
||||
|
||||
describe("Coach streaming lifecycle", () => {
|
||||
it("useSendCoachMessage registers an unmount cleanup that aborts the reader", () => {
|
||||
const source = read("components/insights/coach-panel/use-coach.ts");
|
||||
|
||||
// An effect with an empty dependency array whose cleanup aborts.
|
||||
const hasUnmountAbort =
|
||||
/useEffect\([\s\S]*?return\s*\(\)\s*=>\s*\{[^}]*abortRef\.current\?\.abort\(\)/.test(
|
||||
source,
|
||||
);
|
||||
expect(
|
||||
hasUnmountAbort,
|
||||
"use-coach.ts must abort abortRef in an unmount cleanup effect",
|
||||
).toBe(true);
|
||||
|
||||
// And it must be mount-scoped — a dependency-carrying effect would abort
|
||||
// mid-stream on an unrelated re-render.
|
||||
const effectWithDeps =
|
||||
/return\s*\(\)\s*=>\s*\{\s*abortRef\.current\?\.abort\(\);\s*\};\s*\},\s*\[\]\s*\)/.test(
|
||||
source,
|
||||
);
|
||||
expect(
|
||||
effectWithDeps,
|
||||
"the unmount abort effect must have an empty dependency array",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("the conversation-switch handler resets the send state", () => {
|
||||
const source = read(
|
||||
"components/insights/coach-panel/coach-conversation.tsx",
|
||||
);
|
||||
|
||||
// Isolate the HistoryRail onSelect callback body.
|
||||
const match = source.match(
|
||||
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
|
||||
);
|
||||
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
|
||||
const onSelectBody = match![0];
|
||||
|
||||
expect(
|
||||
onSelectBody.includes("send.reset()"),
|
||||
"selecting another conversation must call send.reset() so the previous " +
|
||||
"thread's trailing stream state does not bleed into the new thread",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("handleNewChat still resets too — the two paths must not diverge again", () => {
|
||||
const source = read(
|
||||
"components/insights/coach-panel/coach-conversation.tsx",
|
||||
);
|
||||
expect(source).toContain("send.reset()");
|
||||
// Both call sites present: new-chat and conversation-switch.
|
||||
const resetCalls = source.match(/send\.reset\(\)/g) ?? [];
|
||||
expect(resetCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Security headers on the proxy's EARLY EXITS.
|
||||
*
|
||||
* The full header block sits at the bottom of `proxy()`, after the pass-through
|
||||
* `NextResponse.next()`. Seven exits return before it — the worker-only 503,
|
||||
* two legacy 301s, the demo-mode 403, and the login / onboarding / MFA
|
||||
* redirects — and used to carry no HSTS, no framing refusal and no nosniff at
|
||||
* all.
|
||||
*
|
||||
* The exposure was one hop (a redirect has no rendered body, and the follow-up
|
||||
* navigation re-enters the proxy and does get the full set), but HSTS is worth
|
||||
* the most on exactly that first hop over a hostile network. These assertions
|
||||
* exist so a future exit added above the header block cannot silently rejoin
|
||||
* the unprotected set.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const shouldRunWeb = vi.fn(() => true);
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => shouldRunWeb(),
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
init: { cookies?: Record<string, string>; method?: string } = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(init.cookies ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
method: init.method ?? "GET",
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
const ORIGINAL_DEMO = process.env.DEMO_MODE;
|
||||
|
||||
beforeEach(() => {
|
||||
shouldRunWeb.mockReturnValue(true);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
if (ORIGINAL_DEMO === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = ORIGINAL_DEMO;
|
||||
});
|
||||
|
||||
/** The transport subset every exit must carry. */
|
||||
function expectBaselineHeaders(res: Response) {
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe(
|
||||
"strict-origin-when-cross-origin",
|
||||
);
|
||||
expect(res.headers.get("Permissions-Policy")).toContain("camera=()");
|
||||
expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin");
|
||||
expect(res.headers.get("X-Permitted-Cross-Domain-Policies")).toBe("none");
|
||||
expect(res.headers.get("Strict-Transport-Security")).toContain(
|
||||
"max-age=31536000",
|
||||
);
|
||||
}
|
||||
|
||||
describe("proxy early exits carry the baseline security headers", () => {
|
||||
it("worker-only 503", () => {
|
||||
shouldRunWeb.mockReturnValue(false);
|
||||
const res = proxy(makeRequest("/dashboard"));
|
||||
expect(res.status).toBe(503);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("legacy 301 redirect", () => {
|
||||
const res = proxy(makeRequest("/stimmung"));
|
||||
expect(res.status).toBe(301);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("unauthenticated page redirect to /auth/login", () => {
|
||||
const res = proxy(makeRequest("/dashboard"));
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("onboarding redirect", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/", {
|
||||
cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" },
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("demo-mode 403 on a blocked mutation", () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
const res = proxy(
|
||||
makeRequest("/api/measurements", {
|
||||
method: "POST",
|
||||
cookies: { healthlog_session: "sess-1" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("omits HSTS in development, like the main header block", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const res = proxy(makeRequest("/stimmung"));
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.get("Strict-Transport-Security")).toBeNull();
|
||||
// The non-transport-dependent headers still attach.
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user