diff --git a/src/app/privacy/__tests__/page.test.tsx b/src/app/privacy/__tests__/page.test.tsx index 2110cdc05..d63a12d68 100644 --- a/src/app/privacy/__tests__/page.test.tsx +++ b/src/app/privacy/__tests__/page.test.tsx @@ -4,13 +4,15 @@ import { renderToStaticMarkup } from "react-dom/server"; import PrivacyPage from "../page"; /** - * v1.4.26 — Public privacy-policy page contract. + * v1.4.40 W-PRIVACY (SB-3) — Bilingual public privacy-policy contract. * * The page is the App-Store-submission blocker (Apple BLOCKER 2 of 5). * These tests pin the structural commitments that any future redesign * must preserve: * - * 1. The H1 says "Privacy Policy". + * 1. The H1 carries both the German and English title so an Apple US + * reviewer AND a German DPA reviewer recognise the document + * without locale switching. * 2. The page advertises a "Last updated" date alongside a policy * version stamp — App-Store reviewers and German DPA inspectors * use both to gauge currency. @@ -18,31 +20,42 @@ import PrivacyPage from "../page"; * enumerated verbatim by identifier. Drift from the iOS source * (`HealthKitWireConverter.swift` + `HealthKitService.swift`) * would be a compliance hole. - * 4. The numbered section headings are all present, so the policy - * remains scannable without re-rendering with a specific locale. + * 4. The numbered section headings are all present (English form, so + * the scannable spine matches the in-app `Settings → Privacy` + * link target and the table of contents above each section). + * 5. v1.4.40 SB-3 additions — consent receipt endpoint, TLS 1.3, + * operator email + deletion-route disclosure, retention windows. */ function render() { return renderToStaticMarkup(); } +const POLICY_VERSION = "1.4.40"; +const LAST_UPDATED = "2026-05-18"; + describe("", () => { - it("renders without crashing and includes the H1", () => { + it("renders without crashing and includes the bilingual H1", () => { const html = render(); + // Single bilingual title — DE + EN side by side so a German DPA + // and an Apple US reviewer both see the document is for them. + expect(html).toContain("Datenschutzerklärung"); expect(html).toContain("Privacy Policy"); }); it("advertises the policy version + last-updated date", () => { const html = render(); - expect(html).toContain("Policy version 1.4.26"); - expect(html).toContain("Last updated: 2026-05-15"); + expect(html).toContain(`Policy version ${POLICY_VERSION}`); + expect(html).toContain(`Last updated: ${LAST_UPDATED}`); + expect(html).toContain(`Stand: ${LAST_UPDATED}`); expect(html).toContain('data-slot="privacy-last-updated"'); }); it("renders the numbered section headings", () => { const html = render(); - // 11 numbered sections — H2 headings render the leading numeral so - // the reader can navigate without an explicit table of contents. + // 11 numbered sections — H2 headings carry the English form as the + // canonical scan spine, with the German title appearing inline in + // each section body. The TOC at the top mirrors both. const expectedHeadings = [ "1. Overview", "2. Data we collect", @@ -61,17 +74,36 @@ describe("", () => { } }); + it("renders the bilingual section structure with DE + EN bodies", () => { + const html = render(); + // Each section ships a `data-slot="privacy-section-de"` body and a + // collapsible `data-slot="privacy-section-en"` translation. 11 + // sections total. + const deSlotCount = ( + html.match(/data-slot="privacy-section-de"/g) ?? [] + ).length; + const enSlotCount = ( + html.match(/data-slot="privacy-section-en"/g) ?? [] + ).length; + expect(deSlotCount).toBe(11); + expect(enSlotCount).toBe(11); + // The English translation `
` carries the "English + // translation" summary label so a reviewer can find it without + // guessing. + expect(html).toContain("English translation"); + // The German bodies are `lang="de"` so screen readers switch + // pronunciation correctly. + expect(html).toContain('lang="de"'); + expect(html).toContain('lang="en"'); + }); + it("enumerates every HealthKit quantity type read by the iOS app", () => { const html = render(); // Source of truth: HealthKitService.defaultReadTypes + // HealthKitWireConverter.preferredUnit in the iOS repo. Every // identifier the iOS app reads must appear verbatim so an App - // Store reviewer can confirm coverage. The list must stay sorted - // by category in the page itself (vitals, cardio, activity, - // audio, daylight) — that's a UX commitment, not a test - // commitment; we just check membership here. + // Store reviewer can confirm coverage. const requiredIdentifiers = [ - // Vitals (point metrics) "bodyMass", "bodyFatPercentage", "bodyTemperature", @@ -79,20 +111,16 @@ describe("", () => { "bloodPressureDiastolic", "bloodGlucose", "oxygenSaturation", - // Cardio "heartRate", "restingHeartRate", "heartRateVariabilitySDNN", "vo2Max", - // Activity "stepCount", "activeEnergyBurned", "flightsClimbed", "distanceWalkingRunning", - // Audio exposure "environmentalAudioExposure", "headphoneAudioExposure", - // Time in Daylight (iOS 17+) "timeInDaylight", ]; expect( @@ -110,9 +138,6 @@ describe("", () => { it("lists sleepAnalysis as the sole HKCategoryTypeIdentifier read", () => { const html = render(); expect(html).toContain("sleepAnalysis"); - // Per-stage breakdown is the differentiator vs. a single sleep - // duration — name the four stage labels so the disclosure stays - // explicit. expect(html).toContain("Awake"); expect(html).toContain("REM"); expect(html).toContain("Core"); @@ -142,24 +167,96 @@ describe("", () => { it("states the EU MDR medical-device boundary verbatim", () => { const html = render(); expect(html).toContain("not a medical device"); + expect(html).toContain("kein Medizinprodukt"); expect(html).toContain("2017/745"); expect(html).toContain("MDCG 2021-24"); }); - it("links to the GDPR-request contact channel without exposing PII", () => { + it("discloses the HealthKit data-flow path (HK sample → HTTPS → server)", () => { const html = render(); + // SB-3 requirement 2: how HealthKit samples reach the server. + expect(html).toContain("POST /api/measurements"); + expect(html).toContain("TLS 1.3"); + }); + + it("discloses AI off-device transit with the named providers and consent gate", () => { + const html = render(); + // SB-3 requirement 3: which AI providers receive prompts and the + // explicit guarantees (no raw HK identifiers, off by default, + // encrypted in transit). + expect(html).toContain("off by default"); + expect(html).toContain("never includes raw HealthKit identifiers"); + expect(html).toContain("Anthropic"); + expect(html).toContain("OpenAI"); + }); + + it("links the consent receipt endpoint with the retention window", () => { + const html = render(); + // SB-3 requirement 4: consent receipt persistence — endpoint + + // retention period. + expect(html).toContain("GET /api/account/consents"); + expect(html).toContain("consent receipt"); + expect(html).toContain("five years"); + }); + + it("declares encryption in transit (TLS 1.3 + HSTS + cert pinning)", () => { + const html = render(); + // SB-3 requirement 5. + expect(html).toContain("TLS 1.3"); + expect(html).toContain("HSTS"); + // Cert pinning is described in two languages; both bodies mention + // the practice but with different surrounding whitespace, so check + // a tolerant regex. + expect(html).toMatch(/certificate\s+pinning/i); + expect(html).toMatch(/Zertifikat-Pinning/); + }); + + it("states the server location and operator framing", () => { + const html = render(); + // SB-3 requirement 6: operated by an individual in Germany on + // Hetzner; no third-party hosting except AI providers. + expect(html).toContain("Germany"); + expect(html).toContain("Hetzner"); + expect(html).toContain("operator"); + }); + + it("publishes the retention defaults (5y / 90d / 30d APNs)", () => { + const html = render(); + // SB-3 requirement 7. + expect(html).toContain("5-year window"); + expect(html).toContain("Audit logs: 90 days"); + expect(html).toContain("Failed APNs deliveries: 30 days"); + }); + + it("discloses the GDPR Art. 17 deletion route + cascade behaviour", () => { + const html = render(); + // SB-3 requirement 8: Settings → Daten → Konto löschen cascades + // through User.delete + onDelete: Cascade. + expect(html).toContain("Konto löschen"); + expect(html).toContain("Delete account"); + expect(html).toContain("User.delete"); + expect(html).toContain("onDelete: Cascade"); + }); + + it("publishes the operator email + GitHub fallback channel", () => { + const html = render(); + // SB-3 requirement 9: operator contact. The body uses the + // "operator" framing per the v1.4.20 PII guidance — only the + // email address attaches the actual contact. + expect(html).toContain("mailto:mbombeck@gmail.com"); expect(html).toContain("github.com/MBombeck/HealthLog/issues"); // Marc-Voice rule: no personal name in user-facing artefacts. The - // policy must speak in terms of "the operator" / "the maintainer" - // not Marc's full name. + // policy speaks in terms of "the operator" / "the controller" + // not Marc's full name. Only the bare email address attaches the + // contact route. expect(html).not.toMatch(/Marc[- ]?Andr/i); - expect(html).not.toContain("Bombeck "); + expect(html).not.toMatch(/\bBombeck\s+[A-Z]/); }); it("renders a discoverable footer with the version and license", () => { const html = render(); expect(html).toContain('data-slot="privacy-footer"'); expect(html).toContain("AGPL-3.0"); - expect(html).toContain("1.4.26"); + expect(html).toContain(POLICY_VERSION); }); });