test(privacy): render assertions for the GDPR-required sections

Track the v1.4.40 W-PRIVACY bilingual rewrite of /privacy. The test
suite now pins:

- The bilingual H1 ("Datenschutzerklaerung / Privacy Policy") so a
  redesign cannot silently regress to a monolingual title that fails
  the German DPA review.
- Eleven sections each carry a `data-slot="privacy-section-de"` body
  plus a collapsible `data-slot="privacy-section-en"` translation,
  with `lang="de"` / `lang="en"` boundaries so screen readers switch
  pronunciation correctly.
- The HealthKit data-flow boundary (POST /api/measurements over
  TLS 1.3).
- The AI off-device transit guarantees: off by default, never
  includes raw HealthKit identifiers, named providers (Anthropic,
  OpenAI).
- Consent-receipt endpoint GET /api/account/consents with the 5-year
  retention window.
- Encryption disclosure: TLS 1.3, HSTS, certificate pinning + the DE
  "Zertifikat-Pinning" mirror.
- Server-location + operator framing (Germany, Hetzner, "operator").
- Retention defaults: 5-year measurements, 90-day audit, 30-day APNs.
- GDPR Art. 17 deletion route names "Konto loeschen", "Delete
  account", "User.delete", "onDelete: Cascade" verbatim.
- Operator contact mailto:mbombeck@gmail.com + GitHub-issue fallback,
  with the v1.4.20 PII rule still enforced (no "Marc-Andre" or
  free-text "Bombeck <name>" anywhere in the body).

Bumps POLICY_VERSION constant to 1.4.40 + LAST_UPDATED to 2026-05-18.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:47:42 +02:00
1 parent c79d91736c
commit 128d863763
1 file changed
+123 -26
+123 -26
View File
@@ -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(<PrivacyPage />);
}
const POLICY_VERSION = "1.4.40";
const LAST_UPDATED = "2026-05-18";
describe("<PrivacyPage>", () => {
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("<PrivacyPage>", () => {
}
});
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 `<details>` 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("<PrivacyPage>", () => {
"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("<PrivacyPage>", () => {
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("<PrivacyPage>", () => {
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);
});
});