mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
c79d91736c
commit
128d863763
1 file changed
+123
-26
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user