From 690c4aeb5aa1a205fecd831deb12495db3c0b900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:26:26 +0200 Subject: [PATCH] test(01-14): add failing truthful cohort contracts - distinguish complete partial failed parked skipped and clean-zero outcomes - require bounded privacy-safe scalar serialization and retry codes --- src/lib/jobs/__tests__/job-outcome.test.ts | 195 +++++++++++++ .../integration-cohort-outcomes.test.ts | 260 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 src/lib/jobs/__tests__/job-outcome.test.ts create mode 100644 src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts diff --git a/src/lib/jobs/__tests__/job-outcome.test.ts b/src/lib/jobs/__tests__/job-outcome.test.ts new file mode 100644 index 000000000..9f0d79d65 --- /dev/null +++ b/src/lib/jobs/__tests__/job-outcome.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from "vitest"; + +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; + +type SerializableJobOutcome = { + ok: boolean; + reason_code?: string; + did?: Readonly>; +}; + +type JobOutcomeSubject = { + JOB_FACT_ALLOWLIST: ReadonlySet; + MAX_JOB_FACTS: number; + MAX_JOB_OUTCOME_BYTES: number; + serializeJobOutcome(outcome: JobOutcome): SerializableJobOutcome; +}; + +function loadSubject(): Promise { + return vi.importActual("@/lib/jobs/job-outcome"); +} + +const COHORT_FACT_KEYS = [ + "provider", + "outcome", + "total", + "users_synced", + "users_complete", + "users_partial", + "users_failed", + "users_parked", + "users_skipped", + "users_useful", + "users_clean_zero", + "users_retryable", + "downstream_failed", + "measurements_imported", +] as const; + +const FORBIDDEN_FACT_KEYS = [ + "user_id", + "userId", + "email", + "name", + "access_token", + "refresh_token", + "secret", + "url", + "raw_error", + "health_value", + "blood_glucose", +] as const; + +describe("bounded generic JobOutcome serialization", () => { + it("publishes a fixed allowlist for truthful cohort scalars", async () => { + const { JOB_FACT_ALLOWLIST } = await loadSubject(); + for (const key of COHORT_FACT_KEYS) { + expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(true); + } + for (const key of FORBIDDEN_FACT_KEYS) { + expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(false); + } + }); + + it.each(FORBIDDEN_FACT_KEYS)( + "rejects the private or unbounded fact key %s", + async (key) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => + serializeJobOutcome( + jobDone({ [key]: "private-value" } as Record), + ), + ).toThrow(/fact|allow|private|unsupported/i); + }, + ); + + it("rejects nested objects, arrays, nulls, non-finite numbers, and unsafe integers", async () => { + const { serializeJobOutcome } = await loadSubject(); + for (const unsafe of [ + { nested: true }, + ["user-a"], + null, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + -1, + ]) { + expect(() => + serializeJobOutcome( + jobDone({ total: unsafe } as unknown as Record), + ), + ).toThrow(/scalar|finite|integer|range|unsupported/i); + } + }); + + it.each([ + "patient@example.test", + "https://provider.test/capability", + "token=secret", + "raw upstream stack trace", + "blood glucose 411", + "x".repeat(65), + ])("rejects an unbounded or private string fact: %s", async (value) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => serializeJobOutcome(jobDone({ provider: value }))).toThrow( + /code|string|private|length|unsupported/i, + ); + }); + + it("rejects too many facts and enforces a small serialized byte ceiling", async () => { + const { + JOB_FACT_ALLOWLIST, + MAX_JOB_FACTS, + MAX_JOB_OUTCOME_BYTES, + serializeJobOutcome, + } = await loadSubject(); + expect(MAX_JOB_FACTS).toBeGreaterThanOrEqual(COHORT_FACT_KEYS.length); + expect(MAX_JOB_FACTS).toBeLessThanOrEqual(32); + expect(MAX_JOB_OUTCOME_BYTES).toBeLessThanOrEqual(2_048); + + const allowed = [...JOB_FACT_ALLOWLIST]; + const tooMany = Object.fromEntries( + Array.from({ length: MAX_JOB_FACTS + 1 }, (_, index) => [ + allowed[index] ?? `overflow_${index}`, + index, + ]), + ); + expect(() => serializeJobOutcome(jobDone(tooMany))).toThrow( + /fact|count|limit|allow/i, + ); + }); + + it("distinguishes useful work from a successful clean zero", async () => { + const { serializeJobOutcome } = await loadSubject(); + const useful = serializeJobOutcome( + jobDone({ outcome: "useful", total: 1, measurements_imported: 4 }), + ); + const cleanZero = serializeJobOutcome( + jobDone({ outcome: "clean_zero", total: 1, measurements_imported: 0 }), + ); + + expect(useful.did?.outcome).toBe("useful"); + expect(cleanZero.did?.outcome).toBe("clean_zero"); + expect(useful).not.toEqual(cleanZero); + }); + + it("emits only stable failure codes and never serializes raw causes", async () => { + const { serializeJobOutcome } = await loadSubject(); + const privateText = + "patient@example.test https://provider.test?token=secret glucose=411"; + const serialized = serializeJobOutcome( + jobFailed("provider_cohort_failed", new Error(privateText), { + outcome: "clean_zero", + total: 0, + }), + ); + + expect(serialized).toEqual({ + ok: false, + reason_code: "provider_cohort_failed", + did: { outcome: "clean_zero", total: 0 }, + }); + expect(JSON.stringify(serialized)).not.toContain(privateText); + expect(serialized).not.toHaveProperty("cause"); + }); + + it.each([ + "raw upstream error", + "https://provider.test/failure", + "failed_for_user_patient@example.test", + "token=secret", + "x".repeat(65), + ])("rejects an unstable or private failure reason: %s", async (reason) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => serializeJobOutcome(jobFailed(reason))).toThrow( + /reason|code|private|length/i, + ); + }); + + it("keeps a retryable queue failure failed so runJob will retry it", async () => { + const { serializeJobOutcome } = await loadSubject(); + const serialized = serializeJobOutcome( + jobFailed("provider_cohort_retry", new Error("socket reset"), { + outcome: "clean_zero", + users_retryable: 1, + }), + ); + + expect(serialized.ok).toBe(false); + expect(serialized.reason_code).toBe("provider_cohort_retry"); + expect(serialized.did).toEqual({ + outcome: "clean_zero", + users_retryable: 1, + }); + }); +}); diff --git a/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts new file mode 100644 index 000000000..644c43133 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts @@ -0,0 +1,260 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +type Provider = "google_health" | "withings"; +type UserStatus = "complete" | "partial" | "failed" | "parked" | "skipped"; + +type UserVerdict = { + status: UserStatus; + imported: number; + downstreamFailed?: boolean; + retryable?: boolean; + reasonCode?: string; +}; + +type CohortFacts = { + provider: Provider; + outcome: "useful" | "clean_zero"; + total: number; + users_synced: number; + users_complete: number; + users_partial: number; + users_failed: number; + users_parked: number; + users_skipped: number; + users_useful: number; + users_clean_zero: number; + users_retryable: number; + downstream_failed: number; + measurements_imported: number; +}; + +type CohortSubject = { + foldIntegrationCohortOutcomes(input: { + provider: Provider; + verdicts: readonly UserVerdict[]; + }): { ok: true; did: CohortFacts }; +}; + +function loadSubject(): Promise { + return vi.importActual("@/lib/jobs/reminder/poll-cohort"); +} + +const googleHandlerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/reminder/google-health-sync.ts"), + "utf8", +); +const googleSyncSource = readFileSync( + join(process.cwd(), "src/lib/google-health/sync.ts"), + "utf8", +); +const withingsHandlerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/reminder/withings-sync.ts"), + "utf8", +); + +describe("provider handlers use the truthful verdict fold", () => { + it("does not discard Google Health's resolved failed verdict", () => { + expect(googleSyncSource).not.toMatch( + /syncUserGoogleHealth\(userId\)\.then\(\(r\)\s*=>\s*r\.imported\)/, + ); + expect(googleHandlerSource).toMatch(/foldIntegrationCohortOutcomes/); + }); + + it("does not count every resolved Withings call as synced unconditionally", () => { + expect(withingsHandlerSource).toMatch(/foldIntegrationCohortOutcomes/); + expect(withingsHandlerSource).not.toMatch( + /(?:syncUserMeasurements|syncUserActivity|syncUserSleep|syncUserEcg)[\s\S]{0,180}usersSynced\+\+/, + ); + }); +}); + +describe.each(["google_health", "withings"] as const)( + "%s truthful integration cohort outcomes", + (provider) => { + it("classifies complete, partial, failed, parked, and skipped users separately", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [ + { status: "complete", imported: 3 }, + { + status: "partial", + imported: 2, + downstreamFailed: true, + retryable: true, + reasonCode: "rollup_failed", + }, + { + status: "failed", + imported: 0, + retryable: true, + reasonCode: "provider_unavailable", + }, + { + status: "parked", + imported: 0, + reasonCode: "reauth_required", + }, + { status: "skipped", imported: 0, reasonCode: "not_configured" }, + ], + }); + + expect(outcome).toEqual({ + ok: true, + did: { + provider, + outcome: "useful", + total: 5, + users_synced: 1, + users_complete: 1, + users_partial: 1, + users_failed: 1, + users_parked: 1, + users_skipped: 1, + users_useful: 2, + users_clean_zero: 0, + users_retryable: 2, + downstream_failed: 1, + measurements_imported: 5, + }, + }); + expect( + outcome.did.users_complete + + outcome.did.users_partial + + outcome.did.users_failed + + outcome.did.users_parked + + outcome.did.users_skipped, + ).toBe(outcome.did.total); + }); + + it("counts a fully successful zero-write user as clean zero, not useful work", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [{ status: "complete", imported: 0 }], + }); + + expect(outcome.did).toMatchObject({ + provider, + outcome: "clean_zero", + total: 1, + users_synced: 1, + users_complete: 1, + users_useful: 0, + users_clean_zero: 1, + measurements_imported: 0, + }); + }); + + it.each([ + ["failed", true], + ["parked", false], + ["skipped", false], + ] as const)( + "never counts a %s user as synced", + async (status, retryable) => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [{ status, imported: 0, retryable }], + }); + + expect(outcome.did.users_synced).toBe(0); + expect(outcome.did[`users_${status}`]).toBe(1); + expect(outcome.did.users_retryable).toBe(retryable ? 1 : 0); + }, + ); + + it("marks raw writes plus a rollup/downstream failure as partial and retryable", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [ + { + status: "partial", + imported: 7, + downstreamFailed: true, + retryable: true, + reasonCode: "rollup_failed", + }, + ], + }); + + expect(outcome.did).toMatchObject({ + outcome: "useful", + users_synced: 0, + users_partial: 1, + users_retryable: 1, + downstream_failed: 1, + measurements_imported: 7, + }); + }); + + it("never emits per-user identifiers, PII, secrets, URLs, raw errors, or health values", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const privateVerdict = { + status: "failed", + imported: 0, + retryable: false, + reasonCode: "provider_failed", + userId: "user-private-123", + email: "patient@example.test", + accessToken: "secret-token", + url: "https://provider.test/capability?token=secret", + rawError: new Error("blood glucose=411"), + healthValue: 411, + } as UserVerdict; + + const serialized = JSON.stringify( + foldIntegrationCohortOutcomes({ + provider, + verdicts: [privateVerdict], + }), + ); + expect(serialized).not.toMatch( + /user-private|patient@|secret-token|provider\.test|blood glucose|411|rawError|healthValue/i, + ); + }); + + it("folds a high-volume cohort into one fixed bounded scalar payload", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const verdicts = Array.from({ length: 10_000 }, (_, index) => ({ + status: index % 2 === 0 ? ("complete" as const) : ("skipped" as const), + imported: index % 2 === 0 ? 1 : 0, + })); + + const outcome = foldIntegrationCohortOutcomes({ provider, verdicts }); + expect(Object.keys(outcome.did).sort()).toEqual( + [ + "downstream_failed", + "measurements_imported", + "outcome", + "provider", + "total", + "users_clean_zero", + "users_complete", + "users_failed", + "users_parked", + "users_partial", + "users_retryable", + "users_skipped", + "users_synced", + "users_useful", + ].sort(), + ); + expect( + Object.values(outcome.did).every((value) => { + return ( + typeof value === "string" || + (typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0) + ); + }), + ).toBe(true); + expect(JSON.stringify(outcome).length).toBeLessThan(1_024); + }); + }, +);