diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 843e449e..fd2e1d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -353,6 +353,48 @@ jobs: # dragging a flaky suite in here would intermittently block every PR in the repo. run: cd app && npm run test:practices -- --ci --testTimeout=20000 + - name: Navigation layout tests + # DEBUG-562. Co-located under src/core/navigation/, which no existing pattern + # reaches: `test:unit` is --testPathPattern=unit and `test:integration` is + # =integration, and neither matches this directory. Two suites ran on NOBODY'S + # pull request until this step existed — including the one that pins the tab + # bar's top edge clear of the crisis FAB's touch band, which is the mechanical + # form of DEBUG-562's AC2 ruling and the only thing that catches drift on + # EITHER side of that collision. + # + # Wired by PATTERN, never by renaming files toward one — scripts/ + # ci-uncovered-tests.json records coverage-by-filename as the defect it exists + # to remove. Scoped to the directory rather than the file for the same reason. + # + # `CleanTabNavigator.accessibility.test.tsx` in the same directory is already + # reached by the Accessibility job (its name carries `accessibility`); running + # it twice is cheap and beats keying this step on a filename. + run: cd app && npm run test:navigation -- --ci --testTimeout=20000 + + - name: Analytics boundary tests + # INFRA-542. Co-located under src/core/analytics/, which no existing pattern + # reaches: the two files here carrying `privacy` in their NAME are gated by + # test:privacy, and the rest of the directory by nothing — coverage by + # filename, the defect scripts/ci-uncovered-tests.json exists to remove. + # + # This directory is the app's ONLY third-party egress path. Its failure mode + # is silent in both directions: trackEvent early-returns without a client, and + # PHIFilter drops a violating payload with a log rather than a throw. A pin + # here that runs on nobody's PR is worth approximately nothing. + # + # Wired by PATTERN, never by renaming files toward one (DEBUG-468 precedent). + run: cd app && npm run test:analytics -- --ci --testTimeout=20000 + + - name: Classical library tests + # FEAT-567. passagesContent.test.ts holds the passage SCHEMA checks — + # author/work enum membership, principle match, duplicate ids, required + # fields — and matched no CI pattern, so a malformed passage could land + # unnoticed. The provenance suite that checks TRANSLATORS already runs + # (it lives under app/__tests__/unit/); this is the other half. + # + # Wired by PATTERN, never by renaming files toward one (DEBUG-468 precedent). + run: cd app && npm run test:library -- --ci --testTimeout=20000 + - name: Upload coverage if: always() uses: actions/upload-artifact@v6 @@ -362,9 +404,11 @@ jobs: retention-days: 14 # Security + compliance — encryption, HIPAA-adjacent checks, dependency - # audit. npm audit threshold is `high` (not `moderate`) until the - # 5-month-old lockfile is refreshed — see audit-roadmap.md Phase 0/3 for - # the planned dependency refresh. + # audit. npm audit threshold is `moderate`, tightened from `high` by + # MAINT-182 once the explicit allowlist landed (this header claimed `high` + # until INFRA-576; .audit-ci.json has said `"moderate": true` since + # MAINT-182). The 5-month-old lockfile refresh is still outstanding — see + # audit-roadmap.md Phase 0/3. security: name: Security + compliance runs-on: ubuntu-latest @@ -467,6 +511,15 @@ jobs: # JS view hierarchy, so while one is open the root crisis button is not # on screen at all — a zero-988-affordance render state. # + # INFRA-571 added rule 4, which is a different SHAPE: a CALL SITE in + # app/src handing presentation to a third-party component that never + # appears in our tree (Sentry's feedback widget, expo-sharing's share + # sheet, the StoreKit purchase sheet). The lives in node_modules, + # so rules 1-3 cannot see it, and INFRA-531's crisis-constant-import rule + # matches nothing because nothing on those paths imports from + # features/crisis/. Its allowlist is keyed :: and fails on a + # stale entry the same way rule 2 does. + # # This exists because a Protected Paths row could not catch it. The four # sites DEBUG-406 audited live in src/core/components/ and # src/features/insights/, neither of which is on that list and neither of @@ -520,14 +573,13 @@ jobs: - name: npm audit # MAINT-182: switched from raw `npm audit --audit-level=high` to # `audit-ci` with an explicit allowlist at app/.audit-ci.json. - # Three transitive CVEs in @expo/config-plugins@56.0.8 are - # allowlisted (GHSA-ph9p-34f9-6g65 tmp path traversal, - # GHSA-6vfc-qv3f-vr6c markdown-it ReDoS, GHSA-w5hq-g745-h8pq - # uuid bounds check) — all build-time only, no patched stable - # version available. Threshold is `moderate` (tightened from the - # prior `high`) since the allowlist gives precise control. - # Drop the GHSAs from .audit-ci.json when @expo/config-plugins - # >=56.0.9 ships as stable. + # Threshold is `moderate` (tightened from the prior `high`) since + # the allowlist gives precise control. Every allowlisted GHSA + # carries its own review note and drop-condition in that file's + # `$comment` — read there, not here; this list rotted twice. + # INFRA-576 retired GHSA-ph9p-34f9-6g65 (tmp), which this comment + # had attributed to @expo/config-plugins: the tree's only tmp + # arrives via patch-package and is already patched. # # Historical context (kept for archaeology): the W3 paydown's # `npm audit fix` cleared 11 high+critical vulns (xmldom, diff --git a/.github/workflows/main-branch-health.yml b/.github/workflows/main-branch-health.yml index 8d04874b..e8484eb6 100644 --- a/.github/workflows/main-branch-health.yml +++ b/.github/workflows/main-branch-health.yml @@ -44,12 +44,15 @@ name: Main Branch Health # exactly this gate, and this probe covers exactly it. # # WHAT THIS PROBE DOES NOT COVER — say it here so no future reader assumes -# coverage that is not present. `main`'s .github/workflows/ is separately stale -# (retired workflows still active there, an older NODE_VERSION, and a -# job-list that differs from development's). A pull_request into `main` runs -# MAIN's ci.yml, not this repo's current one, so a green probe does NOT by -# itself prove a hotfix PR will pass. Those gaps are tracked as INFRA-458 and -# INFRA-459. +# coverage that is not present. A pull_request into `main` runs MAIN's ci.yml, +# not this repo's current one, so a green probe does NOT by itself prove a +# hotfix PR will pass. `main` carries whatever workflows the last release +# shipped, so any workflow change merged to `development` since then is absent +# there. That drift is the normal state between releases, not an incident, and +# it RE-OPENS after every release — so read +# `git show origin/main:.github/workflows/ci.yml` rather than this repo's copy +# before reasoning about what gates a hotfix. INFRA-458 closed the 2026-07-13 +# instance, in which `main` lacked the safety-privacy job outright for six weeks. # # THIS PROBE STILL CANNOT OBSERVE ITS OWN SILENCE, but something else now does: # INFRA-460 added a liveness cross-check as a step in ci.yml's `ci-pass` job, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfed880c..59f05e2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,10 @@ jobs: build-and-submit: name: EAS build + auto-submit to TestFlight runs-on: ubuntu-latest - timeout-minutes: 15 + # Waits for the build (see the --no-wait note below), so this must cover + # EAS queue + build + submit. Observed build alone is ~7 min; queue is the + # variable. 15 min was the old budget for a fire-and-forget exit. + timeout-minutes: 60 steps: - uses: actions/checkout@v5 @@ -114,10 +117,17 @@ jobs: # accepts only --auto-submit / --auto-submit-with-profile, and the # invented --submit-profile failed every real release from INFRA-146 # until DEBUG-336 (dry runs skip this step, so it went unnoticed). - # --no-wait lets this workflow exit quickly while EAS does - # the 20-30 min build + submit asynchronously. Failures surface in - # the EAS dashboard, in App Store Connect, and (for EAS-side errors) - # in this job's logs. + # This step deliberately does NOT pass --no-wait. It used to, and that + # made a red build report green: `eas build --no-wait` exits 0 the moment + # EAS *accepts* the job, so the workflow's conclusion described the + # scheduling, not the build. v1.2.0 errored in `Install dependencies` on + # an expired GITHUB_TOKEN while this job reported success, and nothing on + # GitHub ever went red. Waiting costs wall-clock and buys a real verdict. + # + # What waiting still does NOT cover: Apple's own processing of the + # uploaded binary. That is asynchronous and reported only by email / App + # Store Connect — a successful upload is not an accepted build. ITMS-90683 + # rejected v1.2.0 minutes after a fully green submit. See the final step. # Skipped on a workflow_dispatch with dry_run=true (validation only). - name: Build + auto-submit to TestFlight if: ${{ !inputs.dry_run }} @@ -127,5 +137,15 @@ jobs: --platform ios \ --profile production \ --auto-submit \ - --non-interactive \ - --no-wait + --non-interactive + + # A green job above means the binary built and uploaded. It does NOT mean + # the build is in TestFlight — Apple can still reject it during + # processing, and only tells you by email. + - name: Report what green does and does not mean + if: ${{ !inputs.dry_run && success() }} + run: | + echo "Build succeeded and the binary was uploaded to App Store Connect." + echo "This is NOT yet a TestFlight build: Apple processing can still reject it" + echo "(e.g. ITMS-90683), and reports only by email / App Store Connect." + echo "Verify: https://appstoreconnect.apple.com/apps/6777579207/testflight/ios" diff --git a/README.md b/README.md index 632f1b73..46b072ca 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Honest examination for learning and growth (not self-judgment): ## ✨ **Core Features** ### **Philosophical Accuracy** -- **Classical Sources**: All content grounded in Marcus Aurelius (*Meditations*), Epictetus (*Enchiridion*, *Discourses*), Seneca (*Letters from a Stoic*, *On Anger*) +- **Classical Sources**: All content grounded in Marcus Aurelius (*Meditations*), Epictetus (*Enchiridion*), Seneca (*Moral Letters to Lucilius*, *On Anger*) - **Educational Approach**: Learn authentic Stoic Mindfulness V2 framework, not pop-Stoicism - **Virtue Ethics**: Track growth in Wisdom, Justice, Courage, Temperance - **Balanced Practice**: Self-compassion integrated to prevent harsh "stoicism" (emotional suppression) @@ -410,7 +410,7 @@ While Being is primarily an educational tool for Stoic Mindfulness philosophy an **Classical Stoic Texts** — public-domain translations, the only renderings shipped in the app: - Marcus Aurelius - *Meditations*, trans. George Long (1862; text as digitized in Project Gutenberg ebook #15877, *Thoughts of Marcus Aurelius Antoninus*) - Epictetus - *Enchiridion*, trans. Elizabeth Carter (1758) -- Seneca - *Letters from a Stoic* (*Epistulae Morales*), trans. Richard Mott Gummere (Loeb Classical Library, 1917–1925); *Of Anger*, *Of Peace of Mind* and the other dialogues, trans. Aubrey Stewart (*Minor Dialogues*, 1889); *On Benefits*, trans. Aubrey Stewart (1887) +- Seneca - *Moral Letters to Lucilius* (*Ad Lucilium Epistulae Morales*), trans. Richard Mott Gummere (Loeb Classical Library, 1917–1925; public domain by pre-1929 US publication; text as digitized on Wikisource from Internet Archive scans `adluciliumepistu01seneuoft` and `adluciliumepistu03seneuoft`); *Of Anger*, *Of Peace of Mind* and the other dialogues, trans. Aubrey Stewart (*Minor Dialogues*, 1889); *On Benefits*, trans. Aubrey Stewart (1887) Gregory Hays (2002), Robin Hard (2011/2014) and Nicholas White (1983) are excellent modern translations and worth reading, but they are **in copyright** and no rendering from them appears anywhere in this app. That is enforced mechanically by `app/src/features/learn/__tests__/moduleClassicalQuotes.test.ts` and its sibling guards. diff --git a/app/.audit-ci.json b/app/.audit-ci.json index 22217b97..782d7071 100644 --- a/app/.audit-ci.json +++ b/app/.audit-ci.json @@ -3,14 +3,14 @@ "moderate": true, "report-type": "summary", "allowlist": [ - "GHSA-ph9p-34f9-6g65", "GHSA-6vfc-qv3f-vr6c", "GHSA-w5hq-g745-h8pq", "GHSA-6v5v-wf23-fmfq", "GHSA-22p9-wv53-3rq4", "GHSA-v245-v573-v5vm", "GHSA-5p2g-fcmc-qvqq", - "GHSA-w3rx-r6r6-pgpr" + "GHSA-w3rx-r6r6-pgpr", + "GHSA-vcc3-ghjq-m6fr" ], - "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY \u2014 transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix \u2014 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade \u2014 and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test \u2014 no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm \u2014 it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request \u2014 Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON \u2014 the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 \u2014 npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously \u2014 the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` \u2014 never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition \u2014 audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN \u2014 this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close." + "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY \u2014 transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix \u2014 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade \u2014 and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test \u2014 no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm \u2014 it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request \u2014 Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON \u2014 the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 \u2014 npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously \u2014 the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` \u2014 never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition \u2014 audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN \u2014 this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close. --- DEBUG-573 (2026-08-31) GHSA-vcc3-ghjq-m6fr (decode-uri-component, \"Denial of service via exponential decoding of malformed percent-encoded input\", MODERATE; range <=0.4.2, firstPatched 0.5.0) published 2026-08-31T22:10:20Z and blocked every PR in the repo with zero repo changes - found on a branch whose only diff was one word in a postinstall script, and reproduced against the untouched dependency tree. ALLOWLISTED, and for a NEW reason this file has not recorded before: the fix EXISTS and is forceable via overrides, but taking it BREAKS THE CONSUMER. decode-uri-component@0.5.0 is ESM-ONLY - package.json declares \"type\":\"module\", its exports map has NO require condition, and the tarball ships a single ESM index.js with `export default`. Its only consumer here, query-string@7.1.3, is CommonJS and does `const decodeComponent = require('decode-uri-component')` at index.js:3, so an override resolves the call to a module namespace object rather than a function. 0.4.1 is ALSO \"type\":\"module\" and still inside the <=0.4.2 range, so there is NO CJS-compatible fixed version at any point on the line. Upgrading the parent does not help either: @react-navigation/core@7.21.13 (latest at this change) still depends on query-string ^7.1.3. REACHABILITY IS STATED HONESTLY AND DIFFERS FROM EVERY OTHER ENTRY ABOVE - do not read this as another 'no attacker-controlled-input path' case. There IS an input path: query-string parses deep links via @react-navigation linking, and Being handles deep links (app/.maestro/deeplink-consent-gate.yaml, daily-loop-deeplink.yaml), so a crafted link's query string reaches the decoder. The accepted risk is bounded rather than absent: impact is a local DoS (the app hanging on the user's own device, recoverable by force-quit), it requires the user to open a hostile link, and there is no data exposure. That risk was accepted specifically BECAUSE the alternative is worse - forcing the ESM version would have made this gate green while breaking deep-link parsing, which routes through CombinedLegalGateScreen, the screen hosting the pre-consent 988 footer (INFRA-416). A green security gate over a broken crisis path is not a trade this repo takes. Drop GHSA-vcc3 when ANY of: query-string ships a CJS-compatible line off decode-uri-component; @react-navigation moves to query-string >=8; or decode-uri-component backports the fix to a 0.2.x/0.3.x CJS release (watch `npm view decode-uri-component versions` - as of this change 0.5.0 is the only patched version and it is ESM). NOT verified and deliberately not attempted here: Metro's ESM/CJS interop may or may not paper over the require(); proving that needs a Release build plus a deep-link flow run, and an unproven interop assumption on the deep-link path is not a security fix. PATTERN: fifth advisory-side event in ~6 weeks to red-gate every PR on an unchanged tree, and the first where `npm view versions` shows a patch that must NOT be taken - check the shipped tarball's module format before reaching for an override, not just the version number. --- INFRA-576 (2026-09-02) TWO HIGH browserslist advisories plus one MODERATE @xmldom/xmldom advisory published 2026-09-01/09-02 and blocked every PR in the repo with zero repo changes - found by /b-batch --resume when MAINT-566 (a one-line placeholder copy change, PR #458) failed this gate, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 23be61e8. ALL THREE FIXED, NOTHING ALLOWLISTED - a same-major patch existed for every one, which is the standing rule's first check. GHSA-73wf-gq98-2v4g (uncaught crash / prototype write via untrusted browserslist-stats.json in normalizeStats) and GHSA-c83g-rgw3-j3cx (unbounded memory growth, no cache eviction, eventual OOM) are both browserslist <=4.28.6, firstPatched 4.28.7. The tree held a single deduped browserslist@4.28.2 and every requester is on the 4.x line - @babel/helper-compilation-targets ^4.24.0, @expo/metro-config ^4.25.0, core-js-compat ^4.28.1, update-browserslist-db peer >=4.21.0 - so an unkeyed override to ^4.28.7 satisfies all four and resolves 4.28.8. Unkeyed (the shell-quote/postcss shape) rather than version-keyed because there is one node on one major line; the caret lets the next patch flow rather than freezing at a version the next advisory re-hits, which is the brace-expansion lesson. GHSA-6gmq-8vp8-gcm6 (XML fragment injection via an invalid EntityReference.nodeName during requireWellFormed serialization) reports TWO applicable ranges with SEPARATE first-patched versions - >=0.7.0 <=0.8.14 -> 0.8.15, and >=0.9.0 <=0.9.11 -> 0.9.12 - and the tree held one node on each: @expo/plist@0.7.0 asks ^0.8.8 (0.8.13) and plist@3.1.1 asks ^0.9.10 (0.9.10). Version-keyed overrides+resolutions pin @xmldom/xmldom@^0.8.8 -> 0.8.15 and @xmldom/xmldom@^0.9.10 -> 0.9.12; each requested range admits its own patch, so neither line is dragged across a major. Note the 0.8 line does have a fix - reading only the advisory's headline range would have suggested allowlisting it. REMOVED GHSA-ph9p-34f9-6g65 (tmp path traversal, MAINT-182) from the allowlist. FEAT-313, INFRA-350 and INFRA-359 each declined to, on the recorded ground that its drop-condition was an @expo/config-plugins bump and therefore unrelated scope. That drop-condition was simply wrong: the advisory is against tmp, and the tree's only tmp is 0.2.7 via patch-package@8.0.1 - above the 0.2.6 first-patched, and reached through no @expo/config-plugins path at all. Same shape as INFRA-350 finding 'on the next ts-jest major' obsolete once ts-jest left the js-yaml tree. audit-ci had been printing 'Consider not allowlisting' for it; that hint is now clear. AC-mandated pass over the other eight allowlisted-but-still-vulnerable advisories found NO new same-major fix and changed none of them: markdown-it 10.0.0 -> firstPatched 14.2.0, linkify-it 2.2.0 -> 5.0.2, and uuid 7.0.3 (under xcode) -> 11.1.1 are all breaking major jumps; image-size still reports firstPatched NONE on every published version; and decode-uri-component 0.5.0 remains the ESM-only trap DEBUG-573 recorded. Every documented drop-condition still holds. Verified after the install that npx patch-package still resolves expo-modules-jsi@56.0.12 - a blanket `npm audit fix` would bump past that pin and break `npm ci`, never run one here - and that `npx expo export --platform ios` still bundles, since browserslist sits on the babel/metro path and ci.yml records that CI does not run Metro. PATTERN: sixth advisory-side event in ~7 weeks to red-gate every PR on an unchanged tree, and the first in that run where every finding had a clean same-major fix - the allowlist did not grow, it shrank." } diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json index 13112484..db92e76c 100644 --- a/app/.eslint-baseline.json +++ b/app/.eslint-baseline.json @@ -12,10 +12,12 @@ "src/core/components/subscription/SubscriptionStatusCard.tsx": 4, "src/core/config/__tests__/env.quick.test.ts": 1, "src/core/config/env.test.ts": 1, + "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx": 1, "src/core/navigation/CleanRootNavigator.tsx": 3, "src/core/navigation/CleanTabNavigator.tsx": 5, "src/core/navigation/__tests__/CleanTabNavigator.accessibility.test.tsx": 1, "src/core/navigation/__tests__/dailyLoopDeepLink.test.ts": 1, + "src/core/navigation/__tests__/tabBarLayout.test.ts": 1, "src/core/services/data-retention/DataRetentionService.ts": 1, "src/core/services/data-retention/__tests__/DataRetentionService.test.ts": 1, "src/core/services/logging/__tests__/RateLimiter.test.ts": 1, @@ -74,6 +76,7 @@ "src/core/services/supabase/__tests__/crisisTelemetryGuard.unit.test.ts": 1, "src/core/services/supabase/__tests__/crisisTelemetryKillWindow.unit.test.ts": 1, "src/core/services/supabase/__tests__/secureStoreSessionAdapter.unit.test.ts": 1, + "src/core/services/supabase/__tests__/sessionIdRotation.unit.test.ts": 1, "src/core/services/supabase/hooks/useCloudSync.ts": 3, "src/core/services/supabase/index.ts": 3, "src/core/stores/__tests__/consentChangelog.privacy.test.ts": 1, @@ -137,10 +140,12 @@ "src/features/crisis/components/__tests__/RootCrisisButton.test.tsx": 1, "src/features/crisis/constants/__tests__/crisisButtonGeometry.test.ts": 1, "src/features/crisis/screens/CrisisResourcesScreen.tsx": 2, + "src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx": 1, "src/features/crisis/screens/__tests__/CrisisResourcesScreen.reachability.test.tsx": 1, "src/features/crisis/screens/__tests__/CrisisResourcesScreen.test.tsx": 1, "src/features/crisis/services/__tests__/CrisisSecurityProtocol.test.ts": 1, "src/features/crisis/services/__tests__/crisisAlert.unit.test.ts": 1, + "src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts": 1, "src/features/crisis/services/__tests__/textCrisisDetection.parity.test.ts": 1, "src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts": 1, "src/features/crisis/services/types/__tests__/CrisisResources.test.ts": 1, @@ -161,7 +166,10 @@ "src/features/insights/screens/__tests__/WellnessTrendsDetailScreen.accessibility.test.tsx": 1, "src/features/insights/utils/__tests__/wellnessTrendData.test.ts": 1, "src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx": 1, + "src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts": 1, + "src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx": 1, "src/features/journal/services/__tests__/journalCrisisScan.unit.test.ts": 1, + "src/features/journal/services/__tests__/journalDateRange.unit.test.ts": 1, "src/features/journal/services/__tests__/journalEntryStore.unit.test.ts": 1, "src/features/journal/services/__tests__/journalErasure.privacy.test.ts": 1, "src/features/learn/__tests__/moduleClassicalQuotes.test.ts": 1, @@ -191,6 +199,7 @@ "src/features/practices/dailyloop/__tests__/DailyLoopStepScreen.breathReachability.test.tsx": 1, "src/features/practices/dailyloop/__tests__/DailyLoopStepScreen.stageNote.test.tsx": 1, "src/features/practices/dailyloop/__tests__/DailyLoopStepScreen.crisisSupportLineReachability.test.tsx": 1, + "src/features/practices/dailyloop/__tests__/DailyLoopStepScreen.virtueChipLayout.test.tsx": 1, "src/features/practices/dailyloop/__tests__/dailyLoopEngagement.test.ts": 1, "src/features/practices/dailyloop/__tests__/stageNotes.test.ts": 1, "src/features/practices/dailyloop/__tests__/tenseMode.test.ts": 1, @@ -217,5 +226,14 @@ "src/features/profile/screens/__tests__/PrivacyDataScreen.accessibility.test.tsx": 1, "src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx": 1, "src/core/theme/__tests__/black-call-sites.accessibility.test.ts": 1, - "src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1 + "src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1, + "src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1, + "src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts": 1, + "src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1, + "src/core/analytics/__tests__/PostHogProvider.networkSilence.privacy.test.ts": 1, + "src/core/analytics/__tests__/useAnalytics.consentGate.privacy.test.tsx": 1, + "src/core/analytics/__tests__/AppLifecycleTracker.test.tsx": 1, + "src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx": 1, + "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1, + "src/features/practices/shared/perf/__tests__/frameAccumulator.unit.test.ts": 1 } diff --git a/app/.maestro/breathing-fps-budget.yaml b/app/.maestro/breathing-fps-budget.yaml new file mode 100644 index 00000000..da9abe53 --- /dev/null +++ b/app/.maestro/breathing-fps-budget.yaml @@ -0,0 +1,85 @@ +appId: fyi.being.app +tags: + - perf-device-only +name: "UI frame delivery on the breathing path stays within the dropped-frame budget (device-only)" +# e2e-certifies: any +# INFRA-373 — perf-device-only: a simulator renders on the host Mac GPU, so a frame +# reading taken there measures the Mac. The axis is the device's display pipeline. +--- +# DEVICE-ONLY. Run against a real iPhone: +# npm run e2e:perf:breathing-fps # requires E2E_PERF_MAX_DROPPED_RATIO +# +# The `perf-device-only` tag keeps this out of `npm run e2e:safety`, whose discovery +# greps for a tag line that is exactly `- safety`. It is NOT a safety flow and must not +# be counted as one; INFRA-373 taught `e2e-safety.sh` to treat the tag as device-only +# for TARGET RESOLUTION only. +# +# WHAT THIS PINS. UI-thread frame delivery while `PracticeTimerScreen` is mounted — not +# `BreathingCircle` specifically. The same screen mounts `Timer` (re-rendering at 4 Hz) +# and the `usePracticeHaptics` cue chain. Read the result as "the breathing screen", not +# "the breathing animation". See BreathingFrameProbe.tsx. +# +# NO ARTIFACT ATTESTATION. Device runs cannot have it — `simctl get_app_container`, the +# otool/plutil shape checks and e2e-provenance.js are all simulator-container-bound. This +# flow names its target but does not vouch for the binary. Install the build you mean to +# measure, deliberately, with EXPO_PUBLIC_PERF_HUD=true. +# +# WHY THE ASSERTIONS LOOK PARANOID. The Technical Notes for INFRA-373 flag that +# `${maestro.copiedText}` coercion is unexercised in this repo and that a string/number +# comparison bug there produces a SILENTLY PASSING assertion — the exact failure class +# this item exists to eliminate. Three specific traps are guarded below: +# 1. An absent threshold. `"0.001" <= undefined` is false, but an empty string coerces: +# `Number('') === 0`, so a blank threshold would compare as zero and could pass a +# genuinely clean run while silently testing nothing. The threshold is REQUIRED and +# has NO default, and is validated as a number before use. +# 2. A probe that never sampled. `frames 0` still renders `ratio 0.00000`, which passes +# any budget. A zero-frame window must fail, not sail through. +# 3. Reading the wrong element, or an empty one. Each copied string is shape-checked +# against its expected label before any numeric comparison. + +# ---- 1. The threshold is required, and must be a number --------------------------- +# `typeof` rather than a bare reference: an undeclared identifier would raise a +# ReferenceError, which is also a failure, but this reports WHY. +- assertTrue: ${typeof E2E_PERF_MAX_DROPPED_RATIO !== 'undefined' && String(E2E_PERF_MAX_DROPPED_RATIO).trim().length > 0 && !isNaN(Number(E2E_PERF_MAX_DROPPED_RATIO))} + +- launchApp: + clearState: true + clearKeychain: true # SecureStore-backed consent persists across clearState (INFRA-179) +- runFlow: _legal-and-onboarding.yaml + +# Deep link rather than UI navigation: `visualMode` is not a link parameter and defaults +# to 'breathing', so the circle — and therefore the probe — mounts. `duration` and `title` +# are REQUIRED params of the PracticeTimer route (DEBUG-353). +- openLink: being://practice/probe?duration=60&title=Frame+Probe +- assertVisible: + id: "practice-timer-screen" + +# ---- 2. Wait out the probe's measurement window ------------------------------------ +# The probe closes a 10 s window from a JS-side timer, then renders. 25 s of headroom +# covers a cold first frame without masking a probe that never reports at all. +- extendedWaitUntil: + visible: + id: "practice-timer-screen-frame-probe-dropped-ratio" + timeout: 25000 + +# ---- 3. The probe actually sampled -------------------------------------------------- +- copyTextFrom: + id: "practice-timer-screen-frame-probe-frames" +- assertTrue: ${/^frames \d+$/.test(maestro.copiedText)} +# A 10 s window on any shipping refresh rate delivers hundreds of frames. 100 is a floor +# that only a probe which barely ran could fail — it is a liveness check, not a budget. +- assertTrue: ${Number(maestro.copiedText.replace('frames ', '')) >= 100} + +# ---- 4. The nominal interval was derived --------------------------------------------- +# Guards against normalising a ratio against a zero nominal, which would make every +# interval read as zero dropped frames. +- copyTextFrom: + id: "practice-timer-screen-frame-probe-nominal-ms" +- assertTrue: ${/^nominal \d+\.\d+$/.test(maestro.copiedText)} +- assertTrue: ${Number(maestro.copiedText.replace('nominal ', '')) > 0} + +# ---- 5. The budget itself ------------------------------------------------------------- +- copyTextFrom: + id: "practice-timer-screen-frame-probe-dropped-ratio" +- assertTrue: ${/^ratio \d+\.\d+$/.test(maestro.copiedText)} +- assertTrue: ${Number(maestro.copiedText.replace('ratio ', '')) <= Number(E2E_PERF_MAX_DROPPED_RATIO)} diff --git a/app/.maestro/bug-report-crisis-reachability.yaml b/app/.maestro/bug-report-crisis-reachability.yaml new file mode 100644 index 00000000..74329832 --- /dev/null +++ b/app/.maestro/bug-report-crisis-reachability.yaml @@ -0,0 +1,100 @@ +appId: fyi.being.app +tags: + - safety +name: "Bug-report form leaves the 988 affordance reachable and has a fixed exit" +# e2e-certifies: 375x667 +# The smallest supported viewport is the constraining one here: the form is a bottom +# sheet, so the room left for the crisis FAB above it is tightest at 667pt tall. A pass +# on a larger device would not certify this. +--- +# FEAT-570 — the on-device inverse of DEBUG-533's measurement. +# +# DEBUG-533 measured, on an iPhone SE 3rd gen / iOS 18.6 Release build, that with +# Sentry's feedback widget open `crisis-button-root` appeared ZERO times in the +# accessibility hierarchy — having asserted VISIBLE three steps earlier in the +# same run. The occluder was `FeedbackWidgetProvider`'s inset-0 backdrop, a later +# sibling of our whole app, which `RootCrisisButton`'s zIndex 9999 cannot reach +# past. FEAT-570 replaced the widget with our own form in `rootOverlaySlot`. +# +# This flow asserts the opposite of that measurement, and then TAPS THROUGH — the +# distinction matters under this defect class, because a hierarchy dump can list a +# view that another native window is covering, so `assertVisible` alone is not +# proof of reachability (DEBUG-465). Landing on `crisis-resources-screen` is. +# +# ⚠️ THIS FLOW MUST NEVER TAP `bug-report-send`. +# The gate build resolves a LIVE production Sentry DSN — the `e2e-sim` profile +# sets only the two E2E keys and otherwise extends production — and unlike the +# crisis-analytics path there is no INFRA-411-style egress suppression on +# `captureFeedback`. A scripted submit would deliver a real feedback event +# carrying e2e-seed text to the production Sentry project. Opening proves +# reachability; submitting proves nothing this flow needs. +# +# The shake entry point is NOT covered here and cannot be: Maestro 2.6.0 has no +# shake or device-motion command. Its route-independence is covered by +# `bug-report-suppressed-route.yaml` and by jest; the gesture itself is an +# attended device session. +- launchApp: + clearState: true + clearKeychain: true # SecureStore-backed consent persists across clearState (INFRA-179) +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home + +- tapOn: + id: "tab-profile" +- scrollUntilVisible: + element: + id: "profile-card-bug-report" + direction: DOWN + centerElement: true # DEBUG-465: the card sits inside ProfileScreen's ScrollView +- tapOn: + id: "profile-card-bug-report" +- assertVisible: + id: "bug-report-overlay" + +# THE ASSERTION THIS FLOW EXISTS FOR. Present, and reachable, with the form up. +- assertVisible: + id: "crisis-button-root" +- tapOn: + id: "crisis-button-root" +- assertVisible: + id: "crisis-resources-screen" +- extendedWaitUntil: + notVisible: "Unable to Call" + timeout: 3000 +- assertVisible: + id: "crisis-call-988-button" + +# The slot's force-release, observed on device: navigating to a crisis route +# takes the slot away from the owner unconditionally, so the destination the user +# was just sent to is not covered by an opaque backdrop. +- assertNotVisible: + id: "bug-report-overlay" +- tapOn: + id: "nav-back-button" + +# THE EXIT LEG. Sentry's Cancel was the last child of its ScrollView, below the +# required textarea: measured on device, with three lines typed it was not on +# screen AT ALL. Ours is pinned in the header, OUTSIDE the scroll region, so it +# survives a raised keyboard and a filled field. +- scrollUntilVisible: + element: + id: "profile-card-bug-report" + direction: DOWN + centerElement: true +- tapOn: + id: "profile-card-bug-report" +- assertVisible: + id: "bug-report-overlay" +- tapOn: + id: "bug-report-input" +- inputText: "the breathing timer drifts by a second every cycle and then resets" +- assertVisible: + id: "bug-report-cancel" # still on screen with the keyboard up and the field filled +- tapOn: + id: "bug-report-cancel" +- extendedWaitUntil: + notVisible: + id: "bug-report-overlay" + timeout: 3000 +# One tap left the form, and the root affordance is back. +- assertVisible: + id: "crisis-button-root" diff --git a/app/.maestro/bug-report-suppressed-route.yaml b/app/.maestro/bug-report-suppressed-route.yaml new file mode 100644 index 00000000..fd85b90e --- /dev/null +++ b/app/.maestro/bug-report-suppressed-route.yaml @@ -0,0 +1,67 @@ +appId: fyi.being.app +tags: + - safety +name: "Bug-report form is refused on the pre-consent gate, which keeps its own 988 footer" +# e2e-certifies: 375x667 +# Same viewport and the same reason as deeplink-consent-gate, which asserts this screen's +# pinned 988 footer: whether that footer is on screen is a function of height. +--- +# FEAT-570 — the half of the contract the Profile entry point CANNOT reach. +# +# WHY THIS FLOW EXISTS. The bug-report form has two entry points, and only one is +# drivable: Maestro 2.6.0 has no shake or device-motion command (every command +# class in `maestro-orchestra-models.jar` was enumerated). It would be tempting to +# argue that both entries converge on one store action and one overlay, so the +# Profile flow covers both — that is DEBUG-403's reasoning-by-analogy, and it is +# wrong here. Convergence covers the OVERLAY. It does not cover the ENTRY, and +# what differs between the two entries is the only safety-relevant variable: +# WHICH ROUTE IS ACTIVE when the form opens. +# +# The Profile card can only fire on a Profile tab under `Main` — non-suppressed, +# FAB present, the one route where nothing interesting can happen. The shake is +# armed at the app ROOT (`useBugReportShake()` is called inside `App()`, ABOVE +# `NavigationContainer`), so it fires on `AssessmentFlow`, on `CrisisResources`, +# and on the pre-consent `LegalGate` — routes where `RootCrisisButton` steps +# aside and the SCREEN owns the only route to 988. +# +# WHAT THE MARKER REPRODUCES. `e2eOpen=bugreport` raises the form during +# `maybeSeedE2EOnboardedState`, BEFORE the navigator mounts. That is not a +# convenience shortcut — it is exactly the state a real shake produces, because +# the gesture is armed while `CleanRootNavigator` is still rendering +# `LoadingScreen`, so a claim can be standing before any route exists to check. +# Paired with `e2eSeed=ungranted`, the first route that then resolves is +# `LegalGate`, whose pinned footer is the ONLY crisis affordance a user has +# before accepting anything. +# +# If the slot's refusal regressed to claim-time only, or was removed, the form +# would paint its OPAQUE backdrop over that footer and `NavigatorA11yHost` would +# prune the navigator subtree from the accessibility tree — a zero-988 state, for +# sighted and assistive-technology users alike, reached without a single tap. +# +# ⚠️ NEVER add a submit step here. See the note in +# `bug-report-crisis-reachability.yaml`: the gate build resolves a live +# production Sentry DSN with no egress suppression on `captureFeedback`. +- stopApp +- clearState +- clearKeychain +- openLink: being://daily?e2eSeed=ungranted&e2eOpen=bugreport +- extendedWaitUntil: + visible: + id: "legal-dob-picker" + timeout: 90000 + +# THE ASSERTION THIS FLOW EXISTS FOR. The claim was standing before this route +# resolved, and the slot refused it. +- assertNotVisible: + id: "bug-report-overlay" + +# And the affordance it would have covered is present and tappable. +- assertVisible: + id: "legal-gate-crisis-988" +- assertVisible: + id: "legal-gate-crisis-text" + +# Anti-vacuity: prove we are actually on the pre-consent gate and did not simply +# boot somewhere the overlay was never going to appear. +- assertNotVisible: + id: "home-screen" diff --git a/app/.maestro/crisis-988-dial.yaml b/app/.maestro/crisis-988-dial.yaml index 2da3d1c7..6459abb9 100644 --- a/app/.maestro/crisis-988-dial.yaml +++ b/app/.maestro/crisis-988-dial.yaml @@ -6,6 +6,52 @@ name: "988 dial does not surface 'Unable to Call' fallback (device-only)" # INFRA-486 — safety-device-only: runs on real hardware to prove canOpenURL(tel:) does not fall # back. The axis is the OS URL handler, not the viewport. --- +# e2e-device-unavailable: DEBUG-589 measured=2026-09-07 dead-versions=2.0.0..2.10.0 +# +# THE DEVICE HALF OF THE MAESTRO SAFETY GATE IS UNAVAILABLE. No Maestro version can +# execute any flow on a physical iPhone. Measured 2026-09-07 on iPhone 16e / iOS 26.6 / +# Xcode 26.0.1 / team KN6FDLG98K, across 2.0.0, 2.1.0, 2.2.0, 2.4.0, 2.5.1, 2.6.0, 2.6.1, +# 2.7.0, 2.8.0, 2.9.0 and 2.10.0 — two distinct failure modes and no survivor: +# +# >= 2.2.0 (incl. 2.10.0, current latest) the shipped driver Xcode project declares a +# MaestroDriverLib framework target (47 pbxproj references, INFOPLIST_FILE = +# MaestroDriverLib/Info.plist, a source at Sources/MaestroDriverLib/) and the entire +# MaestroDriverLib/ directory is shipped in ZERO releases. Five UITests sources +# `import MaestroDriverLib`. The build dies in ~8s with +# `error: Build input file cannot be found: .../MaestroDriverLib/Info.plist`, +# writing NO JUnit report. Upgrading cannot fix this. +# <= 2.1.0 predates that target and BUILDS the driver and installs the runner, but the +# XCUITest runner never becomes ready within a 300s MAESTRO_DRIVER_STARTUP_TIMEOUT. +# +# THE HARDWARE IS HEALTHY. Do not read this as "no device attached" — that is a different, +# already-documented failure (a sleeping tunnel; see .claude/CLAUDE.md). The iPhone is +# wired, paired, Developer Mode enabled, tunnel connected, and the Maestro runner it +# installed launches by hand via `xcrun devicectl device process launch`. Maestro's own +# driver is the failure. Simulator flows are unaffected because they use a PREBUILT driver +# and never compile — that asymmetry is the whole reason 14 sim flows are green while this +# one cannot start. +# +# WE ARE ACCEPTING UNVERIFIED RUNTIME BEHAVIOUR ON THE 988 DIAL PATH, indefinitely, with +# no known working version. Specifically unverified: +# - that the GENERATED Info.plist still carries `tel`/`sms` after plugin composition +# (iOS is CNG per INFRA-280, so no reviewer ever sees a plist diff); +# - that `canOpenURL('tel:988')` returns true on real iOS; +# - that no "Unable to Call" alert appears on a real tap. +# +# THE SURVIVING JEST PINS DO NOT SUBSUME THIS. The PRIMARY/SUPPLEMENTARY framing below +# describes which pin catches a CONFIG regression first — not that one contains the other. +# The jest pins cover configuration and call-site routing; they do not cover runtime +# behaviour on hardware, and this supplementary flow was the only thing that ever did. +# A green `npm run e2e:safety` and a green precommit do NOT mean the dial path was verified. +# +# DO NOT delete, rename or skip this flow. It is the specification of what is now +# unverified, and the thing to re-run the day a driver works. +# +# EXIT CONDITION: a Maestro release that ships MaestroDriverLib/, or an upstream fix to the +# runner handshake on iOS >= 26. Re-run this flow, then remove this notice and the entry in +# app/__tests__/safety/deviceOnlyFlowsUnavailable.test.ts in the same commit. +# Compensating follow-ups: see that test's header. +# # DEVICE-ONLY (INFRA-184). Cannot pass on iOS simulator: the sim's # Linking.canOpenURL('tel:988') returns false unconditionally because the # simulator has no phone app — independent of LSApplicationQueriesSchemes. diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml index c8ec1dc5..ed7d3a0e 100644 --- a/app/.maestro/crisis-button-reachability.yaml +++ b/app/.maestro/crisis-button-reachability.yaml @@ -211,6 +211,109 @@ name: "Crisis button (single root mount) reaches CrisisResources from every surf - tapOn: id: "nav-back-button" +# ── INFRA-532: the weekly-reflection composer must not occlude the button ─────── +# WeeklyReflectionComposer is a DEBUG-406 conversion site (it was an RN , +# which renders in a separate native window ABOVE the JS hierarchy — a +# zero-988-affordance state while open). It publishes into the root overlay slot, +# which paints immediately BELOW the crisis button. +# +# REACHABLE ONLY BECAUSE OF THE SEED. WeeklyReflectionCard returns null below +# MIN_CHECK_INS_TO_SHOW = 4, and clearState+clearKeychain zeroes the store, so +# before INFRA-532 this card did not exist in the gate build at all. e2eSeed.ts +# seeds four non-'daily' check-ins. If that seed is removed or the constant is +# raised, this segment reds — which is the intended coupling, not a fragility. +# +# THE KEYBOARD STAYS DOWN. autoFocus was removed from the composer deliberately +# (DEBUG-406: it stole VoiceOver focus from the title and raised the keyboard into +# UIRemoteKeyboardWindow, above the app window where zIndex is meaningless). Do +# not tap the input here — the keyboard-up contract is device-only and is NOT +# covered by this flow. +- tapOn: + id: "tab-insights" +- scrollUntilVisible: + element: + id: "weekly-reflection-card" + direction: DOWN + centerElement: true # DEBUG-465: the card is inside InsightsScreen's ScrollView + # while the crisis FAB is pinned outside it, so a card left + # at the bottom edge shares coordinates with the FAB. +- tapOn: + id: "weekly-reflection-prompt" +- assertVisible: + id: "weekly-reflection-overlay" + +# OCCLUSION ARM — tap-through, not assertVisible. Under a native-layer occlusion +# bug the hierarchy dump can still LIST a view another native window is covering, +# so assertVisible alone would have passed against the original defect. +# Only tap-and-land is evidence. +- assertVisible: + id: "crisis-button-root" +- tapOn: + id: "crisis-button-root" +# DEBUG-575 finding 2 — BOTH of the next two are required and neither substitutes +# for the other. Before the fix the SHEET-GONE assertion was what failed: the route +# committed to CrisisResources while this composer's OPAQUE inset-0 backdrop covered +# it and its responder swallowed every touch, so the crisis screen was mounted, +# listed in the hierarchy, and completely useless. Asserting only that the screen +# exists would have passed against that. Asserting the sheet is gone is what proves +# the destination is actually on screen and reachable. +- extendedWaitUntil: + notVisible: + id: "weekly-reflection-overlay" + timeout: 3000 +- assertVisible: + id: "crisis-resources-screen" +- extendedWaitUntil: + notVisible: "Unable to Call" + timeout: 3000 +- tapOn: + id: "nav-back-button" +# INVERTED BY DEBUG-575 finding 2. This used to assert the sheet SURVIVES the +# excursion — that was the defect written down as a contract. The slot is released +# on entry to the crisis route, so the user returns to Insights with the card's own +# control restored and no sheet. The draft is preserved in memory and fed back on +# re-open; that is unobservable to Maestro and is pinned in jest instead. +- assertNotVisible: + id: "weekly-reflection-overlay" +- assertVisible: + id: "weekly-reflection-prompt" + +# The mis-tap arm below needs the sheet up again — the release above closed it. +- tapOn: + id: "weekly-reflection-prompt" +- assertVisible: + id: "weekly-reflection-overlay" + +# MIS-TAP ARM — the INVERSE hazard, and the reason this segment is not just a +# copy of the ThresholdEducationModal block. At zIndex 9999 the crisis FAB WINS an +# overlapping tap, so an action-row geometry regression fires an audit-logged +# crisis navigation the user did not ask for AND swallows the action they did. +# Save is the RIGHTMOST control (the row is justifyContent: 'flex-end'), so it is +# nearest the contested column and is what a regression reaches first; it is +# disabled at empty text, making this a side-effect-free probe. +# +# NECESSARY, NOT SUFFICIENT: Maestro taps element CENTRES, which never enter the +# contested column, so this catches a GROSS regression (padding dropped, row +# re-justified, FAB moved or grown) and cannot catch a marginal one. The marginal +# case is pinned CI-side by __tests__/safety/modalOcclusionConversions.test.tsx, +# which asserts the row's paddingRight === OVERLAY_ACTION_ROW_PADDING_RIGHT. +- tapOn: + id: "weekly-reflection-save" +- tapOn: + id: "weekly-reflection-cancel" +# Settle on the POSITIVE post-condition first: a stolen tap leaves the sheet up +# and reds here, rather than racing a bare assertNotVisible against a transition. +- extendedWaitUntil: + notVisible: + id: "weekly-reflection-overlay" + timeout: 5000 +# All three are required. The first two alone would pass a state where the taps +# did nothing at all. +- assertNotVisible: + id: "crisis-resources-screen" # neither tap reached the FAB +- assertVisible: + id: "weekly-reflection-prompt" # the user's own action landed, not swallowed + # ── Profile tab ────────────────────────────────────────────────── # FEAT-212: Profile migrated to a nested React Navigation stack # (ProfileStackNavigator). MAINT-290 removed the per-navigator crisis overlay diff --git a/app/.maestro/crisis-keyboard-accessory.yaml b/app/.maestro/crisis-keyboard-accessory.yaml index c62ff332..69f27b81 100644 --- a/app/.maestro/crisis-keyboard-accessory.yaml +++ b/app/.maestro/crisis-keyboard-accessory.yaml @@ -3,6 +3,39 @@ tags: - safety-device-only name: "Crisis affordance stays reachable while a software keyboard is up (device-only)" --- +# e2e-device-unavailable: DEBUG-589 measured=2026-09-07 dead-versions=2.0.0..2.10.0 +# +# THE DEVICE HALF OF THE MAESTRO SAFETY GATE IS UNAVAILABLE. No Maestro version can +# execute any flow on a physical iPhone — measured 2026-09-07 across 2.0.0 through 2.10.0 +# on iPhone 16e / iOS 26.6 / Xcode 26.0.1. Full root cause and the two failure modes are +# recorded once, in crisis-988-dial.yaml. THE HARDWARE IS NOT THE PROBLEM: the iPhone is +# wired, paired and Developer-Mode enabled, and the runner Maestro installs launches by +# hand — do not read this as a missing or sleeping device. Maestro's own driver is the +# failure. So this flow cannot start today. +# +# BUT THIS LOSS IS RECOVERABLE AND MUST NOT BE READ AS PERMANENT — that is the difference +# between this flow and crisis-988-dial, and flattening the two would be wrong. The dial is +# device-only by PHYSICS: the simulator's canOpenURL is unconditionally false, so no +# substitute exists even in principle. This flow is device-only only by a DEFERRED +# AUTHORING TASK. Its own header below already records that its device-only rationale is +# FALSE (DEBUG-506): a software keyboard does rise on the gate simulator at the default, +# `UIKeyboardLayoutStar Preview` lands at measured bounds, and journal-crisis-scan.yaml +# asserts that very node three times in the default sim suite today. The reachability half +# of this contract is SIMULATOR-RUNNABLE NOW. +# +# Status: NOT YET MIGRATED — not "unavailable". Follow-up DEBUG-590 moves the reachability +# half into the sim suite (bumping the 14-flow tripwire in the same commit); the dial half +# stays here behind DEBUG-589's exit condition. +# +# UNTIL DEBUG-590 LANDS we are accepting unverified runtime behaviour on the crisis +# keyboard accessory: that iOS actually attaches and paints the accessory over a real +# software keyboard, and that the occlusion predicate fires. This is the DEBUG-450 defect +# class — an accessory that never attached at all, for its entire life, with this flow the +# only thing pinning it. `check-crisis-keyboard-accessory-guard.js` pins that the PROP is +# present on every shipping TextInput; it cannot pin that iOS honours it. +# +# DO NOT delete, rename or skip this flow. +# # DEVICE-ONLY, AND THE RECORDED REASON FOR IT IS FALSE (DEBUG-506). # # This header used to say: "The simulator boots with Connect Hardware Keyboard enabled, so diff --git a/app/.maestro/daily-loop-ax5-entry.yaml b/app/.maestro/daily-loop-ax5-entry.yaml index 65604748..de4e0a4d 100644 --- a/app/.maestro/daily-loop-ax5-entry.yaml +++ b/app/.maestro/daily-loop-ax5-entry.yaml @@ -109,3 +109,16 @@ name: "DailyLoop AX5 entry (DEBUG-469): the loop is enterable at accessibility t id: "daily-loop-support-line" - assertVisible: id: "crisis-resources-screen" +# DEBUG-560 — the destination's own 988 control, still in the fold at AX5. +# +# Added here rather than in a new flow because this flow already lands on +# `crisis-resources-screen` at AX5, so the assertion is free, and because a flow written +# for DEBUG-560's actual defect could not fail: Maestro asserts presence, not wrapping, +# and the Languages text is findable on the broken build and the fixed one alike. +# +# What it DOES guard is DEBUG-432/488's invariant surviving any change to card height. +# The 988 control is a sibling of the ScrollView inside `crisisFooter`, so content height +# is not an input to its frame — but that is a structural argument, and this is the one +# cheap place the structure is observed at AX5 on a 375x667 device rather than argued. +- assertVisible: + id: "crisis-call-988-button" diff --git a/app/.maestro/daily-loop-quick-depth.yaml b/app/.maestro/daily-loop-quick-depth.yaml index 76e33673..b3ccdace 100644 --- a/app/.maestro/daily-loop-quick-depth.yaml +++ b/app/.maestro/daily-loop-quick-depth.yaml @@ -325,6 +325,44 @@ name: "DailyLoop quick-depth (FEAT-301): crisis affordance survives the shorter id: "daily-loop-skip-breath" - assertVisible: id: "daily-loop-input-response" +# ── DEBUG-519 — one of the two `continue-button` taps DEBUG-465's ruling never reached. +# That ruling is stated above `launchApp` at the top of this file: "A scroll in front of +# `continue-button` is now REQUIRED and is not the same act" as the permanently-banned +# scroll in front of `daily-loop-support-line`. Only the Sphere Sovereignty site ever got +# one. This tap and the deep beat-1 tap stayed bare, so a Continue below the fold surfaces +# as a plain `element not found` — indistinguishable from layout drift, on a flow whose reds +# are usually layout. This makes such a failure attributable. IT IS A DETECTOR, NOT A FIX: +# nothing here is red today, and `continueWrap`'s CRISIS_FAB_CLEARANCE is already unconditional. +# +# NO `centerElement` — not the Sphere Sovereignty shape copied carelessly, the opposite call +# for a different beat. THERE, centring is what proves a real scroll happened, which is what +# keeps the tap off the co-located support line; do not remove it there. HERE +# `showsSupportLine()` is false (tenseMode.ts:147 — quick is true only on QUICK_SUPPORT_STEP), +# so there is no co-located affordance to be kept off and centring buys nothing. +# Both DEBUG-477 arms are clear as written: already-visible ⇒ Maestro swipes ZERO times and +# swallows nothing; below the fold ⇒ `continue-button` is the LAST ScrollView child +# (DailyLoopStepScreen.tsx, `continueWrap` immediately before ``), so a DOWN +# scroll terminates at the CONTENT BOUNDARY, which clears the swallow by the documented +# predicate. `centerElement` would re-position mid-content and reintroduce it (probe F). +# No explicit timeout: with no futile centring to fund, this file's plain-boundary-scroll +# convention applies. If one is ever needed, MEASURE it — do not copy the 25000 below. +# +# MEASURED on iPhone SE 3 / iOS 18.6 / 375x667, `maestro hierarchy`, clearState run, +# IDENTICAL at quick beat 1 and deep beat 1: +# continue-button [20,433][283,489] +# crisis-button-root [331,523][375,567] 34pt below the CTA, 48pt right of it +# daily-loop-support-line ABSENT FROM HIERARCHY +# So neither mechanism this ticket cited can fire here, and the second was refuted for a +# reason worth keeping: the CTA sits ABOVE the FAB band, not below it, because +# AwarePresence content does not fill the viewport. Its y-position is CONTENT-DEPENDENT — +# a longer beat pushes it down THROUGH 523..567 — which is exactly why DEBUG-518's fix is +# a HORIZONTAL inset and unconditional. x=283 is CRISIS_FAB_CLEARANCE landing where +# DailyLoopStepScreen.tsx:97 predicted. Do not re-derive this from viewport arithmetic; +# that is what produced the wrong answer before it was measured. +- scrollUntilVisible: + element: + id: "continue-button" + direction: DOWN - tapOn: id: "continue-button" @@ -505,6 +543,14 @@ name: "DailyLoop quick-depth (FEAT-301): crisis affordance survives the shorter # assertion — a `when:` block here could only ever hide a genuine regression. - tapOn: id: "daily-loop-skip-breath" +# DEBUG-519 — the second bare tap. Same guard and same reasoning as the quick beat-1 site +# above; see that block for the DEBUG-465 ruling and the DEBUG-477 boundary derivation. +# Deep beat 1 is Aware Presence, so `showsSupportLine()` is false here too: the support line +# belongs to beat 2 (Radical Acceptance), which is where it is asserted immediately below. +- scrollUntilVisible: + element: + id: "continue-button" + direction: DOWN - tapOn: id: "continue-button" # Deep beat 2 = Radical Acceptance → support line present here. diff --git a/app/.maestro/export-share-sheet-occlusion.yaml b/app/.maestro/export-share-sheet-occlusion.yaml new file mode 100644 index 00000000..84e66fc3 --- /dev/null +++ b/app/.maestro/export-share-sheet-occlusion.yaml @@ -0,0 +1,144 @@ +appId: fyi.being.app +tags: + - safety-occlusion-measurement +name: "DEBUG-577: the expo-sharing share sheet occludes the 988 affordance (DEBT PIN)" + +# e2e-certifies: 375x667 +# +# ⚠️ THIS FLOW PINS A DEBT STATE, NOT A CONTRACT. Its load-bearing assertion is +# `assertNotVisible: crisis-resources-screen` — an assertion that the 988 affordance +# is UNREACHABLE. That is the opposite of what every flow in the `safety` suite +# asserts, and it stays green on the day someone discharges the debt. IF THE +# OCCLUSION IS EVER REMEDIED, DELETE THIS FLOW; do not "fix" it. +# +# WHY ITS OWN TAG CLASS, AND WHY /b-close NEVER SCOPES IT. +# `safety-occlusion-measurement` keeps it out of `npm run e2e:safety` by construction: +# e2e-safety.sh selects on a tag line matching exactly `- safety`, so no runner change +# is needed. The exclusion is REQUIRED, not tidiness — see the banner above, and note +# that an open share sheet is state Maestro does not reliably clear, which per DEBUG-422 +# outlives the flow that raised it and reds LATER flows against a healthy app. It is +# deliberately absent from every /b-close Step 2.5.3 arm. +# +# WHAT IT ADJUDICATES (DEBUG-577, measured 2026-09-07 on iPhone SE 3rd gen / iOS 18.6, +# Release build). With the sheet up, `maestro hierarchy` carries ZERO app-owned nodes — +# not a covered FAB but an absent app tree, leaving a full-bleed `PopoverDismissRegion` +# and the sheet's out-of-process `ShareSheet.RemoteContainerView`. Presence is therefore +# NOT the evidence; hit-testability is. The matched pair below taps ONE screen point on +# ONE route with the sheet down and then up. +# +# 375x667 IS LOAD-BEARING, which is why it is certified rather than left `any`: the probe +# point is derived from this device's measured FAB bounds [331,523][375,567]. On another +# viewport those bounds move and the point stops meaning anything. Re-measure before +# re-certifying elsewhere. +# +# FALSIFICATION: the positive control IS the red arm — the identical point, on the +# identical route, reaches CrisisResources with the sheet down. A run in which the +# control fails is a broken probe, not a finding. +--- +- launchApp: + clearState: true + clearKeychain: true +- extendedWaitUntil: + visible: + id: "home-screen" + timeout: 90000 + +- tapOn: + id: "tab-profile" +- scrollUntilVisible: + element: + id: "profile-card-privacy" + direction: DOWN + centerElement: true +- tapOn: + id: "profile-card-privacy" +- scrollUntilVisible: + element: + id: "profile-card-export" + direction: DOWN + visibilityPercentage: 100 + centerElement: true + timeout: 48000 +- tapOn: + id: "profile-card-export" +- runFlow: + when: + notVisible: + id: "export-data-screen" + commands: + - tapOn: + id: "profile-card-export" +- assertVisible: + id: "export-data-screen" + +# ── POSITIVE CONTROL — the point resolves to the FAB on THIS route, sheet DOWN ── +# Route matters: SUPPRESSED_ROUTES fades/omits the FAB on some routes, so a control +# taken elsewhere proves nothing about ExportData. Without this, a red probe below is +# indistinguishable from "the arithmetic put the point somewhere the FAB is not". +# Safe to tap: RootCrisisButton NAVIGATES, it does not dial. +- assertVisible: + id: "crisis-button-root" +- tapOn: + point: "98%,82%" +- extendedWaitUntil: + visible: + id: "crisis-resources-screen" + timeout: 30000 +# DEBUG-392 witness — it navigated, it did not fall through to the degraded dial +# branch. MUST follow the assertVisible above or it passes vacuously at t=0. +- extendedWaitUntil: + notVisible: "Unable to Call" + timeout: 3000 +- tapOn: + id: "nav-back-button" +- assertVisible: + id: "export-data-screen" + +# ── VACUITY GUARD — the FAB is mounted immediately before the sheet opens ─────── +- assertVisible: + id: "crisis-button-root" + +# DEBUG-465: the button is below this ScrollView's fold and XCUITest keeps it in the +# tree, so without a real scroll Maestro taps coordinates that land on the tab bar. +- scrollUntilVisible: + element: + id: "export-data-button" + direction: DOWN + visibilityPercentage: 100 + centerElement: true + timeout: 48000 +- tapOn: + id: "export-data-button" + +# ── SHEET-IS-UP ORACLE ───────────────────────────────────────────────────────── +# `export-data-screen` going notVisible IS the run-1 measurement restated: with the +# UIActivityViewController up, `maestro hierarchy` carries ZERO app-owned nodes — +# not a covered FAB, an absent app tree. The screenshot below is what distinguishes +# that from a crash, and per the crisis pass a green probe without it is not a result. +# Timeout is generous: gather+serialize+write measured ~20-40s on this Release build. +- extendedWaitUntil: + notVisible: + id: "export-data-screen" + timeout: 90000 +- takeScreenshot: /tmp/debug577-run2-01-sheet-up + +# ── THE PROBE ────────────────────────────────────────────────────────────────── +- tapOn: + point: "98%,82%" +- takeScreenshot: /tmp/debug577-run2-02-after-probe + +# ── FALSIFIER PAIR ───────────────────────────────────────────────────────────── +# (a) the adjudicating outcome: the FAB did NOT take the touch. +- assertNotVisible: + id: "crisis-resources-screen" +# (b) its complement — the app was alive throughout, so (a) is not vacuous. Dismiss +# the sheet and confirm we are back on the very screen we started from. +- tapOn: + point: "92%,52%" +- extendedWaitUntil: + visible: + id: "export-data-screen" + timeout: 30000 +- assertVisible: + id: "crisis-button-root" +- takeScreenshot: /tmp/debug577-run2-03-sheet-dismissed diff --git a/app/.maestro/guidance-gentle-tier-cap.yaml b/app/.maestro/guidance-gentle-tier-cap.yaml new file mode 100644 index 00000000..dd88d9cb --- /dev/null +++ b/app/.maestro/guidance-gentle-tier-cap.yaml @@ -0,0 +1,110 @@ +appId: fyi.being.app +tags: + - safety +name: "Guidance caps the ladder at Tier 1 for a gentle-band reader" +# e2e-certifies: 375x667 +# INFRA-420 — the CONTRACT here is viewport-independent (a gate verdict no layout can +# change), but the REACH is not: this flow gets to the screen by tapping +# `home-guidance-entry`, and the only viewport on which that reach is proven is the one +# `guidance-suppressed-handoff.yaml` certifies. Declaring `any` would claim this flow +# certifies on a large device where the Home reach was never validated. Fail closed, the +# same way `e2e_flow_certifies` defaults an undeclared flow to the smallest viewport. +# Not `scrollUntilVisible` on the entry row: that is the sibling's contract to own, and a +# scroll here would let this flow stay green through a Home regression the sibling exists +# to catch, while also putting a mid-content swipe in front of a tap (DEBUG-477). +--- +# WHAT THIS PINS, AND WHY IT IS NOT COVERED BY guidance-suppressed-handoff.yaml. +# +# `decideGuidanceAccess` has three verdicts. The sibling flow pins SUPPRESSED — the +# protective one, where a distressed reader must receive zero philosophy. Nothing pins the +# POSITIVE side at all: that a reader the gate does NOT suppress actually receives Tier 0 +# and Tier 1, and that Tier 2/3 stay capped unless `allowTier2Plus` is true. +# +# The failure this catches is over-disclosure, NOT crisis silence — a mis-wired +# `allowTier2Plus` handing the full ladder to a gentle-band reader. Scope it as the +# secondary gap it is; the branch where a defect means philosophy instead of crisis +# resources is the sibling's, and it is already covered. +# +# ── WHY THIS FLOW COSTS NO ASSESSMENT TAPS ────────────────────────────────── +# `gentle` is the verdict for MISSING data, not only for mid-band scores. +# guidanceGate.ts states it: "absence is never evidence of safety, so it cannot yield +# full. It is equally never evidence of crisis, so it must not yield suppressed either +# ... gentle is the only defensible answer." So a clean launch with no assessment on +# record IS the canonical gentle case, and needs zero seeding beyond onboarding. +# The sibling records the same fact from the other side, at its own re-launch: +# "NO clearState HERE. The GAD-7 result must survive; clearing would make the gate +# answer `gentle`." This flow is that sentence, run deliberately. +# +# ── THE POSITIVE ASSERTIONS COME FIRST, AND THAT ORDERING IS LOAD-BEARING ─── +# `useGuidanceGate` reports `pending` while the encrypted store hydrates, and the screen +# then renders `guidance-pending` — no tiers, no notice. Every `assertNotVisible` below +# would pass VACUOUSLY in that state, and equally on `guidance-error`. A flow that can +# only go green is worth nothing. So `guidance-content` and `guidance-tier0` are asserted +# FIRST: they are false on pending, on error, and on suppressed, which is what makes the +# absences that follow mean something. +# +# ── THE ABSENCE ASSERTIONS MUST BE MADE AFTER SCROLLING, AND THAT IS MEASURED ── +# This block first said the opposite, citing DEBUG-465: that XCUITest retains elements +# outside a ScrollView's clip, so `assertNotVisible: guidance-tier2` at offset 0 would be +# a real claim about whether the tier RENDERED. That dropped the qualifier the rest of +# this suite states correctly — `_legal-and-onboarding.yaml:106` says "outside a +# ScrollView's clip BUT STILL ON SCREEN". The retention is bounded by the SCREEN, not by +# the clip, and Tier 2/3 sit far below it. +# +# MEASURED on the gate sim with the tier gate forced open, so Tier 2/3 really did render: +# at scroll offset 0 guidance-tier2: ABSENT guidance-tier0/1: present +# after three scrolls guidance-tier2: PRESENT guidance-tier0/1: absent +# The hierarchy is a viewport-relative window. Asserting absence at offset 0 is therefore +# VACUOUS for anything below the fold — it passes whether or not the tier rendered. +# +# Proven by mutation, both arms, which is the only reason to trust it: +# gate forced open → `assertNotVisible: guidance-tier2` FAILED (goes red) +# gate as shipped → flow passes +# Without the scrolls the mutated arm PASSED, i.e. the cap — this flow's whole point — +# could not fail. Do not remove them, and do not assert a tier's absence at offset 0. +- launchApp: + clearState: true + clearKeychain: true # SecureStore-backed consent persists across clearState (INFRA-179) +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home + +# The entry row, reached exactly as the sibling reaches it. If this ever goes red, the +# regression is Home's vertical budget (DEBUG-469) and the fix is the layout — never +# hiding the row, and never a scroll bolted on here. +- assertVisible: + id: "home-guidance-entry" +- tapOn: + id: "home-guidance-entry" + +- assertVisible: + id: "domain-guidance-screen" + +# ── POSITIVE FIRST — these are what stop everything below being vacuous. ──── +# `guidance-content` is the ready-state ScrollView: absent on pending, on error, and on +# suppressed. `guidance-tier0` carries the validation whose abuse/safety escape clause the +# screen's docblock requires to stay first at every access level. +- assertVisible: + id: "guidance-content" +- assertVisible: + id: "guidance-tier0" +- assertVisible: + id: "guidance-tier1" + +# Not the suppressed branch either — this reader gets philosophy, and the notice that +# replaces it must not be on screen. Asserted HERE, at offset 0, because the notice +# replaces the whole ScrollView and would render exactly where we are standing. +- assertNotVisible: + id: "guidance-suppression-notice" + +# ── THE CAP ──────────────────────────────────────────────────────────────── +# Tier 2/3 are gated on `decision.allowTier2Plus === true`; with no assessment on record +# the verdict is `gentle`, so neither may render. Scroll to the end of the ladder FIRST — +# see the block above. For a gentle reader the content ends at Tier 1, so these scrolls +# are a near no-op; if Tier 2/3 ever render, this is what brings them into the window and +# turns the two assertions below from vacuous into load-bearing. +- scroll +- scroll +- scroll +- assertNotVisible: + id: "guidance-tier2" +- assertNotVisible: + id: "guidance-tier3" diff --git a/app/.maestro/journal-crisis-scan.yaml b/app/.maestro/journal-crisis-scan.yaml index a8b45002..c28394d6 100644 --- a/app/.maestro/journal-crisis-scan.yaml +++ b/app/.maestro/journal-crisis-scan.yaml @@ -251,10 +251,30 @@ name: "Voice journal crisis scan surfaces support" # dismisses the keyboard, which destroys the one condition this block tests. # # Tapping Save DIRECTLY at its resting position is therefore both the honest shape -# and the stricter one. MEASURED at 375x667 with the keyboard up: Save sits at -# [24,377][279,429] against an occluding edge of y=407 — 42% covered, tap centre -# y=403 clearing by 4pt. With no scroll to rescue it, this block goes red if -# anything ever erodes that clearance. That sensitivity is the point. +# and the stricter one. The ABSENCE of a scroll before the tap is itself load-bearing: +# adding one would convert "Save is reachable with the keyboard up" into "Save is +# reachable after scrolling", a strictly weaker contract that hides the regression class +# this block exists to catch. +# +# ── THE 4pt FIGURE IS PRE-DEBUG-506 EVIDENCE, NOT A THRESHOLD (DEBUG-516) ───── +# MEASURED at 375x667 with the keyboard up, on `development` in August 2026: Save sat at +# [24,377][279,429] against an occluding edge of y=407 — 42% covered, tap centre y=403 +# clearing by 4pt. Read that as a record of one build, because two things move it: +# +# 1. TYPE SIZE. DEBUG-507 re-measured at extra-extra-extra-large and found Save at +# [24,415][279,473] — 100% covered, 37pt UNDER the edge. 4pt was one Dynamic Type +# step from failing, and it failed. That is DEBUG-516. +# 2. THE KEYBOARD'S OWN CHROME. An attached inputAccessoryView is installed into +# UIRemoteKeyboardWindow and joins the union UIKeyboardFrameEndUserInfoKey reports, +# so the occluding edge RISES by the bar's height. DEBUG-506 attaches this app's +# crisis accessory for the first time (~72pt: TOUCH_TARGETS.large plus padding), +# which would have taken this 4pt negative at the DEFAULT text size. +# +# DEBUG-516 therefore stopped deriving the layout from this number. Save is pinned outside +# the ScrollView and its container is inset from the LIVE keyboard frame, so clearance is +# non-negative by construction and this block is now a behavioural pin rather than a +# clearance pin. Do NOT re-derive a fix from the coordinates above, and do not treat a +# change in them as the regression — `journal-saved-state` failing to arrive is. - launchApp: clearState: true clearKeychain: true diff --git a/app/.maestro/journal-record-liveness.yaml b/app/.maestro/journal-record-liveness.yaml new file mode 100644 index 00000000..c8d2b28f --- /dev/null +++ b/app/.maestro/journal-record-liveness.yaml @@ -0,0 +1,74 @@ +appId: fyi.being.app +tags: + - safety-host-probe +name: "Voice journal record tap does not abort the process (DEBUG-524)" +# e2e-certifies: any +# DEBUG-524 — drives the app to `phase: 'recording'` and STOPS. The verdict is the +# host-side PID sample in scripts/e2e-audio-liveness-probe.sh, not this file. +--- +# WHY THE ASSERTION IS NOT IN THIS FILE +# The oracle is the app's PID sampled either side of a MEASURED interval, which is +# host-side by nature. Every in-flow way to burn the ~15s abort window is a shape +# DEBUG-524's crisis ruling rejects outright: +# +# - `extendedWaitUntil: visible:` on an element that is ALREADY visible returns in +# milliseconds. Zero dwell, green — the exact "green because fast" failure the +# work item forbids closing on. +# - `extendedWaitUntil: notVisible:` on a live element times out whether the app is +# healthy or dead, so it discriminates nothing. +# - a `repeat:` block is host-speed dependent, so a FASTER machine produces a +# SHORTER dwell and a weaker test. +# +# WHY THE TAG IS `safety-host-probe` AND NOT `safety` +# `npm run e2e:safety` globs flows tagged exactly `- safety`; this one is excluded on +# purpose, on the `safety-device-only` / `safety-dynamic-type` precedent. Running it +# bare proves nothing — without the probe's dwell there is no window and no verdict. +# It is still routed through e2e-safety.sh (never `maestro test` directly) so it keeps +# provenance verification, device resolution, the sim lock and the driver reset. It is +# deliberately NOT `_`-prefixed: e2e-safety.sh refuses helper subflows outright +# (DEBUG-505, exit 2), so a `_` name could not be routed through the pre-flight at all. +# +# NOTHING MAY BE APPENDED AFTER THE RECORD TAP, except the precondition assert that +# closes this file. The abort lands ~15s later wherever the flow has got to and +# attributes itself to whatever step is executing — see the measured note at +# profile-voice-reflection-xxxl.yaml:118-140. This flow ending at the tap is what +# leaves the window clean for the probe to own. +- launchApp: + clearState: true + clearKeychain: true # SecureStore-backed consent survives clearState (INFRA-179) +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home +- tapOn: + id: "tab-profile" +- scrollUntilVisible: + element: + id: "profile-card-voice-reflection" + direction: DOWN + centerElement: true # DEBUG-465/477 — a pinned tab bar shares coordinates with clipped content + timeout: 40000 # DEBUG-507 — 20000 measured at 95% of budget on an idle host +# DEBUG-477/479 absorbing tap. XCUITest swallows the synthesised first tap after a flick +# on a MID-LIST card, and FEAT-287 added a card beneath this one, ending its immunity. +- tapOn: + id: "tab-profile" +- tapOn: + id: "profile-card-voice-reflection" +- assertVisible: + id: "voice-reflection-screen" + +# ── THE ABORT WINDOW OPENS HERE ────────────────────────────────────────────── +- tapOn: + id: "journal-record-button" + +# PRECONDITION, NOT DECORATION — the probe is UNSOUND without it. +# journal-stop-button renders ONLY under `phase === 'recording'` +# (VoiceReflectionScreen.tsx:512). If the record tap were swallowed — the DEBUG-479 +# class, live on this very card — the recognizer would never start, no AVAudioEngine +# would be constructed, no RPC would be entered, and the probe would observe a +# perfectly stable PID and report PASS. That false green is indistinguishable from a +# fix. Asserting the phase is what makes a stable PID mean "survived the window" +# rather than "never entered it". +# +# It is also the probe's liveness baseline: this assert passing is the evidence the +# process was ALIVE when the window opened, which is what lets the probe read a +# missing PID as an abort rather than as a blind oracle. +- assertVisible: + id: "journal-stop-button" diff --git a/app/.maestro/profile-voice-reflection-xxxl.yaml b/app/.maestro/profile-voice-reflection-xxxl.yaml index 03bf2f7f..2ce8dd30 100644 --- a/app/.maestro/profile-voice-reflection-xxxl.yaml +++ b/app/.maestro/profile-voice-reflection-xxxl.yaml @@ -71,24 +71,34 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan id: "voice-reflection-screen" timeout: 8000 -# ── AC 3 — WHY THIS FLOW STOPS AT THE KEYBOARD AND DOES NOT TAP SAVE ───────── -# It stops here because the next step is BROKEN, and pinning a broken contract green is -# worse than not pinning it. DEBUG-507 AC 3 re-measured DEBUG-480's clearance at this text -# size, against the full keyboard window ([0,407][375,667] — NOT `UIKeyboardLayoutStar -# Preview`, which is [0,451][375,667] and reports only the key layout, omitting the ~44pt -# predictive bar that occludes just as much): +# ── AC 3 / DEBUG-516 — THIS FLOW NOW TAPS SAVE, AND THAT IS THE POINT ──────── +# It used to stop at the keyboard, because the next step was BROKEN and pinning a broken +# contract green is worse than not pinning it. DEBUG-507 AC 3 re-measured DEBUG-480's +# clearance at this text size, against the full keyboard window ([0,407][375,667] — NOT +# `UIKeyboardLayoutStar Preview`, which is [0,451][375,667] and reports only the key layout, +# omitting the ~44pt predictive bar that occludes just as much): # # content_size large journal-save-button [24,377][279,429] centre y=403 -> clears by 4pt, 42% covered # content_size XXXL journal-save-button [24,415][279,473] centre y=444 -> UNDER by 37pt, 100% covered # # The default-size row reproduces DEBUG-480's published figures exactly, which is what -# vouches for the method. At XXXL the button does not merely lose margin — it sits entirely -# beneath the keyboard, so the tap is swallowed and `journal-saved-state` never arrives. -# Tracked as DEBUG-516 per AC 3; this flow gains the save block when that lands. +# vouches for the method. At XXXL the button did not merely lose margin — it sat entirely +# beneath the keyboard, so the tap was swallowed and `journal-saved-state` never arrived. # -# Do NOT "fix" this by asserting `journal-save-button` is visible: the iOS keyboard is a -# separate window, so XCUITest scores the button fully visible while it is 100% covered. -# That assertion is the exact false green DEBUG-480 exists to warn about. +# BOTH ROWS ARE PRE-DEBUG-516 AND PRE-DEBUG-506 EVIDENCE. They record where an +# in-ScrollView Save happened to land on one build; they are not thresholds. DEBUG-516 +# moved Save out of the ScrollView and inset its container from the LIVE keyboard frame, +# which is the union UIKit reports (key layout + predictive bar + any inputAccessoryView). +# Clearance is therefore non-negative by construction and survives DEBUG-506 attaching the +# crisis accessory, which raises the occluding edge ~72pt on this surface. Do not re-derive +# anything from the coordinates above. +# +# Do NOT "fix" a failure here by asserting `journal-save-button` is visible: the iOS +# keyboard is a separate window, so XCUITest scores the button fully visible while it is +# 100% covered. That assertion is the exact false green DEBUG-480 exists to warn about. +# Do NOT add a scroll before the Save tap either — DEBUG-480 measured `scrollUntilVisible` +# reporting COMPLETED without scrolling here, and a scroll would weaken the contract from +# "reachable with the keyboard up" to "reachable after scrolling". - tapOn: id: "journal-record-button" - tapOn: @@ -104,3 +114,39 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan # visible proves the matcher still fires if iOS renames the identifier. - assertVisible: id: "UIKeyboardLayoutStar Preview" + +# ── WHY THERE IS NO DRAG-DISMISSAL BLOCK HERE, AND WHAT IS THEREFORE UNPINNED ── +# One was written and REMOVED, because it could not be made to pass for a reason that has +# nothing to do with what it asserts. MEASURED on the gate sim, 3 runs of 3: +# `ExpoSpeechRecognizer.start -> prepareMicrophoneRecognition -> AVAudioEngine.inputNode -> +# AURemoteIO::Cleanup -> _ReportRPCTimeout -> abort()` — a BLOCKING RPC to the simulator's +# audio daemon, entered on the journal-record-button tap, which SIGABRTs the app roughly 15 +# SECONDS LATER wherever the flow has got to by then. Crash reports with this exact stack +# predate this branch (2026-08-21 02:14 onward), so it is not a DEBUG-516 regression — but +# it does bound how long a flow may linger on this surface after tapping record. +# +# Everything below therefore runs INSIDE that window, and nothing may be added between the +# record tap and `journal-saved-state` without re-measuring it. If this flow starts failing +# with "App crashed or stopped while executing flow", check ~/Library/Logs/DiagnosticReports +# for that stack BEFORE suspecting the app's layout: the failure lands on whichever step was +# executing, so it impersonates a defect in an unrelated assertion. +# +# STILL UNPINNED as a result: that `keyboardDismissMode='on-drag'` actually dismisses. That +# matters because it is this surface's only keyboard dismissal (multiline field, so Return +# inserts a newline and there is no Done key), and while DEBUG-506 is open it is also the +# only route back to a state where the root crisis button is reachable. DEBUG-516 shrank the +# scroll region, so "the content still scrolls, therefore the drag still dismisses" stopped +# being free. Recorded as a stated coverage gap rather than pinned green by a shorter +# assertion that would not have tested it. + +# AC 1 + AC 4 — SAVE AT ITS RESTING POSITION, KEYBOARD UP, AT THE LARGEST NON-ACCESSIBILITY +# TEXT SIZE. No scroll, no dismissal, no visibility assertion on the button itself. The +# oracle is the state change: `journal-saved-state` is produced ONLY by handleSave's success +# branch, so it cannot be reached without the tap having landed — and handleSave is the only +# caller of scanOnSave, the app's sole crisis scan of text a user typed or corrected. +- tapOn: + id: "journal-save-button" +- extendedWaitUntil: + visible: + id: "journal-saved-state" + timeout: 8000 diff --git a/app/App.tsx b/app/App.tsx index ce1c0b13..09e28372 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -3,10 +3,10 @@ * Evidence-based mindfulness and cognitive therapy for mental wellness */ -import React, { useEffect, useState, useRef } from 'react'; -import { AppState, AppStateStatus, LogBox } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { LogBox } from 'react-native'; import { StatusBar } from 'expo-status-bar'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import * as Sentry from '@sentry/react-native'; import CleanRootNavigator from './src/core/navigation/CleanRootNavigator'; @@ -17,7 +17,6 @@ import { logCrisis } from './src/core/services/logging'; import { IAPService } from './src/core/services/subscription/IAPService'; import { useSubscriptionStore } from './src/core/stores/subscriptionStore'; import EncryptionService from './src/core/services/security/EncryptionService'; -import { useSettingsStore } from './src/core/stores/settingsStore'; import { initializeExternalReporting, logSystem, logError, LogCategory } from './src/core/services/logging'; import { sweepStaleAudioArtifacts } from './src/core/services/speech/audioArtifactSweeper'; import { sweepLegacyPlaintextRecords } from './src/core/services/security/legacyPlaintextRecordSweeper'; @@ -44,7 +43,6 @@ if (__DEV__ && process.env['EXPO_PUBLIC_E2E_SUPPRESS_DEV_MENU'] === '1') { function App() { const [isInitialized, setIsInitialized] = useState(false); - const appState = useRef(AppState.currentState); // FEAT-284: shake-to-report (internal builds only; no-ops when the // bug_reporting flag is off or Sentry has no DSN). @@ -175,26 +173,12 @@ function App() { return () => clearTimeout(t); }, []); - // Track app state changes to update lastActiveTimestamp for intro animation - useEffect(() => { - const handleAppStateChange = (nextAppState: AppStateStatus) => { - // When app goes to background or becomes inactive, record timestamp - if ( - appState.current === 'active' && - (nextAppState === 'background' || nextAppState === 'inactive') - ) { - logSystem('App backgrounded, recording lastActive timestamp'); - useSettingsStore.getState().setLastActiveTimestamp(Date.now()); - } - appState.current = nextAppState; - }; - - const subscription = AppState.addEventListener('change', handleAppStateChange); - - return () => { - subscription?.remove(); - }; - }, []); + // INFRA-542: the AppState listener that recorded lastActiveTimestamp used to + // live here. It MOVED — it did not gain a sibling — to AppLifecycleTracker, + // rendered under below. This component renders that + // provider, so a listener here sits ABOVE it, where usePostHog() is + // undefined and any analytics emit would silently early-return forever. + // The timestamp write itself is unchanged and still unconditional. // Render app immediately - migration runs in background return ( @@ -206,7 +190,25 @@ function App() { // release). Do not remove — the crisis button's swipe affordance depends on it. - + {/* + DEBUG-559: initialMetrics is a CRISIS fix, not a polish item. + + SafeAreaProvider renders NOTHING — literally `null` — until its native + insets round-trip lands (SafeAreaContext.tsx: `{insets != null ? … : null}`, + seeded from `initialMetrics?.insets ?? initialSafeAreaInsets ?? parentInsets`). + This is the outermost such provider, so without initialMetrics that seed is + null on every mount. And EVERY 988 affordance in the app is a descendant: + RootCrisisButton, CrisisKeyboardAccessory, RootCrisisBoundary's + Static988Button fallback, and LoadingScreen's. So any remount of this + subtree is not a FAB gap that LoadingScreen's static button covers — the + static button is inside the curtain too. It is a blank, zero-988 screen. + + `initialWindowMetrics` is read synchronously at bridge init, so children + paint in the SAME commit as the mount. DEBUG-559 removed the consent-grant + trigger for that remount; this removes the CONSEQUENCE for every other + cause, including cold launch. Keep both — they are independent. + */} + {/* DEBUG-341: the app had NO error boundary above CleanRootNavigator, so any diff --git a/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts b/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts new file mode 100644 index 00000000..4a5db377 --- /dev/null +++ b/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts @@ -0,0 +1,199 @@ +/** + * DEBUG-550 — `completeAssessment` refuses to score an answer set that is not + * exactly the expected question ids. + * + * WHAT THE ITEM SAID, AND WHAT IS ACTUALLY WRONG + * ---------------------------------------------- + * The item was filed as "a short PHQ-9 scores as if complete, producing a + * sub-threshold total". That is NOT reachable: `ClinicalScoringService` + * already throws on a wrong COUNT (assessmentStore.ts:220 for PHQ-9, :250 for + * GAD-7), so a short set has never produced a banded result. + * + * A count is not a completeness check, and that gap IS reachable. Nine answers + * where one question id repeats and `phq9_9` is absent passes the count check, + * sums, bands, and computes `suicidalIdeation` from `find('phq9_9') === undefined` + * — i.e. reports NO self-harm risk because the self-harm question is missing. + * That is a Q9 false negative from a set that looks complete, and it is the + * opposite of what the item's body assumed about the self-harm path. + * + * `validateCurrentAnswers` (assessmentStore.ts:924) is NOT a drop-in fix and is + * not used as one. It is a PRESENCE check with zero callers, and it is + * incomparable to the shipped count check rather than stronger: ten answers + * carrying one extra pass presence but fail count, while nine answers with a + * duplicate pass count but fail presence. Substituting it would LOSE a case that + * is caught today. The guard here is set EQUALITY — exactly the expected ids, + * each exactly once — which is strictly stronger than both. + * + * WHY REFUSE RATHER THAN SCORE-AND-FLAG. A partial-flagged result still needs a + * severity band to render, still enters `completedAssessments`, and flows from + * there into history, trends and cloud backup. Every downstream reader would have + * to learn the flag or silently treat the under-total as real, so the false + * negative would move downstream and multiply. + */ + +import { useAssessmentStore } from '@/features/assessment/stores/assessmentStore'; +import type { AssessmentAnswer } from '@/features/assessment/types'; + +const state = () => useAssessmentStore.getState(); + +/** Seed the store directly so we can author answer shapes the UI cannot produce. */ +function seedSession(type: 'phq9' | 'gad7', answers: AssessmentAnswer[]): void { + useAssessmentStore.setState({ + currentSession: { + id: `debug550-${type}-session`, + type, + startedAt: new Date().toISOString(), + progress: { currentQuestion: 0, totalQuestions: type === 'phq9' ? 9 : 7, isComplete: false, answers }, + }, + answers, + currentResult: null, + completionBlocked: null, + error: null, + } as unknown as Parameters[0]); +} + +function answer(questionId: string, response: number): AssessmentAnswer { + return { questionId, response, timestamp: new Date().toISOString() } as unknown as AssessmentAnswer; +} + +describe('DEBUG-550 — completeAssessment refuses a malformed answer set', () => { + beforeEach(() => { + useAssessmentStore.setState({ + currentSession: null, + answers: [], + currentResult: null, + completedAssessments: [], + completionBlocked: null, + error: null, + } as unknown as Parameters[0]); + }); + + describe('the Q9 false negative — count satisfied, completeness not', () => { + it('refuses nine PHQ-9 answers where one id repeats and phq9_9 is absent', async () => { + // Exactly 9 entries, so the shipped count check passes. Only 8 distinct + // ids, and the missing one is the self-harm question. + const answers = [ + answer('phq9_1', 1), answer('phq9_2', 1), answer('phq9_3', 1), + answer('phq9_4', 1), answer('phq9_5', 1), answer('phq9_6', 1), + answer('phq9_7', 1), answer('phq9_8', 1), + answer('phq9_8', 1), // duplicate; phq9_9 never answered + ]; + seedSession('phq9', answers); + + await state().completeAssessment(); + + // Against pre-fix code this set scores 9 and bands 'mild', with + // suicidalIdeation false because phq9_9 is simply absent. + expect(state().currentResult).toBeNull(); + expect(state().completedAssessments).toHaveLength(0); + expect(state().completionBlocked?.reason).toBe('incomplete_answers'); + expect(state().completionBlocked?.missingQuestionIds).toEqual(['phq9_9']); + }); + + it('refuses the GAD-7 mirror', async () => { + const answers = [ + answer('gad7_1', 2), answer('gad7_2', 2), answer('gad7_3', 2), + answer('gad7_4', 2), answer('gad7_5', 2), answer('gad7_6', 2), + answer('gad7_6', 2), // duplicate; gad7_7 never answered + ]; + seedSession('gad7', answers); + + await state().completeAssessment(); + + expect(state().currentResult).toBeNull(); + expect(state().completedAssessments).toHaveLength(0); + expect(state().completionBlocked?.missingQuestionIds).toEqual(['gad7_7']); + }); + }); + + describe('the short set — already refused, now refused with a reason', () => { + it('names every unanswered PHQ-9 question rather than failing opaquely', async () => { + seedSession('phq9', [ + answer('phq9_1', 1), answer('phq9_2', 1), answer('phq9_3', 1), + answer('phq9_4', 1), answer('phq9_5', 1), + ]); + + await state().completeAssessment(); + + expect(state().currentResult).toBeNull(); + // Pre-fix this was already null (the count check throws), but the throw was + // swallowed into `error` and nothing named WHICH questions were missing — + // so the flow had nothing to route the user back to. This half is the red. + expect(state().completionBlocked?.missingQuestionIds).toEqual([ + 'phq9_6', 'phq9_7', 'phq9_8', 'phq9_9', + ]); + }); + + it('leaves the session intact so nothing the user entered is lost', async () => { + seedSession('phq9', [answer('phq9_1', 3), answer('phq9_2', 3)]); + + await state().completeAssessment(); + + expect(state().currentSession).not.toBeNull(); + expect(state().answers).toHaveLength(2); + }); + }); + + describe('a refusal does not strand a stale result', () => { + it('nulls currentResult so a previous assessment cannot render as this one', async () => { + // recoverSession does not clear currentResult, so a second assessment + // completed-then-refused in one app session could otherwise render the + // earlier banded result — and SyncCoordinator's null->non-null transition + // would re-evaluate it for crisis. + seedSession('phq9', [answer('phq9_1', 1)]); + useAssessmentStore.setState({ + currentResult: { totalScore: 24, severity: 'severe' }, + } as unknown as Parameters[0]); + + await state().completeAssessment(); + + expect(state().currentResult).toBeNull(); + }); + }); + + describe('regression — a well-formed set is completely unaffected', () => { + it('still scores and bands a severe PHQ-9 (the phq9-severe-completion invariant)', async () => { + seedSession('phq9', [ + answer('phq9_1', 3), answer('phq9_2', 3), answer('phq9_3', 3), + answer('phq9_4', 3), answer('phq9_5', 3), answer('phq9_6', 3), + answer('phq9_7', 3), answer('phq9_8', 3), answer('phq9_9', 0), + ]); + + await state().completeAssessment(); + + expect(state().completionBlocked).toBeNull(); + expect(state().currentResult).not.toBeNull(); + expect(state().currentResult?.totalScore).toBe(24); + expect(state().completedAssessments).toHaveLength(1); + }); + + it('still scores and bands a severe GAD-7 (the gad7-severe invariant)', async () => { + seedSession('gad7', [ + answer('gad7_1', 3), answer('gad7_2', 3), answer('gad7_3', 3), + answer('gad7_4', 3), answer('gad7_5', 3), answer('gad7_6', 3), + answer('gad7_7', 3), + ]); + + await state().completeAssessment(); + + expect(state().completionBlocked).toBeNull(); + expect(state().currentResult?.totalScore).toBe(21); + }); + + it('a complete set with Q9 > 0 still reports self-harm risk', async () => { + // The invariant q9-single-alert pins. Q9 is answered, so the guard is + // invisible here — it must stay invisible. + seedSession('phq9', [ + answer('phq9_1', 0), answer('phq9_2', 0), answer('phq9_3', 0), + answer('phq9_4', 0), answer('phq9_5', 0), answer('phq9_6', 0), + answer('phq9_7', 0), answer('phq9_8', 0), answer('phq9_9', 2), + ]); + + await state().completeAssessment(); + + expect(state().completionBlocked).toBeNull(); + expect(state().currentResult).not.toBeNull(); + expect((state().currentResult as { suicidalIdeation?: boolean })?.suicidalIdeation).toBe(true); + }); + }); +}); diff --git a/app/__tests__/helpers/phiFilterBaselineV1.ts b/app/__tests__/helpers/phiFilterBaselineV1.ts new file mode 100644 index 00000000..f51d7728 --- /dev/null +++ b/app/__tests__/helpers/phiFilterBaselineV1.ts @@ -0,0 +1,168 @@ +/** + * FROZEN BASELINE — DO NOT EDIT (INFRA-535). + * + * A verbatim copy of `PHIFilter`'s whitelist, blocklist, safe-numeric set and + * `validate()` as they stood at commit `d14d6178`, before the INFRA-535 + * scan-surface tightening. It exists so the differential test can compare the + * live filter against a fixed reference rather than against itself. + * + * FOUR RULES, each load-bearing: + * + * 1. This file MUST NOT import from `@/core/analytics/PHIFilter`. A baseline + * that imports the implementation compares the implementation to itself and + * is green by construction, whatever the implementation does. + * + * 2. This file MUST NOT be reachable from `app/src/`. It is a working copy of a + * LOOSER filter; if it ever became importable from src, a mis-import would + * silently restore pre-change semantics on a module that is eager on the + * crisis path (`CrisisResourcesScreen.tsx` imports the analytics barrel), and + * no path-based safety detector would see it. `phiFilterDifferential.privacy.test.ts` + * asserts mechanically that nothing under `app/src/` references it. + * + * 3. It lives in `__tests__/helpers/` and nowhere else. jest `testMatch` globs + * `/__tests__/**\/*` and `testPathIgnorePatterns` exempts exactly + * `setup/ utils/ reporters/ helpers/` — so a `__tests__/privacy/fixtures/` + * directory would be collected as a suite and fail "Your test suite must + * contain at least one test." + * + * 4. This file is NEVER AMENDED to track a newly-added event type, and the + * `BASELINE_SAFE_EVENT_TYPES.size` pin in the differential is NEVER BUMPED. + * Both are anti-tamper guards on a fixed reference, not a headcount of the + * live whitelist. Adding a live event type here would restore the + * compare-to-itself failure rule 1 exists to prevent. A legitimate widening + * is recorded in the differential's `WIDENED` ledger instead — see the + * amendment procedure in that file's header (INFRA-558). + * + * DELIBERATE DEVIATION FROM VERBATIM: every `logSecurity(...)` call in the + * original `validate()` has been REMOVED. The baseline is called thousands of + * times by the differential corpus and its logging is not under test; keeping the + * calls would flood the audit ring during the suite. Nothing else was changed — + * no keyword added or removed, no branch reordered, no condition altered. + */ + +export interface BaselineValidationResult { + valid: boolean; + reason?: string; +} + +/** Verbatim as of d14d6178. */ +export const BASELINE_SAFE_EVENT_TYPES: ReadonlySet = new Set([ + 'app_opened', + 'app_backgrounded', + 'session_started', + 'session_ended', + 'screen_viewed', + 'check_in_started', + 'check_in_completed', + 'assessment_started', + 'assessment_completed', + 'practice_started', + 'practice_completed', + 'breathing_exercise_started', + 'breathing_exercise_completed', + 'crisis_resources_viewed', + 'crisis_hotline_tapped', + 'settings_opened', + 'consent_changed', + 'error_occurred', + 'onboarding_started', + 'onboarding_completed', + 'onboarding_step_completed', + 'learn_content_viewed', + 'learn_module_started', + 'learn_module_completed', + 'guidance_opened', +]); + +/** Verbatim as of d14d6178 — 28 entries. */ +export const BASELINE_PHI_KEYWORDS: ReadonlyArray = [ + 'score', + 'phq', + 'gad', + 'severity', + 'result', + 'mood', + 'feeling', + 'emotion', + 'anxious', + 'depressed', + 'crisis_contact', + 'emergency_contact', + 'hotline_number', + 'suicid', + 'harm', + 'journal', + 'note', + 'entry', + 'reflection', + 'thought', + 'email', + 'phone', + 'name', + 'address', + 'conflict', + 'career', + 'grief', + 'pain', +]; + +/** Verbatim as of d14d6178. */ +export const BASELINE_SAFE_NUMERIC_KEYS: ReadonlySet = new Set([ + 'duration', + 'duration_ms', + 'duration_seconds', + 'count', + 'timestamp', + 'step', + 'index', + 'page', + 'version', +]); + +/** + * Verbatim as of d14d6178, minus the `logSecurity` calls (see header). + * + * Note what it does NOT do, because these are the gaps INFRA-535 closes and the + * differential must therefore permit as one-sided tightenings: + * - it never scans property KEYS at all; + * - it never calls `containsPHI`; + * - step 4 excludes arrays, so `{tags:['grief']}` passes intact. + */ +export function validateV1( + eventType: string, + eventData: Record +): BaselineValidationResult { + // 1. WHITELIST CHECK + if (!BASELINE_SAFE_EVENT_TYPES.has(eventType)) { + return { valid: false, reason: `Event type "${eventType}" not in whitelist` }; + } + + // 2. PHI KEYWORD CHECK: VALUES only + for (const [key, value] of Object.entries(eventData)) { + if (typeof value === 'string') { + const lowerValue = value.toLowerCase(); + for (const keyword of BASELINE_PHI_KEYWORDS) { + if (lowerValue.includes(keyword)) { + return { valid: false, reason: `PHI keyword detected: "${keyword}" in key "${key}"` }; + } + } + } + } + + // 3. NUMERIC VALUE CHECK + for (const [key, value] of Object.entries(eventData)) { + if (typeof value === 'number' && !BASELINE_SAFE_NUMERIC_KEYS.has(key)) { + return { valid: false, reason: `Suspicious numeric value in key: "${key}"` }; + } + } + + // 4. NESTED OBJECT CHECK — note the deliberate `!Array.isArray` exclusion. + for (const [, value] of Object.entries(eventData)) { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const nested = validateV1(eventType, value as Record); + if (!nested.valid) return nested; + } + } + + return { valid: true }; +} diff --git a/app/__tests__/integration/learn/practices/BodyScanScreen.test.tsx b/app/__tests__/integration/learn/practices/BodyScanScreen.test.tsx index 3e567a39..ba4bda1e 100644 --- a/app/__tests__/integration/learn/practices/BodyScanScreen.test.tsx +++ b/app/__tests__/integration/learn/practices/BodyScanScreen.test.tsx @@ -168,6 +168,10 @@ jest.mock('@/features/learn/practices/shared/usePracticeCompletion', () => ({ ); }, + // DEBUG-536: the hook now returns markStarted alongside markComplete, and the + // timer screens call it on first activation. A mock missing it throws + // "markStarted is not a function" the moment the toggle is pressed. + markStarted: jest.fn(), markComplete: () => { setShowCompletion(true); }, diff --git a/app/__tests__/integration/learn/practices/PracticeTimerScreen.test.tsx b/app/__tests__/integration/learn/practices/PracticeTimerScreen.test.tsx index 7d4d5e4e..0f9daf6e 100644 --- a/app/__tests__/integration/learn/practices/PracticeTimerScreen.test.tsx +++ b/app/__tests__/integration/learn/practices/PracticeTimerScreen.test.tsx @@ -139,6 +139,10 @@ jest.mock('@/features/learn/practices/shared/usePracticeCompletion', () => ({ ); }, + // DEBUG-536: the hook now returns markStarted alongside markComplete, and the + // timer screens call it on first activation. A mock missing it throws + // "markStarted is not a function" the moment the toggle is pressed. + markStarted: jest.fn(), markComplete: () => { setShowCompletion(true); }, diff --git a/app/__tests__/integration/learn/practices/ReflectionTimerScreen.test.tsx b/app/__tests__/integration/learn/practices/ReflectionTimerScreen.test.tsx index d0e28b32..9c6d7a40 100644 --- a/app/__tests__/integration/learn/practices/ReflectionTimerScreen.test.tsx +++ b/app/__tests__/integration/learn/practices/ReflectionTimerScreen.test.tsx @@ -117,6 +117,10 @@ jest.mock('@/features/learn/practices/shared/usePracticeCompletion', () => ({ ); }, + // DEBUG-536: the hook now returns markStarted alongside markComplete, and the + // timer screens call it on first activation. A mock missing it throws + // "markStarted is not a function" the moment the toggle is pressed. + markStarted: jest.fn(), markComplete: () => { setShowCompletion(true); }, diff --git a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts new file mode 100644 index 00000000..7949919a --- /dev/null +++ b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts @@ -0,0 +1,190 @@ +/** + * Tracker contract (INFRA-535, PR2). + * + * The item's #1 declared success metric: "100% of trackers have a green + * contract-test assertion that their real payload passes validate() with zero + * redactions." + * + * WHY THIS EXISTS. `trackEvent` calls `PHIFilter.validate` and captures ONLY when + * it returns valid; on a block it logs and returns. Nothing throws. So a tracker + * whose payload the filter rejects looks completely correct at review, emits + * nothing forever, and reads in PostHog as "nobody did it" rather than as a + * defect. That is how FEAT-137 closed Done. This suite makes that failure + * mechanical instead of silent: it drives every tracker the hook actually returns + * and asserts each one reaches the sink. + * + * It is also the instrument for the scan-surface tightening shipped alongside it. + * Widening what the filter looks at can only be trusted if something proves the + * widening did not start eating live trackers — a hand-picked pair of "named false + * positives" is not that proof. This is. + * + * HOW IT CANNOT GO VACUOUS (DEBUG-390): + * - the key list is DERIVED from the live hook, so a new tracker appears here + * automatically; + * - a derived key with no hand-authored fixture FAILS rather than being skipped, + * so the suite cannot quietly shrink to the trackers someone remembered; + * - fixtures are hand-authored, never generated from the implementation; + * - the enumeration itself is asserted non-empty and at a pinned minimum size. + */ + +const mockCapture = jest.fn(); +jest.mock('posthog-react-native', () => ({ + usePostHog: () => ({ capture: (...args: unknown[]) => mockCapture(...args) }), +})); + +import { renderHook } from '@testing-library/react-native'; +import { useAnalytics } from '@/core/analytics/useAnalytics'; +import { PHIFilter, AnalyticsEvents } from '@/core/analytics/PHIFilter'; +import { useConsentStore } from '@/core/stores/consentStore'; + +/** + * DEBUG-559: granting consent is now a PRECONDITION of this suite, and that is a + * real change in what it proves rather than boilerplate. + * + * `trackEvent` used to gate on `!posthog` alone, which stood in for consent only + * because `PostHogProvider` withheld the client without it. That withholding was + * an element-type swap that remounted every 988 affordance in the app, so it had + * to go — and the emit path now reads the consent store directly. Without this + * the mocked client is present, the consent store is empty, and every tracker + * below correctly emits nothing, which would read as 13 broken trackers. + */ +function grantAnalyticsConsent(): void { + useConsentStore.setState({ + currentConsent: { preferences: { analyticsEnabled: true }, universalOptOut: false }, + } as unknown as Parameters[0]); +} +grantAnalyticsConsent(); + +/** + * HAND-AUTHORED fixtures: tracker name -> the arguments a real call site passes. + * + * Deliberately not derived from the implementation. A fixture generated from the + * code under test would agree with it by construction, including when both are + * wrong. + */ +const FIXTURES: Readonly> = { + trackScreenView: ['HomeScreen'], + // INFRA-542: real call shapes — these two gained emitters and + // properties. A bucketed string, never a raw elapsed number. + trackAppOpened: [true, 'cold_start'], + trackAppBackgrounded: [42], + trackCrisisResourcesViewed: [], + trackCrisisHotlineTapped: [], + trackGuidanceOpened: [], + trackSettingsOpened: [], + trackConsentChanged: [], + trackLearnContentViewed: ['module-1'], + trackLearnModuleStarted: ['module-1'], + trackOnboardingStarted: [], + trackOnboardingStepCompleted: [3], + trackOnboardingCompleted: [], + // DEBUG-536: the six feature-usage lifecycle trackers INFRA-552 removed for + // having zero call sites, restored WITH call sites. Payload shapes are the + // frozen-baseline ones, recovered verbatim from 673bf360^ — duration-only. + // + // Each fixture is the shape the REAL call site passes, not the widest shape the + // signature allows. `trackCheckInCompleted`'s duration is NOT minted at the emit + // site: it is `sessionData.timeSpentSeconds * 1000`, the figure DailyLoopNavigator + // already derived from the same mount-scoped `startTime` that `check_in_started` + // fires from — so the pair agrees by construction. The tracker still omits the + // property when that figure is absent, because `duration_ms` is in + // SAFE_NUMERIC_KEYS and a fabricated one would transmit unchallenged. + trackCheckInStarted: [], + trackCheckInCompleted: [420000], + trackAssessmentStarted: [], + trackAssessmentCompleted: [180000], + trackPracticeStarted: [], + trackPracticeCompleted: [300000], +}; + +/** + * `trackEvent` is the generic escape hatch: it takes an arbitrary event name and + * arbitrary properties, so there is no "real payload" to pin. Every NAMED tracker + * routes through it, which is what this suite actually covers. Excluded explicitly + * rather than by omission, so the exclusion is reviewable. + */ +const EXCLUDED = new Set(['trackEvent']); + +/** Pinned floor: 19 named trackers today (DEBUG-536 restored 6). Growth fine, shrinkage red. */ +const MIN_TRACKERS = 19; + +describe('every useAnalytics tracker transmits (INFRA-535)', () => { + grantAnalyticsConsent(); + const { result } = renderHook(() => useAnalytics()); + const allKeys = Object.keys(result.current).filter( + (k) => typeof (result.current as Record)[k] === 'function' + ); + const trackerKeys = allKeys.filter((k) => !EXCLUDED.has(k)); + + describe('the enumeration is real (anti-vacuity)', () => { + it('derives a non-empty tracker list from the live hook', () => { + expect(trackerKeys.length).toBeGreaterThanOrEqual(MIN_TRACKERS); + }); + + it('every derived tracker has a hand-authored fixture', () => { + // A key without a fixture FAILS. It must not be silently skipped: that is + // exactly how a new tracker would ship uncovered. + const missing = trackerKeys.filter((k) => !(k in FIXTURES)); + expect(missing).toEqual([]); + }); + + it('every fixture corresponds to a real tracker (no dead fixtures)', () => { + const orphaned = Object.keys(FIXTURES).filter((k) => !trackerKeys.includes(k)); + expect(orphaned).toEqual([]); + }); + + it('the excluded key really is on the hook', () => { + for (const key of EXCLUDED) expect(allKeys).toContain(key); + }); + }); + + describe('each tracker reaches the sink with its real payload', () => { + it.each(Object.keys(FIXTURES).map((k) => [k] as const))('%s', (name) => { + mockCapture.mockClear(); + + const fn = (result.current as Record void>)[name]; + expect(typeof fn).toBe('function'); + fn(...(FIXTURES[name] as unknown[])); + + // trackEvent captures ONLY when PHIFilter.validate passes, and swallows the + // block otherwise. So "capture was called" IS the assertion that this + // tracker's real payload survives the filter. + expect(mockCapture).toHaveBeenCalledTimes(1); + + // Re-assert directly, for a failure message that names the reason rather + // than just "expected 1 call, got 0". + const [eventName, payload] = mockCapture.mock.calls[0] as [ + string, + Record, + ]; + expect(PHIFilter.validate(eventName, payload)).toEqual({ valid: true }); + }); + }); + + describe('the suite would notice if the filter started eating trackers', () => { + it('a deliberately bad payload on a real event IS rejected', () => { + // Proves the assertion above discriminates — if validate() accepted + // everything, every tracker would pass for the wrong reason. + expect(PHIFilter.validate(AnalyticsEvents.SCREEN_VIEWED, { screen_name: 'grief' }).valid).toBe( + false + ); + expect(PHIFilter.validate('definitely_not_whitelisted', {}).valid).toBe(false); + }); + }); + + describe('catalog constants with no tracker at all (DISCHARGED by INFRA-552)', () => { + it('session_started and session_ended are gone from the catalog entirely', () => { + // Previously these were whitelisted with no tracker function, so the derived + // enumeration above could not see them and this suite could not protect them. + // INFRA-552 deleted both: no session-lifecycle concept exists anywhere in + // app/src, so they were catalog fiction rather than pending work. Kept as an + // assertion rather than deleted with them — re-adding a name the hook cannot + // reach is the exact defect this block was recording. + expect(PHIFilter.isWhitelisted('session_started')).toBe(false); + expect(PHIFilter.isWhitelisted('session_ended')).toBe(false); + + const emitters = trackerKeys.filter((k) => /session/i.test(k)); + expect(emitters).toEqual([]); + }); + }); +}); diff --git a/app/__tests__/privacy/assessmentAnalyticsBoundary.contract.test.ts b/app/__tests__/privacy/assessmentAnalyticsBoundary.contract.test.ts new file mode 100644 index 00000000..8dc9729a --- /dev/null +++ b/app/__tests__/privacy/assessmentAnalyticsBoundary.contract.test.ts @@ -0,0 +1,81 @@ +/** + * DEBUG-536 — the ACCESS-not-CONTENT boundary on the assessment analytics pair. + * + * `assessment_started` / `assessment_completed` say that the wellness-screening flow + * was entered and finished, and how long it took. They must never say WHICH instrument + * was taken, what it scored, or how severe the result was. + * + * This is the same defence-in-depth shape `guidanceAnalyticsBoundary.contract.test.ts` + * gives FEAT-457's `guidance_opened` domain tokens, and it exists because the review + * that permitted these two events rested on exactly this boundary holding. Compliance's + * ruling: they sit inside the published "we never collect any mental health data" + * promise for the same reason `crisis_resources_viewed` / `crisis_hotline_tapped` do — + * bare access signals for a strictly more sensitive affordance, shipping to PostHog + * today. Access is not the finding. + * + * WHY THIS FILE AND NOT A CODE COMMENT: the FILTER IS NOT THE CONTROL for this class. + * `phq`, `gad`, `score` and `severity` are PHI_KEYWORDS, so those particular values + * block — but a key like `instrument: 'depression'` would validate untouched. The + * trackers take no instrument parameter at all, and that absence is what actually + * holds. This suite pins the half the filter DOES cover, so that a future author who + * adds a parameter finds a red rather than a silent success. + */ + +import { PHIFilter } from '@/core/analytics/PHIFilter'; + +const ASSESSMENT_EVENTS = ['assessment_started', 'assessment_completed'] as const; + +describe('assessment analytics boundary (DEBUG-536)', () => { + describe('the real payloads transmit', () => { + it.each(ASSESSMENT_EVENTS)('%s with no properties is valid', (event) => { + expect(PHIFilter.validate(event, {})).toMatchObject({ valid: true }); + }); + + it('assessment_completed with only a duration is valid', () => { + // `duration_ms` is in SAFE_NUMERIC_KEYS; this is the entire real payload. + expect(PHIFilter.validate('assessment_completed', { duration_ms: 180000 })).toMatchObject({ + valid: true, + }); + }); + }); + + describe('instrument identity and findings are rejected', () => { + // Each of these is a property someone could plausibly reach for, and each would + // re-disclose what DEBUG-239's screen-name coarsening exists to suppress. + const FORBIDDEN: ReadonlyArray]> = [ + ['instrument name', { instrument: 'phq9' }], + ['instrument as a bare key', { phq: 1 }], + ['the other instrument', { gad: 1 }], + ['a score', { score: 18 }], + ['a severity bucket', { severity: 'severe' }], + ['a result', { result: 'moderate' }], + ]; + + it.each(FORBIDDEN)('%s is blocked on assessment_completed', (_label, payload) => { + expect(PHIFilter.validate('assessment_completed', payload)).toMatchObject({ + valid: false, + }); + }); + + it('the enumeration is non-empty and the matcher still fires (DEBUG-390)', () => { + // Without this, an empty FORBIDDEN list — or a validate() that started returning + // valid:false for everything — would look identical to a working boundary. + expect(FORBIDDEN.length).toBeGreaterThanOrEqual(6); + expect(PHIFilter.validate('assessment_completed', { duration_ms: 1000 }).valid).toBe(true); + expect(PHIFilter.validate('not_a_real_event', {}).valid).toBe(false); + }); + }); + + describe('the neutral-token laundering route stays closed', () => { + it('documents why a sanitized instrument token is not an escape hatch', () => { + // `wellness_9` / `wellness_7` were REJECTED on review as laundering rather than + // sanitization: they defeat the keyword filter while preserving the inference. + // The filter therefore PASSES them — which is precisely the point, and why the + // control is the trackers' missing parameter plus this recorded ruling, not + // validate(). Asserting the filter blocks them would be asserting a falsehood. + expect(PHIFilter.validate('assessment_started', { instrument: 'wellness_9' })).toMatchObject({ + valid: true, + }); + }); + }); +}); diff --git a/app/__tests__/privacy/bugReportSubmit.contract.test.ts b/app/__tests__/privacy/bugReportSubmit.contract.test.ts new file mode 100644 index 00000000..38eb4d8d --- /dev/null +++ b/app/__tests__/privacy/bugReportSubmit.contract.test.ts @@ -0,0 +1,231 @@ +/** + * PRIVACY / SAFETY CONTRACT — FEAT-570 first-party bug-report submission. + * + * FEAT-284 presented Sentry's own feedback widget. DEBUG-533 ruled that a + * zero-988-affordance window; FEAT-570 replaced the presentation with our own + * form and kept the SDK only as the transport. This file pins the parts of that + * swap that are invisible on screen and would fail SILENTLY: + * + * • the SDK presenter is never called again (the guard pins the call site; + * this pins the behaviour); + * • the kill switch still covers feedback, which it no longer does for free — + * `showFeedbackForm()` used to gate on `isActive()`, so killing the reporter + * also disabled the surface. Presentation is now ours and opens regardless, + * so the check MOVED to the submit path. If that move is ever undone, the + * form still opens, the user still types, and submission still transmits; + * • the payload carries no identity fields; + * • the message is scrubbed at the CALL SITE, independently of the event + * processor — which `initialize()` only registers behind a `typeof` guard, + * so it can legitimately be absent. + * + * @see docs/legal/dpia-sensitive-wellness-data.md §2 (Sentry) + */ + +const mockInit = jest.fn(); +const mockAddEventProcessor = jest.fn(); +const mockCaptureFeedback = jest.fn(); +const mockShowFeedbackWidget = jest.fn(); + +jest.mock('@sentry/react-native', () => ({ + init: (...args: unknown[]) => mockInit(...args), + addEventProcessor: (...args: unknown[]) => mockAddEventProcessor(...args), + captureFeedback: (...args: unknown[]) => mockCaptureFeedback(...args), + // Present in the mock ON PURPOSE. If it were absent, "the presenter is never + // called" would pass because the module lacks it, not because we stopped + // calling it — the vacuous-green shape this repo keeps finding. + showFeedbackWidget: (...args: unknown[]) => mockShowFeedbackWidget(...args), + feedbackIntegration: jest.fn((opts: unknown) => ({ name: 'MobileFeedback', options: opts })), +})); + +// `bug_reporting` is a BUILD-TIME flag read from EXPO_PUBLIC_FEATURE_FLAGS, which +// is unset under jest — so without this every `showFeedbackForm()` case would +// pass through the flag guard and prove nothing. Mocked rather than stubbed into +// the env so the guard itself can be exercised in both directions below. +const mockIsFeatureEnabled = jest.fn(() => true); +jest.mock('@/core/services/featureFlags', () => ({ + isFeatureEnabled: (...args: unknown[]) => mockIsFeatureEnabled(...(args as [])), +})); + +import { ExternalErrorReporter } from '@/core/services/logging/ExternalErrorReporter'; +import { useBugReportStore } from '@/core/stores/bugReportStore'; + +const TEST_DSN = 'https://examplePublicKey@o0.ingest.sentry.io/0'; + +async function freshReporter(): Promise { + (ExternalErrorReporter as any).instance = undefined; + jest.clearAllMocks(); + const reporter = ExternalErrorReporter.getInstance(); + await reporter.initialize(TEST_DSN); + mockIsFeatureEnabled.mockReturnValue(true); + return reporter; +} + +beforeEach(() => { + useBugReportStore.setState({ visible: false }); +}); + +describe('FEAT-570 · the SDK presenter is gone', () => { + it('never calls showFeedbackWidget when the form is opened', async () => { + const reporter = await freshReporter(); + reporter.showFeedbackForm(); + expect(mockShowFeedbackWidget).not.toHaveBeenCalled(); + }); + + it('opens our own form instead', async () => { + const reporter = await freshReporter(); + reporter.showFeedbackForm(); + expect(useBugReportStore.getState().visible).toBe(true); + }); + + it('is gated by the bug_reporting flag, in ONE place both entries funnel through', async () => { + // crisis constraint: the flag check lives at showFeedbackForm(), not + // duplicated by convention at each call site. The store stays flag-free. + const reporter = await freshReporter(); + mockIsFeatureEnabled.mockReturnValue(false); + + reporter.showFeedbackForm(); + expect(useBugReportStore.getState().visible).toBe(false); + }); + + it('the mocked presenter is callable — the assertion above is not vacuous', () => { + // Without this, deleting `showFeedbackWidget` from the mock would make the + // first case pass for the wrong reason. + const sentry = require('@sentry/react-native'); + expect(typeof sentry.showFeedbackWidget).toBe('function'); + }); +}); + +describe('FEAT-570 · the kill switch covers feedback on the SUBMIT path', () => { + it('refuses submission once the reporter is killed', async () => { + const reporter = await freshReporter(); + reporter.kill(); + + expect(reporter.submitFeedback('the send button does nothing')).toBe(false); + expect(mockCaptureFeedback).not.toHaveBeenCalled(); + }); + + it('accepts submission while the reporter is active — the refusal is real', async () => { + const reporter = await freshReporter(); + expect(reporter.submitFeedback('the send button does nothing')).toBe(true); + expect(mockCaptureFeedback).toHaveBeenCalledTimes(1); + }); + + it('reads `killed` FRESH at submit time, not from when the form opened', async () => { + // kill() is an emergency circuit breaker and can fire while the form is + // already open with text in it. A value cached at open time would submit + // straight through it. + const reporter = await freshReporter(); + reporter.showFeedbackForm(); + expect(useBugReportStore.getState().visible).toBe(true); + + reporter.kill(); + + expect(reporter.submitFeedback('typed before the kill landed')).toBe(false); + expect(mockCaptureFeedback).not.toHaveBeenCalled(); + }); + + it('still OPENS the form while killed — refusing to open would be a silent no-op', async () => { + // Ruled by crisis + compliance: the kill switch changes what the form + // RENDERS (no input, no submit control), never whether the entry point + // responds. A Profile card that advertises "Report a bug" and does nothing + // is the silence this item exists to remove. + const reporter = await freshReporter(); + reporter.kill(); + reporter.showFeedbackForm(); + expect(useBugReportStore.getState().visible).toBe(true); + }); +}); + +describe('FEAT-570 · the submitted payload carries no identity', () => { + it('sends message + source and nothing else', async () => { + const reporter = await freshReporter(); + reporter.submitFeedback('the timer resets when I background the app'); + + const [params] = mockCaptureFeedback.mock.calls[0]; + expect(params).toEqual({ + message: 'the timer resets when I background the app', + source: 'first-party-form', + }); + }); + + it('omits name, email and associatedEventId at SOURCE', async () => { + // Omitted rather than deleted downstream. `scrubFeedbackEvent` strips + // associated_event_id but never reads contact_email or name, so relying on + // the processor for these would rely on something that does not happen. + const reporter = await freshReporter(); + reporter.submitFeedback('a report'); + + const [params] = mockCaptureFeedback.mock.calls[0]; + expect(params).not.toHaveProperty('name'); + expect(params).not.toHaveProperty('email'); + expect(params).not.toHaveProperty('associatedEventId'); + }); + + it('is not wrapped in a scope — the event processor must still see it', async () => { + // `scrubFeedbackEvent` is registered on the ISOLATION scope via + // addEventProcessor. captureFeedback is called with ONE argument, so no + // scope override is passed. + const reporter = await freshReporter(); + reporter.submitFeedback('a report'); + expect(mockCaptureFeedback.mock.calls[0]).toHaveLength(1); + }); +}); + +describe('FEAT-570 · the message is scrubbed at the call site', () => { + it('redacts an inline email before the event is built', async () => { + const reporter = await freshReporter(); + reporter.submitFeedback('reach me at leaked@example.com about this'); + + const [params] = mockCaptureFeedback.mock.calls[0]; + expect(params.message).not.toContain('leaked@example.com'); + expect(params.message).toContain('[REDACTED]'); + }); + + it('truncates to the 500-char contract', async () => { + // Deliberately PROSE, not `'x'.repeat(900)`: a long single-character run + // matches one of SENSITIVE_DATA_PATTERNS' secret shapes and scrubs to + // `[REDACTED]`, so that fixture never reached the truncation it claimed to + // test — it was 10 characters long and passing for the wrong reason. + const reporter = await freshReporter(); + reporter.submitFeedback('the breathing timer drifts a little every cycle. '.repeat(30)); + + const [params] = mockCaptureFeedback.mock.calls[0]; + expect(params.message).toHaveLength(500); + }); + + it('refuses a message that scrubs down to nothing', async () => { + const reporter = await freshReporter(); + expect(reporter.submitFeedback(' ')).toBe(false); + expect(mockCaptureFeedback).not.toHaveBeenCalled(); + }); +}); + +describe('FEAT-570 · the integration stays registered as an identity floor', () => { + it('registers feedbackIntegration with showName and showEmail false', async () => { + await freshReporter(); + const options = mockInit.mock.calls[0][0]; + const integrations = options.integrations([]); + const feedback = integrations.find((i: any) => i.name === 'MobileFeedback'); + + // Not decorative. With no MobileFeedback integration registered, + // getFeedbackOptions() returns {} and FeedbackWidget falls back to + // defaultProps, where showName and showEmail are BOTH true. scrubFeedbackEvent + // never reads contact_email or name, so this registration is the only control + // on them if a presenter call is ever reintroduced. + expect(feedback).toBeDefined(); + expect(feedback.options.showName).toBe(false); + expect(feedback.options.showEmail).toBe(false); + }); + + it('pins both screenshot options OFF rather than omitting them', async () => { + // The SDK defaults agree today, but omission would inherit whatever a future + // SDK bump makes the default, and the drift direction is toward capturing — + // over a mid-assessment or journal screen, on a root-armed surface. + await freshReporter(); + const options = mockInit.mock.calls[0][0]; + const feedback = options.integrations([]).find((i: any) => i.name === 'MobileFeedback'); + + expect(feedback.options.enableScreenshot).toBe(false); + expect(feedback.options.enableTakeScreenshot).toBe(false); + }); +}); diff --git a/app/__tests__/privacy/featureUsageConsentGate.privacy.test.tsx b/app/__tests__/privacy/featureUsageConsentGate.privacy.test.tsx new file mode 100644 index 00000000..34481c8d --- /dev/null +++ b/app/__tests__/privacy/featureUsageConsentGate.privacy.test.tsx @@ -0,0 +1,130 @@ +/** + * DEBUG-536 — the consent gate on the six restored feature-usage trackers. + * + * `trackEvent` (useAnalytics.ts) is the consent gate every tracker funnels through. + * Nothing asserted that before this item, and this item adds emit points across five + * feature areas. + * + * MECHANISM CORRECTED BY DEBUG-559. This file was authored when the gate was + * `if (!posthog) return;` alone, on the reasoning that declining consent meant no + * `` was mounted so `usePostHog()` returned null. That equivalence was a + * side effect of a defect: withholding the provider swapped the element type above + * `SafeAreaProvider` and remounted every 988 affordance in the app on a consent tap. + * With that fixed the provider is always mounted, a client exists from launch, and + * client presence is no longer a consent signal — so the halves below set the CONSENT + * STORE, which is what `trackEvent` now reads. The suite's shape and intent are + * unchanged. The "client present but consent denied" axis this correction opens is + * pinned separately in `src/core/analytics/__tests__/useAnalytics.consentGate.privacy.test.tsx`. + * + * The negative half alone would be worthless. A tracker that is broken, misnamed, or + * never reached produces the identical "zero captures" reading as a tracker correctly + * suppressed by the gate. So this suite is deliberately TWO halves over the SAME + * hand-listed six and the SAME payloads: consent withheld must capture nothing, + * consent granted must capture all six. Only the pair distinguishes "the gate held" + * from "the code never ran" (DEBUG-390). + * + * The six are hand-listed rather than derived from the hook. Deriving would silently + * follow the hook if a tracker were renamed or dropped; the point here is to pin + * exactly the six this item restored. + */ + +import { renderHook } from '@testing-library/react-native'; +import { useAnalytics } from '@/core/analytics'; +import { useConsentStore } from '@/core/stores/consentStore'; + +/** DEBUG-559: trackEvent reads the consent store, not the client's presence. */ +const setAnalyticsConsent = (analyticsEnabled: boolean): void => { + useConsentStore.setState({ + currentConsent: { preferences: { analyticsEnabled }, universalOptOut: false }, + } as unknown as Parameters[0]); +}; + +const mockCapture = jest.fn(); +let mockClient: { capture: jest.Mock } | null = null; + +jest.mock('posthog-react-native', () => ({ + usePostHog: () => mockClient, +})); + +/** + * The six restored trackers and the arguments their real call sites pass. Kept + * in step with `analyticsTrackerContract.privacy.test.ts`'s FIXTURES, but written + * out separately on purpose — a shared constant would let one edit move both halves + * of a control and its own check. + */ +const RESTORED: ReadonlyArray = [ + ['trackCheckInStarted', []], + ['trackCheckInCompleted', [420000]], + ['trackAssessmentStarted', []], + ['trackAssessmentCompleted', [180000]], + ['trackPracticeStarted', []], + ['trackPracticeCompleted', [300000]], +]; + +const invokeAll = (): void => { + const { result } = renderHook(() => useAnalytics()); + for (const [name, args] of RESTORED) { + const fn = (result.current as Record void>)[name]; + expect(typeof fn).toBe('function'); + fn(...(args as unknown[])); + } +}; + +describe('DEBUG-536 feature-usage trackers respect the analytics consent gate', () => { + beforeEach(() => { + mockCapture.mockClear(); + }); + + it('pins exactly the six trackers this item restored', () => { + // Anti-vacuity: an empty or truncated list makes both halves below pass over + // nothing, in exactly the same way. + expect(RESTORED).toHaveLength(6); + }); + + describe('consent WITHHELD — declined in the consent store', () => { + beforeEach(() => { + mockClient = null; + setAnalyticsConsent(false); + }); + + it('none of the six reaches PostHog', () => { + invokeAll(); + expect(mockCapture).toHaveBeenCalledTimes(0); + }); + }); + + describe('consent GRANTED — granted in the store, live client mounted', () => { + beforeEach(() => { + mockClient = { capture: mockCapture }; + setAnalyticsConsent(true); + }); + + it('all six reach PostHog, which is what makes the suppression above meaningful', () => { + invokeAll(); + expect(mockCapture).toHaveBeenCalledTimes(6); + }); + + it('each captured event carries its restored name and nothing self-disclosing', () => { + invokeAll(); + const captured = mockCapture.mock.calls.map(([name]) => name as string); + expect(captured).toEqual([ + 'check_in_started', + 'check_in_completed', + 'assessment_started', + 'assessment_completed', + 'practice_started', + 'practice_completed', + ]); + + // ACCESS, never CONTENT. The only properties any of the six may carry are a + // duration — no score, no instrument, no severity, no practice identity, no + // free text. + const allowed = new Set(['duration_ms']); + for (const [, props] of mockCapture.mock.calls) { + for (const key of Object.keys((props ?? {}) as Record)) { + expect(allowed.has(key)).toBe(true); + } + } + }); + }); +}); diff --git a/app/__tests__/privacy/feedbackScrub.contract.test.ts b/app/__tests__/privacy/feedbackScrub.contract.test.ts index e9c003c6..f003ecd4 100644 --- a/app/__tests__/privacy/feedbackScrub.contract.test.ts +++ b/app/__tests__/privacy/feedbackScrub.contract.test.ts @@ -1,20 +1,29 @@ /** * PRIVACY CONTRACT — FEAT-284 in-app bug/feedback reporting. * - * The surface is Sentry's feedback widget (message + screenshot), triggered by - * shake-to-report or the Profile entry, gated behind the build-time + * The surface is OUR OWN form since FEAT-570 (`core/components/BugReportOverlay`), + * triggered by shake-to-report or the Profile entry, gated behind the build-time * `bug_reporting` flag (INTERNAL/TestFlight only; flipped OFF before the public * App Store launch). CRITICAL SDK FACT (verified in @sentry/core@10.x): a * `type:'feedback'` event does NOT pass through `beforeSend`, so a global event * processor (`scrubFeedbackEvent`) is the only place to touch the outbound - * feedback event. + * feedback event — which is why the transport swap to `captureFeedback()` left + * this contract intact rather than bypassing it. * - * Posture is deliberately useful (internal tool, owner's own data): the - * screenshot is intentional, and breadcrumbs ride along because they are already - * sanitized app-wide by `beforeBreadcrumbHook`. This contract pins the LIGHT - * hygiene the processor still enforces — identity reduced to the anonymous uid, - * no cross-linking to a prior error, and a pattern-scrub of the typed message — - * and the flag mechanism. Runs in `npm run precommit` via `test:privacy`. + * **THERE IS NO LONGER A SCREENSHOT.** This docblock used to record that "the + * screenshot is intentional" as part of a deliberately-useful posture. FEAT-570 + * dropped the capability outright: attachments ride the ENVELOPE rather than the + * event, so the processor pinned here structurally cannot reach one, and the + * surface is armed over `AssessmentFlow` and `VoiceReflectionScreen`. Message + * text only. Breadcrumbs still ride along because they are already sanitized + * app-wide by `beforeBreadcrumbHook`. + * + * This contract pins the LIGHT hygiene the processor enforces — identity reduced + * to the anonymous uid, no cross-linking to a prior error, and a pattern-scrub of + * the typed message — and the flag mechanism. The submit path's own guarantees + * (kill switch, payload shape, call-site scrub) are pinned separately in + * `bugReportSubmit.contract.test.ts`. Runs in `npm run precommit` via + * `test:privacy`. * * @see docs/legal/dpia-sensitive-wellness-data.md §2 (Sentry) */ diff --git a/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts b/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts index 7cfba7d7..d0aaddb5 100644 --- a/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts +++ b/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts @@ -183,6 +183,101 @@ describe('no journal source performs outbound network egress', () => { }); }); +/** + * Sentry — the sink this file claimed to cover and did not (FEAT-288). + * + * The scans above pin PostHog, Supabase and raw network egress. Sentry is none + * of those: it is a first-party-configured monitoring SDK with a live production + * DSN, imported elsewhere in the app as `import * as Sentry from + * '@sentry/react-native'`. So until now this passed every check in this file, + * from inside `features/journal`: + * + * Sentry.addBreadcrumb({ message: `filter applied, ${results.length} results` }) + * + * That is entry-derived data leaving the device on a feature whose defining + * constraint is that none does. Breadcrumbs and tags are the dangerous shape + * rather than an obvious one: they read as diagnostics, they are attached far + * from the sink, and they ship with every subsequent error report. + * + * `captureException` is forbidden too, not just the deliberate-context calls. An + * exception thrown while handling entry text can carry that text in its message, + * and this feature has no error path important enough to buy that risk. The + * allow-list stays EMPTY for the same reason the egress one does: adding a + * permitted module should be a reviewable diff line here, not an invisible new + * file over there. + */ +const SENTRY_PATTERNS: ReadonlyArray = [ + ['@sentry/ import', /@sentry\//], + ['Sentry.addBreadcrumb', /\bSentry\.addBreadcrumb\s*\(/], + ['Sentry.setContext', /\bSentry\.setContext\s*\(/], + ['Sentry.setTag(s)', /\bSentry\.setTags?\s*\(/], + ['Sentry.setUser', /\bSentry\.setUser\s*\(/], + ['Sentry.setExtra(s)', /\bSentry\.setExtras?\s*\(/], + ['Sentry.captureMessage', /\bSentry\.captureMessage\s*\(/], + ['Sentry.captureException', /\bSentry\.captureException\s*\(/], + ['Sentry.startSpan', /\bSentry\.startSpan\s*\(/], + ['Sentry.withScope', /\bSentry\.withScope\s*\(/], +]; + +/** Journal modules permitted to reach Sentry. EMPTY BY DESIGN — see above. */ +const SENTRY_ALLOWED_FILES: readonly string[] = []; + +describe('no journal source reaches Sentry', () => { + it.each(JOURNAL_SOURCES.map((f) => [f.split('/').pop() ?? f, f]))( + '%s sends nothing to Sentry', + (_label, file) => { + const path = file as string; + if (SENTRY_ALLOWED_FILES.some((allowed) => path.endsWith(allowed))) return; + + const stripped = stripComments(readFileSync(path, 'utf8')); + expect(stripped.trim().length).toBeGreaterThan(0); + + const hits = SENTRY_PATTERNS.filter(([, pattern]) => pattern.test(stripped)).map( + ([label]) => label + ); + + expect(hits).toEqual([]); + } + ); + + it('the Sentry matchers still fire against known-bad source', () => { + const knownBad = [ + "import * as Sentry from '@sentry/react-native';", + "Sentry.addBreadcrumb({ message: 'filter applied, 12 results' });", + "Sentry.setContext('journal', { entries: 12 });", + "Sentry.setTag('range', 'last30');", + "Sentry.setTags({ range: 'last30' });", + "Sentry.setUser({ id: 'anon' });", + "Sentry.setExtra('preview', entry.text);", + "Sentry.setExtras({ preview: entry.text });", + "Sentry.captureMessage('journal filter used');", + 'Sentry.captureException(err);', + "Sentry.startSpan({ name: 'journal.filter' }, run);", + 'Sentry.withScope((scope) => scope.setTag(\'a\', \'b\'));', + ].join('\n'); + + for (const [label, pattern] of SENTRY_PATTERNS) { + expect([label, pattern.test(knownBad)]).toEqual([label, true]); + } + }); + + it('prose naming Sentry does not trip the scan', () => { + // This feature's modules warn readers off these calls by name, so the + // stripper has to be doing real work here (DEBUG-390). + const source = [ + '// Never call Sentry.addBreadcrumb from this feature.', + '/* @sentry/react-native is forbidden here. */', + 'const local = 1;', + ].join('\n'); + + const stripped = stripComments(source); + for (const [, pattern] of SENTRY_PATTERNS) { + expect(pattern.test(stripped)).toBe(false); + } + expect(/\bSentry\.addBreadcrumb\s*\(/.test(`${stripped}\nSentry.addBreadcrumb({});`)).toBe(true); + }); +}); + describe('no LLM client ships inside the app package', () => { /** * The directory scans above are rooted at the journal and speech trees, so diff --git a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts new file mode 100644 index 00000000..a627ef0c --- /dev/null +++ b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts @@ -0,0 +1,437 @@ +/** + * PHIFilter differential contract (INFRA-535). + * + * Pins ONE relation between the live filter and the frozen `d14d6178` baseline: + * + * validateV1(p) rejects ⟹ PHIFilter.validate(p) rejects + * + * It is deliberately ONE-SIDED. The converse is NOT asserted, because INFRA-535 + * legitimately makes the filter reject payloads the baseline accepted — property + * KEYS are now scanned, `containsPHI` now runs per property, and arrays are now + * traversed (the baseline's step 4 excludes them, so `{tags:['grief']}` passed + * intact). Asserting equivalence would forbid exactly the tightening this item + * exists to deliver. + * + * Compliance framing: the filter may become NARROWER, never LOOSER. This suite is + * the mechanical form of that ruling. A future change that lets any + * baseline-rejected payload through fails here. + * + * DEBUG-390 discipline: a differential harness whose corpus contains no rejections + * is vacuously green and stays green forever. Three guards below — corpus size, + * a pinned minimum rejection count, and a live matcher check — exist so this suite + * can still go red. + * + * =========================================================================== + * AMENDING THE WHITELIST — the procedure (INFRA-558) + * =========================================================================== + * + * WHAT THIS SUITE CAN AND CANNOT DETECT. Read this before relying on it. + * The relation above is one-sided over a HAND-AUTHORED corpus, so it catches a + * LOOSENING of the filter's scanning behaviour against payloads the corpus + * happens to contain. It does NOT, on its own, notice a new event type being + * added to the live whitelist: nothing in the corpus mentions that name, so + * every assertion stays true and the suite stays green. The `WIDENED` ledger + * below is what closes that gap, and it is the ONLY thing that does. + * + * The harness verifies that a widening was DECLARED. It cannot verify that a + * widening was WARRANTED — no test can. Do not read a green run as review. + * + * THE FROZEN BASELINE IS NEVER AMENDED. `phiFilterBaselineV1.ts` is a fixed + * reference to `d14d6178`; editing it to track live makes the suite compare the + * implementation to itself. `BASELINE_SAFE_EVENT_TYPES.size` is an anti-tamper + * pin on that file, not a count of the live whitelist — never bump it. What + * gets amended is the DELTA, recorded here. + * + * TO ADD AN EVENT TYPE, in ONE pull request: + * 1. Add the string to `SAFE_EVENT_TYPES` and the constant to + * `AnalyticsEvents` (both in `PHIFilter.ts`) — a name in one but not the + * other cannot transmit and fails silently. + * 2. Add a `WIDENED` entry below naming the event, the work item, and why. + * Omit it and the enforcement test red-lines; that is the control. + * 3. Add a per-event boundary suite in the FEAT-457 shape — see + * `guidanceAnalyticsBoundary.contract.test.ts`: whitelist/constant parity, + * the exact emitted payload, and an explicit non-vacuity case. + * 4. Refresh the enumerated event list in + * `docs/architecture/analytics-architecture.md`. + * 5. Get a `compliance` pass. On a solo-founder repo "approval" cannot mean a + * human gate that does not exist, so the durable artifact is the ledger + * entry plus the boundary suite — a review with no checkable output is + * indistinguishable afterwards from a review that never happened. + * + * DO NOT add a benign CORPUS case for a newly-whitelisted event type. It turns + * this suite RED by construction: `validateV1` rejects the unknown name, live + * accepts it, and the one-sided relation fires. The ledger is the exempted + * channel for exactly that reason. + * + * KNOWN, UNRATIFIED: the corpus names four event types that are NOT in the + * baseline whitelist — `voice_journal_started`, `journal_entry_saved`, + * `reflection_transcribed`, `totally_new_event`. Whitelisting any of them WOULD + * red-line the relation, so the corpus is a de-facto permanent negative list for + * those four names. That is an unrecorded side effect, not a designed control. + * A future item wanting to ship journal analytics must ratify or retire it + * deliberately rather than discovering it as a mysterious red. + */ + +import { PHIFilter } from '@/core/analytics/PHIFilter'; +import { containsPHI } from '@/core/analytics/phiDetection'; +import { + validateV1, + BASELINE_PHI_KEYWORDS, + BASELINE_SAFE_EVENT_TYPES, +} from '../helpers/phiFilterBaselineV1'; + +import * as fs from 'fs'; +import * as path from 'path'; + +interface Case { + readonly label: string; + readonly eventType: string; + readonly data: Record; +} + +/** + * The differential corpus. + * + * Grouped by what each group proves. `expectV1Reject` is not asserted directly — + * it documents intent and feeds the pinned-count guard, so that a future edit that + * accidentally neuters the corpus (e.g. renaming a keyword out of existence) is + * caught by the count rather than passing silently. + */ +const CORPUS: ReadonlyArray = [ + // ---- Benign: must pass BOTH filters. These are the payloads real trackers send. + { label: 'app_opened bare', eventType: 'app_opened', data: {} }, + { label: 'crisis_resources_viewed bare', eventType: 'crisis_resources_viewed', data: {} }, + { label: 'crisis_hotline_tapped bare', eventType: 'crisis_hotline_tapped', data: {} }, + { label: 'guidance_opened bare', eventType: 'guidance_opened', data: {} }, + { label: 'screen_viewed coarsened', eventType: 'screen_viewed', data: { screen_name: 'App' } }, + { label: 'screen_viewed Home', eventType: 'screen_viewed', data: { screen_name: 'Home' } }, + { label: 'check_in_completed duration', eventType: 'check_in_completed', data: { duration_ms: 5000 } }, + { label: 'learn_content_viewed module', eventType: 'learn_content_viewed', data: { module_id: 'm1' } }, + { label: 'learn_module_completed pair', eventType: 'learn_module_completed', data: { module_id: 'm1', duration_ms: 900 } }, + { label: 'onboarding_step_completed step', eventType: 'onboarding_step_completed', data: { step: 3 } }, + { label: 'error_occurred type', eventType: 'error_occurred', data: { error_type: 'network' } }, + { label: 'assessment_completed duration', eventType: 'assessment_completed', data: { duration_ms: 42000 } }, + { label: 'practice_completed duration', eventType: 'practice_completed', data: { duration_ms: 300000 } }, + { label: 'breathing_exercise_started bare', eventType: 'breathing_exercise_started', data: {} }, + { label: 'settings_opened bare', eventType: 'settings_opened', data: {} }, + { label: 'consent_changed bare', eventType: 'consent_changed', data: {} }, + + // ---- V1 rejects: non-whitelisted event NAME. + { label: 'unknown event', eventType: 'voice_journal_started', data: {} }, + { label: 'journal_entry_saved', eventType: 'journal_entry_saved', data: {} }, + { label: 'reflection_transcribed', eventType: 'reflection_transcribed', data: {} }, + { label: 'made-up event', eventType: 'totally_new_event', data: { step: 1 } }, + + // ---- V1 rejects: PHI keyword in a string VALUE. + { label: 'value grief', eventType: 'screen_viewed', data: { screen: 'grief' } }, + { label: 'value career', eventType: 'learn_content_viewed', data: { topic: 'career' } }, + { label: 'value conflict', eventType: 'app_opened', data: { detail: 'conflict at work' } }, + { label: 'value journal', eventType: 'app_opened', data: { detail: 'my journal from tonight' } }, + { label: 'value note', eventType: 'app_opened', data: { detail: 'a note to self' } }, + { label: 'value suicid stem', eventType: 'app_opened', data: { detail: 'suicidal thoughts' } }, + { label: 'value harm', eventType: 'app_opened', data: { detail: 'self harm' } }, + { label: 'value phq', eventType: 'assessment_completed', data: { label: 'phq total' } }, + { label: 'value mood', eventType: 'check_in_completed', data: { label: 'mood is low' } }, + { label: 'value email', eventType: 'settings_opened', data: { field: 'email address' } }, + + // ---- V1 rejects: suspicious numeric in a non-safe key. + { label: 'numeric score', eventType: 'assessment_completed', data: { total: 18 } }, + { label: 'numeric rating', eventType: 'check_in_completed', data: { rating: 4 } }, + { label: 'numeric q9', eventType: 'assessment_completed', data: { q9: 2 } }, + + // ---- V1 rejects: nested object violation. + { label: 'nested keyword value', eventType: 'app_opened', data: { meta: { detail: 'grief' } } }, + { label: 'nested numeric', eventType: 'app_opened', data: { meta: { total: 21 } } }, + + // ---- V1 ACCEPTS these; INFRA-535 rejects them. The one-sided assertion says + // nothing about them, which is the point — but the `TIGHTENED` group below + // asserts the live filter does in fact catch them, so the tightening cannot + // silently disappear. + { label: 'array of keyword strings', eventType: 'app_opened', data: { tags: ['grief'] } }, + { label: 'array nested deeper', eventType: 'app_opened', data: { tags: [['career']] } }, + { label: 'array inside object', eventType: 'app_opened', data: { meta: { tags: ['suicidal'] } } }, + { label: 'PHI keyword as KEY', eventType: 'check_in_completed', data: { mood: 'ok' } }, + { label: 'PHI keyword as key segment', eventType: 'assessment_completed', data: { phq_score: 'x' } }, + { label: 'journal key', eventType: 'app_opened', data: { journal_id: 'abc' } }, +]; + +/** Payloads V1 accepts but the tightened filter must now reject. */ +const TIGHTENED: ReadonlyArray = [ + 'array of keyword strings', + 'array nested deeper', + 'array inside object', + 'PHI keyword as KEY', + 'PHI keyword as key segment', + 'journal key', +]; + +/** + * Pinned literal, equal to the corpus's exact baseline-rejection count today + * (4 non-whitelisted names + 10 keyword values + 3 suspicious numerics + 2 nested). + * Growth is fine; shrinkage is not. If a future edit trims the corpus or neuters + * the keyword list, this goes red rather than the suite passing over nothing. + */ +const MIN_BASELINE_REJECTIONS = 19; + +/** + * The registered delta between the frozen baseline and the live whitelist + * (INFRA-558). Every event type live-whitelisted after `d14d6178` must appear + * here, in the same PR that adds it. + * + * SHIPPED EMPTY, and correctly so: live and baseline are both exactly the 25 + * names of `d14d6178`. `guidance_opened` is NOT a widening — FEAT-457 landed + * `bb70cb87` on 2026-08-21, three days BEFORE the freeze, so it is inside the + * snapshot. There is no post-freeze precedent; this ledger records the first. + * + * `sample` is a payload the event would really send, used to prove the widening + * is name-scoped — i.e. the baseline rejects it for the NAME and not because it + * smuggles wellness data past the keyword scan. + */ +interface Widening { + readonly eventType: string; + readonly workItem: string; + readonly rationale: string; + readonly sample: Record; +} + +const WIDENED: ReadonlyArray = []; + +/** + * The other direction (INFRA-552). A name REMOVED from the live whitelist after + * `d14d6178` must appear here, in the same PR that removes it. + * + * This ledger exists because the amendment procedure above had an add-path only: + * `declared` was `baseline ∪ WIDENED`, the frozen baseline is never amended, and + * `WIDENED` only grows — so a legitimate narrowing had no way to be recorded and + * red-lined the removal assertion with no sanctioned resolution. The assertion's + * own comment already said a removal "must be reflected here"; nothing implemented + * that. This does. + * + * A narrowing is SAFER than the baseline (the filter transmits strictly less), so + * unlike `WIDENED` there is no payload to vet — the check is that the removal was + * declared, attributed, and reasoned, not that it is harmless. + * + * TO REMOVE AN EVENT TYPE, in ONE pull request: + * 1. Delete the constant from `AnalyticsEvents` in `PHIFilter.ts`. Since + * INFRA-552 the whitelist is DERIVED from it, so there is no second list to + * edit — and no way to remove from one and not the other. + * 2. Delete its tracker function and hook-return entry in `useAnalytics.ts`, and + * its `FIXTURES` entry in `analyticsTrackerContract.privacy.test.ts`. Deleting + * the constant alone breaks typecheck; deleting the tracker alone leaves a + * dead fixture that suite rejects. + * 3. Add a `NARROWED` entry below naming the event, the work item, and why. + * 4. Refresh the enumerated event list in + * `docs/architecture/analytics-architecture.md`. + * + * Do NOT remove a name because nothing emits it YET. "No production emitter" and + * "no longer wanted" are different claims, and a sibling item mid-flight looks + * exactly like the former — INFRA-542 wired `app_opened`/`app_backgrounded` while + * this prune was being planned, which is why both are absent from this list. + */ +interface Narrowing { + readonly eventType: string; + readonly workItem: string; + readonly rationale: string; +} + +const NARROWED: ReadonlyArray = [ + // Six remain of INFRA-552's original twelve. DEBUG-536 removed the other six + // entries when it RESTORED those events with real call sites — a restoration of + // frozen-baseline members, not a new grant, which is why no `WIDENED` entry was + // added: deleting the entry returns the name to `declared` via the baseline it + // never left. That is the re-add path the docblock above does not spell out. + // + // Each of the nine reversed the rationale "no production emitter", which was true + // when written and is now false. The rule stated above — "'No production emitter' + // and 'no longer wanted' are different claims, and a sibling item mid-flight looks + // exactly like the former" — is exactly the case those nine were: DEBUG-536 was + // filed and blocked when the prune ran, with no repo footprint to see. + // + // These six stay. error_occurred / session_started / session_ended are the genuine + // "no longer wanted" cases. The breathing and learn_module_completed entries are a + // different kind: DEBUG-536 could have restored them on the same evidence, and + // deliberately did not — they answer no question anyone is asking, and restoring a + // tracker whose only consumer is a dashboard nobody reads is how this set became + // twelve orphans the first time. They come back with a Job, or not at all. + { eventType: 'breathing_exercise_started', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' }, + { eventType: 'breathing_exercise_completed', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' }, + { eventType: 'learn_module_completed', workItem: 'INFRA-552', rationale: 'No production emitter; its siblings learn_module_started and learn_content_viewed do fire and are retained.' }, + { eventType: 'error_occurred', workItem: 'INFRA-552', rationale: 'No production emitter. Error reporting goes to Sentry, not to PostHog product analytics.' }, + { eventType: 'session_started', workItem: 'INFRA-552', rationale: 'Catalog fiction: no tracker function ever existed, so the contract test could not see it. No session-lifecycle concept exists in app/src.' }, + { eventType: 'session_ended', workItem: 'INFRA-552', rationale: 'Catalog fiction: no tracker function ever existed, so the contract test could not see it. No session-lifecycle concept exists in app/src.' }, +]; + +describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () => { + const baselineRejections = CORPUS.filter((c) => !validateV1(c.eventType, c.data).valid); + + describe('anti-vacuity guards (DEBUG-390)', () => { + it('the corpus is non-empty and substantial', () => { + expect(CORPUS.length).toBeGreaterThanOrEqual(35); + }); + + it('the baseline actually rejects a pinned minimum of the corpus', () => { + // Without this, a corpus of only-benign payloads satisfies the one-sided + // relation completely and goes green forever. + expect(baselineRejections.length).toBeGreaterThanOrEqual(MIN_BASELINE_REJECTIONS); + }); + + it('the baseline still fires on a literal known-bad payload', () => { + expect(validateV1('app_opened', { detail: 'grief' }).valid).toBe(false); + expect(validateV1('not_a_real_event', {}).valid).toBe(false); + }); + + it('the baseline keyword list and whitelist are intact', () => { + expect(BASELINE_PHI_KEYWORDS).toHaveLength(28); + expect(BASELINE_SAFE_EVENT_TYPES.size).toBe(25); + }); + + it('containsPHI still fires on a literal known-bad string', () => { + // Proves the detector this suite reasons about is live, not a stub. + expect(containsPHI({ v: 'reach me at a@b.com' })).toBe(true); + expect(containsPHI({ v: 'PHQ-9: 21' })).toBe(true); + expect(containsPHI({ v: 'nothing sensitive here' })).toBe(false); + }); + }); + + /** + * The live-side control (INFRA-558). Everything above compares BEHAVIOUR over a + * fixed corpus; nothing above reads the live whitelist's MEMBERSHIP, so before + * this group a new event type could be added with no test anywhere noticing. + */ + describe('whitelist amendments are declared (INFRA-558)', () => { + const live = new Set(PHIFilter.getWhitelistedEvents()); + const narrowed = new Set(NARROWED.map((n) => n.eventType)); + const declared = new Set( + [...BASELINE_SAFE_EVENT_TYPES, ...WIDENED.map((w) => w.eventType)].filter( + (e) => !narrowed.has(e) + ) + ); + + it('every live event type is either in the frozen baseline or in the WIDENED ledger', () => { + const undeclared = [...live].filter((e) => !declared.has(e)).sort(); + // Failing here means someone widened SAFE_EVENT_TYPES without recording it. + // The fix is a WIDENED entry in that same PR — never an edit to the baseline. + expect(undeclared).toEqual([]); + }); + + it('nothing declared has since been removed from the live whitelist', () => { + // The other direction: a stale ledger entry, or a baseline name deleted live. + // A removal is a legitimate NARROWING, but it must be reflected here rather + // than left as a claim the code no longer supports. + const missing = [...declared].filter((e) => !live.has(e)).sort(); + expect(missing).toEqual([]); + }); + + it('each ledger entry is a NAME-scoped widening, not smuggled wellness data', () => { + // Vacuous while WIDENED is empty — the guard below is what keeps that honest. + for (const w of WIDENED) { + const before = validateV1(w.eventType, w.sample); + expect(before.valid).toBe(false); + expect(before.reason).toMatch(/not in whitelist/i); + expect(PHIFilter.validate(w.eventType, w.sample).valid).toBe(true); + expect(containsPHI(w.sample)).toBe(false); + expect(w.workItem).toMatch(/^(FEAT|DEBUG|INFRA|MAINT|AGENT)-\d+$/); + expect(w.rationale.length).toBeGreaterThan(20); + } + }); + + it('each NARROWED entry names a baseline event that really is gone (INFRA-552)', () => { + // The symmetric control to the widening check above. Two ways this ledger + // could rot, both silent without this: + // - an entry naming something that was never in the baseline (a typo, or a + // name invented to satisfy the arithmetic), which would shrink `declared` + // without any real removal having happened; + // - a stale entry left behind after the event was RE-ADDED, which would + // hide it from the "every live event type is declared" check above. + for (const n of NARROWED) { + expect(BASELINE_SAFE_EVENT_TYPES.has(n.eventType)).toBe(true); + expect(live.has(n.eventType)).toBe(false); + expect(n.workItem).toMatch(/^(FEAT|DEBUG|INFRA|MAINT|AGENT)-\d+$/); + expect(n.rationale.length).toBeGreaterThan(20); + } + // Non-vacuity: this suite shipped with WIDENED empty, and an empty NARROWED + // would make the loop above pass over nothing in exactly the same way. + expect(NARROWED.length).toBeGreaterThan(0); + }); + + it('the membership matcher still fires (DEBUG-390)', () => { + // An empty ledger plus an unchanged whitelist makes the two tests above pass + // over nothing. Prove the comparison can still detect an undeclared name, so + // "green" means "checked" rather than "found nothing to check". + // + // Deliberately over SYNTHETIC sets, not over `live`: a control derived from + // live state fails whenever the test it is controlling fails, which makes it + // a second symptom rather than an independent check. + const fakeDeclared = new Set(['a', 'b']); + const fakeLive = new Set(['a', 'b', 'phantom_undeclared_event']); + expect([...fakeLive].filter((e) => !fakeDeclared.has(e))).toEqual([ + 'phantom_undeclared_event', + ]); + expect([...fakeDeclared].filter((e) => !fakeLive.has(e))).toEqual([]); + + // And that the real sets being compared are non-trivial, so the assertions + // above are running against something. + expect(live.size).toBeGreaterThanOrEqual(13); + expect(declared.size).toBe( + BASELINE_SAFE_EVENT_TYPES.size + WIDENED.length - NARROWED.length + ); + }); + }); + + describe('ONE-SIDED relation: anything the baseline rejected is still rejected', () => { + it.each(CORPUS.map((c) => [c.label, c] as const))( + '%s', + (_label, c) => { + const before = validateV1(c.eventType, c.data); + const after = PHIFilter.validate(c.eventType, c.data); + + if (!before.valid) { + expect(after.valid).toBe(false); + } + // Deliberately no assertion when `before.valid` is true: the live filter + // is permitted to be stricter. See the TIGHTENED group. + } + ); + }); + + describe('the tightening is real and did not silently disappear', () => { + it.each(TIGHTENED.map((label) => [label] as const))( + '%s: baseline accepts, live filter rejects', + (label) => { + const c = CORPUS.find((x) => x.label === label); + expect(c).toBeDefined(); + expect(validateV1(c!.eventType, c!.data).valid).toBe(true); + expect(PHIFilter.validate(c!.eventType, c!.data).valid).toBe(false); + } + ); + }); + + describe('the frozen baseline is unreachable from app/src (FEAT-376)', () => { + const SRC = path.resolve(__dirname, '../../src'); + + const walk = (dir: string, acc: string[] = []): string[] => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, acc); + else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p); + } + return acc; + }; + + const files = walk(SRC); + const NEEDLE = 'phiFilterBaselineV1'; + + it('the scan actually found source files (anti-vacuity)', () => { + expect(files.length).toBeGreaterThan(200); + }); + + it('the matcher fires against a literal known-bad string (anti-vacuity)', () => { + expect(`import { validateV1 } from '../${NEEDLE}';`).toContain(NEEDLE); + }); + + it('no file under app/src references the frozen baseline', () => { + const offenders = files.filter((f) => fs.readFileSync(f, 'utf8').includes(NEEDLE)); + expect(offenders).toEqual([]); + }); + }); +}); diff --git a/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts new file mode 100644 index 00000000..07c16541 --- /dev/null +++ b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts @@ -0,0 +1,233 @@ +/** + * PHIFilter scan-surface contract (INFRA-535, C2). + * + * Pins the three surfaces the filter did NOT scan before this change, all under + * UNCHANGED whole-event-reject semantics: + * + * 1. property KEYS (previously never scanned — "keys are controlled by us"); + * 2. property values via `containsPHI` (never once armed: the predicate had zero + * production importers, and its own docblock's claim that it "gates every + * analytics event" was false — see DEBUG-553); + * 3. ARRAY members (the old step 4 excluded arrays, so `{tags:['grief']}` shipped + * intact). + * + * Every assertion here is a TIGHTENING. Nothing in this suite permits a payload the + * previous filter rejected — that direction is pinned mechanically by + * `phiFilterDifferential.privacy.test.ts`. + * + * The crisis rulings this suite exists to keep true are called out inline. They are + * not decoration: each one names a way the tightening could silently and + * permanently disable a working crisis or navigation event. + */ + +jest.mock('@/core/services/logging', () => ({ + logSecurity: jest.fn(), + logAnalytics: jest.fn(), +})); + +import { PHIFilter } from '@/core/analytics/PHIFilter'; +import { logSecurity } from '@/core/services/logging'; + +const mockLogSecurity = logSecurity as jest.MockedFunction; + +beforeEach(() => { + mockLogSecurity.mockClear(); +}); + +describe('PHIFilter scan surface (INFRA-535)', () => { + describe('KEY scanning — new, and the allowlist that makes it survivable', () => { + it('blocks a PHI keyword appearing as a whole key', () => { + expect(PHIFilter.validate('app_backgrounded', { mood: 'ok' }).valid).toBe(false); + expect(PHIFilter.validate('app_opened', { journal: 'x' }).valid).toBe(false); + }); + + it('blocks a PHI keyword appearing as one segment of a key', () => { + expect(PHIFilter.validate('screen_viewed', { phq_score: 'x' }).valid).toBe(false); + expect(PHIFilter.validate('app_opened', { journal_id: 'abc' }).valid).toBe(false); + expect(PHIFilter.validate('app_opened', { userEmail: 'x' }).valid).toBe(false); + }); + + it('blocks a stem keyword as a key prefix', () => { + // `suicid` and `harm` are deliberately stems, not whole words. + expect(PHIFilter.validate('app_opened', { suicidal_flag: 'x' }).valid).toBe(false); + expect(PHIFilter.validate('app_opened', { harmful: 'x' }).valid).toBe(false); + }); + + it('does NOT block a key that merely CONTAINS a keyword mid-segment', () => { + // This is what segment matching buys over substring matching. `campaign` + // contains "pain"; `notation` contains "note". Substring matching would + // block both, and a filter that blocks ordinary keys gets weakened by the + // next person who hits it. + expect(PHIFilter.validate('app_opened', { campaign_id: 'spring' }).valid).toBe(true); + expect(PHIFilter.validate('app_opened', { notation: 'x' }).valid).toBe(true); + }); + + it('CRISIS PIN: screen_name is allowlisted, so screen_viewed still emits', () => { + // `screen_name` segments to ['screen','name'] and `name` is an exact + // PHI_KEYWORDS hit. Without the allowlist this degrades to a whole-event + // reject — and screen_viewed fires in the SAME useFocusEffect as + // trackCrisisResourcesViewed, so crisis-screen reach becomes unmeasurable. + expect(PHIFilter.validate('screen_viewed', { screen_name: 'App' }).valid).toBe(true); + expect(PHIFilter.validate('screen_viewed', { screen_name: 'Home' }).valid).toBe(true); + }); + + it('the allowlist exempts only the NEW checks, never the existing VALUE scan', () => { + // An allowlisted key carrying a keyword VALUE must still be rejected, or the + // allowlist would be a loosening rather than a false-positive fix. + expect(PHIFilter.validate('screen_viewed', { screen_name: 'grief' }).valid).toBe(false); + }); + + it('every real tracker key survives the key scan', () => { + // Derived from the literal keys in useAnalytics.ts. INFRA-552 replaced + // check_in_completed / learn_module_completed / error_occurred here: all three + // were pruned, and a deleted event would pass this assertion for the WRONG + // reason — rejected at the whitelist check, never reaching the key scan this + // block exists to exercise. Every entry below must be a LIVE whitelisted event. + const real: Array<[string, Record]> = [ + ['screen_viewed', { screen_name: 'App' }], + ['app_opened', { is_cold_start: true, since_last_active: 'lt_5m' }], + ['app_backgrounded', { duration_seconds: 42 }], + ['learn_content_viewed', { module_id: 'm1' }], + ['learn_module_started', { module_id: 'm1' }], + ['onboarding_step_completed', { step: 3 }], + ]; + for (const [evt, data] of real) { + expect(PHIFilter.isWhitelisted(evt)).toBe(true); + expect(PHIFilter.validate(evt, data)).toEqual({ valid: true }); + } + }); + }); + + describe('the EVENT NAME is never key-scanned', () => { + it('CRISIS PIN: crisis_hotline_tapped validates despite containing "hotline"', () => { + // `hotline_number` is a PHI keyword. If the scan were ever applied to the + // event name, or if `hotline_number` were shortened to `hotline`, this event + // would self-block forever and the app would lose its 988-reach signal. + expect(PHIFilter.validate('crisis_hotline_tapped', {})).toEqual({ valid: true }); + }); + + it('CRISIS PIN: crisis_resources_viewed validates', () => { + expect(PHIFilter.validate('crisis_resources_viewed', {})).toEqual({ valid: true }); + }); + + it('the keyword list still contains the un-shortened hotline_number', () => { + // Guards the shortening directly, not just its symptom. + const src = require('fs').readFileSync( + require('path').resolve(__dirname, '../../src/core/analytics/PHIFilter.ts'), + 'utf8' + ); + expect(src).toContain("'hotline_number'"); + }); + }); + + describe('containsPHI per property — armed for the first time', () => { + it('blocks an email in a property value', () => { + expect(PHIFilter.validate('settings_opened', { field: 'reach me at a@b.com' }).valid).toBe(false); + }); + + it('blocks a long numeric identifier in a property value', () => { + expect(PHIFilter.validate('app_opened', { ref: '1234567890123' }).valid).toBe(false); + }); + + it('blocks a UUID in a property value', () => { + expect( + PHIFilter.validate('app_opened', { ref: '123e4567-e89b-12d3-a456-426614174000' }).valid + ).toBe(false); + }); + + it('MAINT-202 PIN: an allowlisted numeric key is exempt from containsPHI', () => { + // A 13-digit Date.now() matches the \b\d{10,}\b identifier pattern. Arming + // containsPHI without exempting allowlisted keys reintroduces exactly the + // MAINT-202 defect, which silently dropped every consent-passing event. + expect(PHIFilter.validate('app_opened', { timestamp: 1755000000000 })).toEqual({ valid: true }); + expect(PHIFilter.validate('app_opened', { timestamp: '1755000000000' })).toEqual({ valid: true }); + }); + }); + + describe('ARRAY scanning — closes a live hole', () => { + it('blocks a PHI keyword inside an array of strings', () => { + // Shipped intact before this change: the value branch tested + // `typeof value === 'string'` and step 4 excluded arrays. + expect(PHIFilter.validate('app_opened', { tags: ['grief'] }).valid).toBe(false); + }); + + it('blocks a keyword in a nested array', () => { + expect(PHIFilter.validate('app_opened', { tags: [['career']] }).valid).toBe(false); + }); + + it('blocks a keyword in an array inside an object', () => { + expect(PHIFilter.validate('app_opened', { meta: { tags: ['suicidal'] } }).valid).toBe(false); + }); + + it('allows a benign array', () => { + expect(PHIFilter.validate('app_opened', { tags: ['alpha', 'beta'] })).toEqual({ valid: true }); + }); + }); + + describe('logSecurity is aggregated to at most ONE call per validate()', () => { + it('emits exactly one call for a rejected payload', () => { + // ProductionLogger.security() logs at LogLevel.ERROR into a 1000-entry FIFO + // audit ring and console.errors in production, synchronously. One call per + // redacted property would fire once per screen view, evicting genuine + // crisis-path entries and landing an ERROR inside the crisis-tap window. + PHIFilter.validate('app_opened', { mood: 'low', journal: 'x', detail: 'grief' }); + expect(mockLogSecurity).toHaveBeenCalledTimes(1); + }); + + it('emits exactly one call for a non-whitelisted event', () => { + PHIFilter.validate('not_a_real_event', {}); + expect(mockLogSecurity).toHaveBeenCalledTimes(1); + }); + + it('emits no call for a clean payload', () => { + PHIFilter.validate('screen_viewed', { screen_name: 'App' }); + expect(mockLogSecurity).not.toHaveBeenCalled(); + }); + + it('emits exactly one call for a nested violation', () => { + // The old implementation recursed through validate() itself, so a nested + // violation logged from the inner frame. The scan must not re-enter the + // logging path. + PHIFilter.validate('app_opened', { meta: { detail: 'grief' } }); + expect(mockLogSecurity).toHaveBeenCalledTimes(1); + }); + + it('never logs the offending VALUE', () => { + PHIFilter.validate('settings_opened', { field: 'reach me at secret@example.com' }); + const logged = mockLogSecurity.mock.calls.map((c) => String(c[0])).join(' | '); + expect(logged).not.toContain('secret@example.com'); + }); + }); + + describe('the filter did not become more permissive', () => { + it('still rejects a non-whitelisted event name', () => { + expect(PHIFilter.validate('voice_journal_started', {}).valid).toBe(false); + }); + + it('still rejects a suspicious numeric in a non-safe key', () => { + expect(PHIFilter.validate('assessment_completed', { total: 18 }).valid).toBe(false); + }); + + it('still rejects a keyword in a plain string value', () => { + expect(PHIFilter.validate('app_opened', { detail: 'my journal from tonight' }).valid).toBe(false); + }); + + it('keeps all 28 keywords and all 9 safe numeric keys', () => { + const src = require('fs').readFileSync( + require('path').resolve(__dirname, '../../src/core/analytics/PHIFilter.ts'), + 'utf8' + ); + // Strip comments first (DEBUG-390) — this file deliberately names + // anti-patterns in prose, and a bare match would hit the commentary. + const stripped = src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + expect(stripped.length).toBeGreaterThan(2000); + // Anti-vacuity: the matcher must still fire against a literal known-bad string. + expect("'suicid',").toMatch(/'suicid',/); + for (const kw of ['suicid', 'harm', 'hotline_number', 'crisis_contact', 'grief']) { + expect(stripped).toContain(`'${kw}'`); + } + }); + }); +}); diff --git a/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts b/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts new file mode 100644 index 00000000..fd9ef26f --- /dev/null +++ b/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts @@ -0,0 +1,73 @@ +/** + * DEBUG-539 AC7 — pin the PostHog storage BACKEND BRANCH, not the version. + * + * `resetAnalyticsIdentity` deletes `.posthog-rn.json` / `.posthog-rn-logs.json` + * from the document directory on the never-consented path. That is correct only + * while `posthog-react-native` resolves its optimistic storage to the + * `expo-file-system` FILE branch, where storage KEYS *are* filenames. + * + * `buildOptimisticAsyncStorage` is a THREE-TIER fallback: + * 1. expo-file-system exporting BOTH `Paths` and `File` -> file-backed (today) + * 2. expo-file-system legacy `readAsStringAsync` -> legacy store + * 3. @react-native-async-storage/async-storage -> AsyncStorage + * + * If a future resolution loses `Paths`/`File`, the adapter silently drops to + * tier 2 or 3. At that point the file-unlinking arm becomes dead code AND + * `SECURE_STORAGE_CONFIG.SWEPT_EXACT_KEYS` becomes the correct fix — the exact + * inversion this file exists to make loud. + * + * Pinning the VERSION would not catch it: the branch depends on what the module + * EXPORTS, which can change within a semver-compatible bump. So assert the + * export surface and the guard, and read them as TEXT — expo-file-system's entry + * is untransformed TS (which is why jest.setup.js mocks it at all) and posthog's + * dist is not reachable through the package `exports` map. + */ + +import fs from 'fs'; +import path from 'path'; + +import { POSTHOG_RN_STORAGE_FILES } from '@/core/analytics/analyticsIdentityReset'; + +const NODE_MODULES = path.resolve(__dirname, '../../node_modules'); +const read = (rel: string): string => fs.readFileSync(path.join(NODE_MODULES, rel), 'utf8'); + +describe('DEBUG-539 AC7: the PostHog storage backend branch is pinned', () => { + it('posthog-react-native still names the two storage files we delete', () => { + const storage = read('posthog-react-native/dist/storage.js'); + + // Matcher-fires control (DEBUG-390): a path typo or a moved dist file would + // make every assertion below vacuous against an empty string. + expect(storage.length).toBeGreaterThan(500); + + for (const file of POSTHOG_RN_STORAGE_FILES) { + expect(storage).toContain(file); + } + }); + + it('expo-file-system still exports BOTH Paths and File — tier 1 stays reachable', () => { + const dts = read('expo-file-system/build/index.d.ts'); + expect(dts.length).toBeGreaterThan(200); + expect(dts).toMatch(/\bPaths\b/); + expect(dts).toMatch(/\bFile\b/); + }); + + it('the tier-1 guard is still the FIRST branch taken', () => { + const deps = read('posthog-react-native/dist/native-deps.js'); + expect(deps.length).toBeGreaterThan(200); + + // The guard that selects file-backed storage. If this stops matching, the + // adapter has moved and the reset primitive's unlink arm is no longer sound. + expect(deps).toMatch(/Paths\s*&&[\s\S]{0,40}File/); + + // And the file-backed construction itself, which is what makes a storage KEY + // a FILENAME rather than an AsyncStorage key. + expect(deps).toMatch(/new\s+\w*\.?File\(/); + }); + + it('CONSEQUENCE, stated so a future reader does not have to re-derive it', () => { + // Not an assertion about behaviour — a deliberate, executable note. If any + // test above goes red, the fix is NOT to relax it: it is to move the residue + // handling from file-unlinking to SWEPT_EXACT_KEYS and re-point this pin. + expect(POSTHOG_RN_STORAGE_FILES).toEqual(['.posthog-rn.json', '.posthog-rn-logs.json']); + }); +}); diff --git a/app/__tests__/safety/crisis-zero-988-windows.test.tsx b/app/__tests__/safety/crisis-zero-988-windows.test.tsx index 63a6db2c..e2380df7 100644 --- a/app/__tests__/safety/crisis-zero-988-windows.test.tsx +++ b/app/__tests__/safety/crisis-zero-988-windows.test.tsx @@ -442,6 +442,16 @@ describe('App.tsx — the root boundary exists at all', () => { require('path').join(__dirname, '../../App.tsx'), 'utf8', ); + // DEBUG-390: this file deliberately names anti-patterns in prose, and App.tsx's + // comments discuss these very elements, so structural matching runs on + // comment-stripped source. The length assertion below is what stops a + // mis-written strip from making every matcher below vacuously true. + const stripped = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + + test('the comment-stripped source is still substantive (guards the matchers below)', () => { + expect(stripped.length).toBeGreaterThan(1000); + expect(stripped).toContain(''); + }); test('RootCrisisBoundary is the immediate parent of CleanRootNavigator', () => { // Sentry.wrap is a profiler/touch wrapper — componentDidCatch appears nowhere in its @@ -453,12 +463,48 @@ describe('App.tsx — the root boundary exists at all', () => { }); test('the boundary sits INSIDE SafeAreaProvider and GestureHandlerRootView', () => { - const gh = source.indexOf(''); - const rb = source.indexOf(']/); + const sa = stripped.search(/]/); + const rb = stripped.search(/]/); + // Matched with a trailing-delimiter regex, not `indexOf('')` + // (DEBUG-559). The exact-match form silently returned -1 the moment the element + // took a prop, and `gh < -1` then failed with a message about ORDERING — a real + // red for an unrelated reason, which is worse than no pin. + expect(gh).toBeGreaterThan(-1); + expect(sa).toBeGreaterThan(-1); + expect(rb).toBeGreaterThan(-1); expect(gh).toBeLessThan(sa); expect(sa).toBeLessThan(rb); }); + + test('SafeAreaProvider is seeded with initialMetrics — otherwise it renders NOTHING on mount', () => { + // DEBUG-559. SafeAreaProvider's body is `{insets != null ? … : null}`, seeded + // from `initialMetrics?.insets ?? initialSafeAreaInsets ?? parentInsets ?? null`. + // This is the outermost such provider, so without initialMetrics that seed is + // null on EVERY mount and the provider renders nothing until the native insets + // round-trip lands. Every 988 affordance in the app is inside it — the root + // button, the keyboard accessory, and BOTH Static988Button fallbacks — so an + // unseeded remount is a blank, zero-988 screen rather than a FAB gap that the + // static button covers. This is the class of window this whole file exists to + // forbid, and here it was reachable by an ordinary consent tap. + expect(/]*\binitialMetrics=\{initialWindowMetrics\}/.test(stripped)).toBe( + true, + ); + expect(/\binitialWindowMetrics\b/.test(stripped.slice(0, stripped.indexOf(']*\binitialMetrics=\{initialWindowMetrics\}/.test( + '', + ), + ).toBe(true); + expect( + /]*\binitialMetrics=\{initialWindowMetrics\}/.test(''), + ).toBe(false); + }); }); describe('DailyLoopDepthSelectScreen — DEBUG-469: the depth choices are pinned OUTSIDE the ScrollView', () => { diff --git a/app/__tests__/safety/deviceOnlyFlowsUnavailable.test.ts b/app/__tests__/safety/deviceOnlyFlowsUnavailable.test.ts new file mode 100644 index 00000000..d9de6202 --- /dev/null +++ b/app/__tests__/safety/deviceOnlyFlowsUnavailable.test.ts @@ -0,0 +1,170 @@ +/** + * Device-only Maestro flows are UNAVAILABLE — mechanical pin (DEBUG-589) + * + * No Maestro version can execute a flow on a physical iPhone. Measured 2026-09-07 on + * iPhone 16e / iOS 26.6 / Xcode 26.0.1 / team KN6FDLG98K, across 2.0.0, 2.1.0, 2.2.0, + * 2.4.0, 2.5.1, 2.6.0, 2.6.1, 2.7.0, 2.8.0, 2.9.0 and 2.10.0 — two failure modes, no + * survivor: + * >= 2.2.0 the shipped driver project declares a MaestroDriverLib framework target and + * the MaestroDriverLib/ directory is shipped in ZERO releases; the build dies + * in ~8s with no JUnit report. 2.10.0 is current latest, so upgrading cannot + * fix it. + * <= 2.1.0 the driver builds and the runner installs, but the XCUITest runner never + * becomes ready within a 300s MAESTRO_DRIVER_STARTUP_TIMEOUT. + * The hardware is healthy — the runner launches by hand via `devicectl`. Simulator flows + * are unaffected: they use a prebuilt driver and never compile. + * + * THIS SUITE ASSERTS ONLY THAT THE NOTICE IS PRESENT, BOUNDED, AND STILL TRUE. It asserts + * nothing that implies the gate is healthy — the device gate is not healthy, and a pin + * that could be read as coverage would recreate the hazard it exists to close. + * + * DEBUG-390 INVERTS HERE — read this before "fixing" the matching below. The house rule is + * that source-string assertions must STRIP comments first, because this codebase names + * anti-patterns in prose. That rule does not apply to this suite: the marker IS a comment, + * in a YAML file whose entire header is comments. Stripping would delete the thing under + * test and every assertion would pass vacuously. Matching is deliberately on RAW source, + * and the vacuity controls below are what stand in for the stripping rule's protection. + * + * Compensating follow-ups (crisis ruling, DEBUG-589): + * DEBUG-590 move the keyboard-accessory REACHABILITY half into the sim suite — it is + * simulator-runnable today (DEBUG-506), so that loss is recoverable and is + * recorded as "not yet migrated", never as "unavailable". + * INFRA-591 attended manual device checklist for both contracts, release-gated. + * INFRA-592 assert the GENERATED Info.plist keeps tel/sms after plugin composition — + * the one dial residual the surviving jest pin cannot reach, since it reads + * app.json and iOS is CNG (INFRA-280). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; + +const MAESTRO_DIR = path.join(__dirname, '..', '..', '.maestro'); + +/** Versions measured dead on 2026-09-07. A version outside this set invalidates the notice. */ +const RECORDED_DEAD_VERSIONS = [ + '2.0.0', '2.1.0', '2.2.0', '2.4.0', '2.5.1', + '2.6.0', '2.6.1', '2.7.0', '2.8.0', '2.9.0', '2.10.0', +]; + +const DEVICE_ONLY_FLOWS = ['crisis-988-dial.yaml', 'crisis-keyboard-accessory.yaml']; + +/** + * Structured, not prose. A reworded sentence must not be able to make the record vanish + * while the condition it describes still holds. + */ +const MARKER = + /^#\s*e2e-device-unavailable:\s*DEBUG-589\s+measured=(\d{4}-\d{2}-\d{2})\s+dead-versions=([0-9.]+)\.\.([0-9.]+)\s*$/m; + +function readFlow(name: string): string { + return fs.readFileSync(path.join(MAESTRO_DIR, name), 'utf8'); +} + +describe('DEBUG-589 — the device-unavailability notice', () => { + describe('vacuity controls (these guard every assertion below)', () => { + it('the marker regex fires against a known-GOOD literal', () => { + expect( + MARKER.test('# e2e-device-unavailable: DEBUG-589 measured=2026-09-07 dead-versions=2.0.0..2.10.0'), + ).toBe(true); + }); + + it('the marker regex does NOT fire against a near-miss literal', () => { + // Prose that mentions the concept must not satisfy a pin about the record existing. + expect(MARKER.test('# device flows are unavailable, see DEBUG-589')).toBe(false); + expect(MARKER.test('# e2e-device-unavailable: DEBUG-589')).toBe(false); + }); + + it.each(DEVICE_ONLY_FLOWS)('%s is a real, non-trivial file', (name) => { + // A rename or a move must go RED here rather than silently satisfying nothing. + expect(readFlow(name).length).toBeGreaterThan(1000); + }); + }); + + describe.each(DEVICE_ONLY_FLOWS)('%s', (name) => { + it('carries the structured unavailability marker', () => { + expect(readFlow(name)).toMatch(MARKER); + }); + + it('the marker carries its measured bounds and they match the recorded set', () => { + const m = MARKER.exec(readFlow(name)); + expect(m).not.toBeNull(); + const [, measured, low, high] = m as RegExpExecArray; + expect(measured).toBe('2026-09-07'); + expect(low).toBe(RECORDED_DEAD_VERSIONS[0]); + expect(high).toBe(RECORDED_DEAD_VERSIONS[RECORDED_DEAD_VERSIONS.length - 1]); + }); + + it('states the unavailability unhedged, and names the hardware as healthy', () => { + const src = readFlow(name); + expect(src).toMatch(/No Maestro version can\s*\n?#?\s*execute any flow on a physical iPhone|No Maestro version can execute any flow on a physical iPhone/); + expect(src).toMatch(/HARDWARE IS (?:HEALTHY|NOT THE PROBLEM)/); + }); + + it('is not deleted, renamed or skipped — the flow body still exists', () => { + const src = readFlow(name); + expect(src).toMatch(/^appId:\s*fyi\.being\.app$/m); + expect(src).toMatch(/^\s*-\s+safety-device-only\s*$/m); + }); + }); + + it('crisis-988-dial rebuts the PRIMARY/SUPPLEMENTARY reading rather than leaning on it', () => { + // The pre-existing header calls the jest pin PRIMARY and this flow SUPPLEMENTARY. + // Post-DEBUG-589 that phrasing reads as reassurance that nothing was lost, so the + // notice must say in the same breath that it is not subsumption. + const src = readFlow('crisis-988-dial.yaml'); + expect(src).toMatch(/do NOT mean the dial path was verified/i); + expect(src).toMatch(/do not cover runtime\s*\n?#?\s*behaviour on hardware|not cover runtime behaviour on hardware/i); + }); + + it('crisis-keyboard-accessory records a RECOVERABLE loss, never a permanent one', () => { + // Ruling: flattening this into the dial's "unavailable" would launder a movable gap + // into a permanent one. Its reachability half is simulator-runnable today (DEBUG-506). + const src = readFlow('crisis-keyboard-accessory.yaml'); + expect(src).toMatch(/SIMULATOR-RUNNABLE NOW/); + expect(src).toMatch(/NOT YET MIGRATED/); + expect(src).toMatch(/DEBUG-590/); + }); + + it('the set of safety-device-only flows is EXACTLY the two that were ruled on', () => { + // Without this, a third device-only flow inherits the unavailability silently, with + // nobody ruling on it. + const tagged = fs + .readdirSync(MAESTRO_DIR) + .filter((f) => f.endsWith('.yaml')) + .filter((f) => /^\s*-\s+safety-device-only\s*$/m.test(readFlow(f))) + .sort(); + expect(tagged).toEqual([...DEVICE_ONLY_FLOWS].sort()); + }); + + describe('drift arm — the notice must not outlive the condition', () => { + // CI is 100% ubuntu-latest and has no maestro, so this arm is skipped there. The skip + // is asserted to be DELIBERATE (maestro genuinely absent) rather than the invocation + // being broken, which is the failure mode a bare try/catch would hide. + let installed: string | null = null; + let resolutionFailed = false; + + try { + const out = execFileSync('maestro', ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const line = out.split('\n').map((l) => l.trim()).filter((l) => /^\d+\.\d+\.\d+$/.test(l)).pop(); + if (line) installed = line; + else resolutionFailed = true; + } catch { + installed = null; + } + + it('either resolved a version or maestro is genuinely absent — never a broken invocation', () => { + expect(resolutionFailed).toBe(false); + }); + + it('any installed maestro is inside the recorded-dead set', () => { + if (installed === null) { + // Deliberate skip: no maestro on PATH (CI). Every other assertion still ran. + return; + } + expect(RECORDED_DEAD_VERSIONS).toContain(installed); + }); + }); +}); diff --git a/app/__tests__/safety/e2eSeedGate.config.test.ts b/app/__tests__/safety/e2eSeedGate.config.test.ts index 467f0218..eddfd860 100644 --- a/app/__tests__/safety/e2eSeedGate.config.test.ts +++ b/app/__tests__/safety/e2eSeedGate.config.test.ts @@ -42,7 +42,7 @@ describe('EXPO_PUBLIC_E2E_SEED_ONBOARDED is scoped to the e2e-sim profile only', }, ); - it('the ineligible marker cannot be swallowed by another marker (INFRA-481)', () => { + it('no launch marker can be swallowed by another (INFRA-481, widened FEAT-570)', () => { // Every predicate in e2eSeed.ts is `url.includes(MARKER)`, so no marker token may be a // substring of another. The AC proposed `e2eSeed=stale-ineligible`, which // `isStaleConsentBootRequested` matches — an ineligible launch would have silently @@ -55,10 +55,17 @@ describe('EXPO_PUBLIC_E2E_SEED_ONBOARDED is scoped to the e2e-sim profile only', path.join(__dirname, '..', '..', 'src', 'core', 'config', 'e2eSeed.ts'), 'utf8', ); - const markers = [...src.matchAll(/E2E_SEED_[A-Z_]*MARKER\s*=\s*'([^']+)'/g)].map(m => m[1]); + // FEAT-570 WIDENED THIS PATTERN from `E2E_SEED_[A-Z_]*MARKER`. Its bug-report + // marker uses a different KEY (`e2eOpen=`) precisely so it can appear in the + // same URL as a seed marker and be read alongside it — and that different key + // meant the old regex did not match the constant at all, so the new token + // would have been excluded from the very check that exists to vet it. A + // collision guard that silently stops covering new members is the failure + // this file is otherwise built to prevent. + const markers = [...src.matchAll(/E2E_[A-Z_]*MARKER\s*=\s*'([^']+)'/g)].map(m => m[1]); // Fail CLOSED: a regex that stopped matching would make the loop below vacuous. - expect(markers.length).toBeGreaterThanOrEqual(3); + expect(markers.length).toBeGreaterThanOrEqual(4); for (const a of markers) { for (const b of markers) { @@ -69,6 +76,10 @@ describe('EXPO_PUBLIC_E2E_SEED_ONBOARDED is scoped to the e2e-sim profile only', // Proof the comparator still discriminates: the REJECTED token does collide. expect('e2eSeed=stale-ineligible'.includes('e2eSeed=stale')).toBe(true); + + // And proof the WIDENED pattern actually reaches the new key — without this, + // narrowing the regex back would leave the loop green on fewer markers. + expect(markers).toContain('e2eOpen=bugreport'); }); it('appears in exactly one build profile across all of eas.json', () => { @@ -255,3 +266,92 @@ describe('INFRA-377 stale-consent forge is reachable only through the store seam expect(seedSource.length).toBeGreaterThan(1000); }); }); + +/** + * INFRA-532 — the check-in seed that makes WeeklyReflectionComposer reachable. + * + * `WeeklyReflectionCard` renders null below MIN_CHECK_INS_TO_SHOW = 4, so the + * composer — a DEBUG-406 conversion site and a Protected Path — was absent from + * the gate build entirely and could only be given a printed notice. The seed + * writes four check-ins so `crisis-button-reachability` can tap through it. + * + * These pins protect the two properties that make that seed safe, neither of + * which is visible in a diff of the flow it enables. + * + * NOTE ON COMMENT-STRIPPING (DEBUG-390): this file's existing pins match import + * syntax and storage-key literals, which the codebase never names in prose. That + * is NOT true here — `'daily'` appears in `e2eSeed.ts`'s own comment explaining + * why it is excluded, so a bare `not.toContain("'daily'")` would match the + * warning and fail correct code. Strip comments first and match call-shaped + * patterns, then prove the matchers still fire. + */ +describe('INFRA-532 check-in seed stays inert to every other surface', () => { + /** `e2eSeed.ts` with block and line comments removed. */ + const strippedSeedSource = seedSource + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + + it("never seeds a 'daily' check-in", () => { + // 'daily' is the ONLY check-in type a production surface reads back + // (CleanHomeScreen -> isCheckInCompletedToday('daily')). Seeding it would + // flip the Home check-in card's completed state for every flow in the suite, + // including daily-loop-ax5-entry, which taps that card. The four types that + // ARE seeded have no reader outside getCheckInHistory. + expect(strippedSeedSource).not.toMatch(/['"]daily['"]/); + }); + + it('seeds exactly the four types the card needs, through the real store API', () => { + const listMatch = strippedSeedSource.match( + /E2E_SEEDED_CHECK_IN_TYPES:\s*readonly CheckInType\[\]\s*=\s*\[([\s\S]*?)\]/, + ); + expect(listMatch).not.toBeNull(); + + const seeded = (listMatch as RegExpMatchArray)[1] + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + expect(seeded).toEqual(['morning', 'midday', 'evening', 'learn']); + + // Through the real mutator, not a seam. INFRA-377's seam precedent requires + // that no real API can produce the state; markCheckInComplete can, so a new + // seam would be unjustified power. + expect(strippedSeedSource).toMatch(/markCheckInComplete\(/); + expect(strippedSeedSource).not.toMatch(/__seed[A-Za-z]*CheckIn[A-Za-z]*ForE2E/); + // And never by reaching around the store to its storage key. + expect(seedSource).not.toContain('stoic_practice_state'); + }); + + it('writes the check-ins AFTER all three marker early-returns', () => { + // The ungranted / stale / ineligible boot states must keep byte-identical + // state, or deeplink-consent-gate, reconsent-stale and + // reconsent-stale-ineligible silently start booting into a seeded store. + const lastMarkerIdx = strippedSeedSource.indexOf('isStaleIneligibleBootRequested(launchUrl)'); + const checkInIdx = strippedSeedSource.indexOf('markCheckInComplete('); + expect(lastMarkerIdx).toBeGreaterThan(-1); + expect(checkInIdx).toBeGreaterThan(lastMarkerIdx); + + // And after grantConsent, so the seeded store is written into a fully + // consented state rather than ahead of it. + expect(checkInIdx).toBeGreaterThan(strippedSeedSource.indexOf('grantConsent(preferences')); + }); + + it('the assertions above can still fail (DEBUG-390 control)', () => { + // Each matcher, proven against a literal known-bad string. Without this, + // comment-stripping plus a narrow regex is exactly the combination that can + // silently match nothing and read as a pass. + expect("await practice.markCheckInComplete('daily');").toMatch(/['"]daily['"]/); + expect('await practice.markCheckInComplete(type);').toMatch(/markCheckInComplete\(/); + expect('await __seedCheckInHistoryForE2E({});').toMatch( + /__seed[A-Za-z]*CheckIn[A-Za-z]*ForE2E/, + ); + expect("const K = 'stoic_practice_state';").toContain('stoic_practice_state'); + expect( + "const E2E_SEEDED_CHECK_IN_TYPES: readonly CheckInType[] = ['morning'];", + ).toMatch(/E2E_SEEDED_CHECK_IN_TYPES:\s*readonly CheckInType\[\]\s*=\s*\[([\s\S]*?)\]/); + + // The stripped source must still be substantial — a stripper that ate the + // file would make every `not.toMatch` above vacuously true. + expect(strippedSeedSource.length).toBeGreaterThan(1000); + expect(strippedSeedSource).toContain('maybeSeedE2EOnboardedState'); + }); +}); diff --git a/app/__tests__/safety/modalOcclusionConversions.test.tsx b/app/__tests__/safety/modalOcclusionConversions.test.tsx index c8941724..197868a4 100644 --- a/app/__tests__/safety/modalOcclusionConversions.test.tsx +++ b/app/__tests__/safety/modalOcclusionConversions.test.tsx @@ -25,6 +25,7 @@ import { render } from '@testing-library/react-native'; import ThresholdEducationModal from '@/core/components/ThresholdEducationModal'; import SessionNoteComposer from '@/features/insights/components/SessionNoteComposer'; import WeeklyReflectionComposer from '@/features/insights/components/WeeklyReflectionComposer'; +import { BugReportForm } from '@/core/components/BugReportOverlay'; import { CRISIS_BUTTON_EXCLUSION_RECT, OVERLAY_ACTION_ROW_PADDING_RIGHT, @@ -42,6 +43,7 @@ const noop = (): void => undefined; const CASES = [ { name: 'ThresholdEducationModal', + mount: 'inline' as const, overlayTestId: 'threshold-education-overlay', element: (visible: boolean) => ( @@ -49,6 +51,7 @@ const CASES = [ }, { name: 'SessionNoteComposer', + mount: 'root-slot' as const, overlayTestId: 'session-note-overlay', element: (visible: boolean) => ( ( ), }, + { + // FEAT-570. The fourth conversion, and the first whose ORIGINAL occluder was + // third-party code we do not render: Sentry's feedback widget, whose backdrop + // is a later sibling of our whole app, so no z-order change could reach it. + // The same structural guards apply to the replacement. + name: 'BugReportForm', + mount: 'root-slot' as const, + overlayTestId: 'bug-report-overlay', + element: (visible: boolean) => ( + + ), + }, ] as const; -describe.each(CASES)('DEBUG-406 · $name occlusion guards', ({ overlayTestId, element }) => { +describe.each(CASES)('DEBUG-406 · $name occlusion guards', ({ overlayTestId, element, mount }) => { it('renders no RN — the occlusion shape must not return', () => { const { UNSAFE_queryAllByType } = render(element(true)); expect(UNSAFE_queryAllByType(Modal)).toHaveLength(0); @@ -87,9 +103,30 @@ describe.each(CASES)('DEBUG-406 · $name occlusion guards', ({ overlayTestId, el expect(queryByTestId(overlayTestId)).toBeNull(); }); - it('traps iOS accessibility focus via accessibilityViewIsModal', () => { + // DEBUG-575 — SPLIT BY MOUNT SITE. This used to assert `toBe(true)` for all + // three, which PINNED A DEFECT: `accessibilityViewIsModal` prunes the + // RECEIVER'S SIBLINGS, and the two root-slot overlays are direct native + // siblings of RootCrisisButton and CrisisKeyboardAccessory (RootOverlaySlot + // renders a bare fragment). So on those two the prop deleted both crisis + // affordances from the accessibility tree — measured on device as zero + // `crisis-button-root` nodes with the sheet open, the button still painted. + // ThresholdEducationModal mounts INLINE in ProfileScreen, where the crisis + // button is an ancestor's sibling and out of prune scope, so it keeps the prop. + // + // Asserted on the RENDERED TREE, never on source text: both composers now + // carry prose naming this anti-pattern, which is exactly the DEBUG-390 + // collision a source-string matcher would trip over. + it('supplies its focus trap in the way its mount site allows', () => { const { getByTestId } = render(element(true)); - expect(getByTestId(overlayTestId).props.accessibilityViewIsModal).toBe(true); + const isModal = getByTestId(overlayTestId).props.accessibilityViewIsModal; + + if (mount === 'inline') { + expect(isModal).toBe(true); + } else { + // Root-slot: the trap is CleanRootNavigator's host instead, pinned by + // __tests__/safety/rootOverlayFocusTrap.test.tsx. + expect(isModal).not.toBe(true); + } }); it('is a full-bleed absolute layer, so its box is its host', () => { @@ -162,6 +199,12 @@ describe('DEBUG-406 · action rows clear the crisis button exclusion rect', () = onCancel={noop} />, , + // FEAT-570: on this one the padded row is the BOTTOM Send row, not the + // pinned header. The exclusion rect is anchored to the screen's bottom + // right, so padding a header would protect nothing and would leave Send in + // the contested column — where the FAB's zIndex 9999 turns a Send press + // into a wrong-destination crisis navigation. + , ]) { const { UNSAFE_root } = render(element); const padded = UNSAFE_root.findAll((n) => { @@ -186,6 +229,7 @@ describe('DEBUG-406 · the composers do not steal focus with autoFocus', () => { it.each([ ['session-note-input', ], ['weekly-reflection-input', ], + ['bug-report-input', ], ] as const)('%s does not autoFocus', (testId, element) => { const { getByTestId } = render(element); expect(getByTestId(testId).props.autoFocus).toBeFalsy(); diff --git a/app/__tests__/safety/modalOcclusionGuard.test.ts b/app/__tests__/safety/modalOcclusionGuard.test.ts index 6dba7a6b..3c04315c 100644 --- a/app/__tests__/safety/modalOcclusionGuard.test.ts +++ b/app/__tests__/safety/modalOcclusionGuard.test.ts @@ -35,8 +35,11 @@ const { ALLOWLIST, + PRESENTER_ALLOWLIST, + THIRD_PARTY_PRESENTERS, findAliasedModalImports, findModalJsx, + findPresenterCalls, runGuard, stripComments, // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -145,3 +148,221 @@ describe('DEBUG-406 · RN occlusion guard', () => { }); }); }); + +describe('INFRA-571 · third-party full-screen presenter call sites', () => { + /** + * Rule 4 is a different SHAPE from rules 1-3. Those match a component we + * render; this matches a CALL that hands presentation to a third party whose + * component never appears in our tree. DEBUG-533 found the first instance and + * no detector could see it: the guard scans `app/src` for JSX ``, so + * Sentry's `` in node_modules is invisible, and INFRA-531's + * crisis-constant-import rule matches nothing because nothing on the path + * imports from `features/crisis/`. The call site is the right anchor because + * it is the only part of the mechanism that is ours. + */ + + describe('the matcher fires for every denylisted name', () => { + it.each(THIRD_PARTY_PRESENTERS)('matches a literal known-bad call to %s', (name) => { + const found = findPresenterCalls(`const go = () => ${name}();`); + expect(found).toEqual([{ name, line: 1 }]); + }); + + it('matches the member form the real defect takes', () => { + // ExternalErrorReporter reaches Sentry through a dynamically-assigned + // module handle, so the live call is a member expression on an instance + // field — not the `Sentry.showFeedbackWidget()` an import-aware matcher + // would look for. + expect(findPresenterCalls('this.sentryModule.showFeedbackWidget();')).toEqual([ + { name: 'showFeedbackWidget', line: 1 }, + ]); + }); + + it('reports the real line number of the match', () => { + expect(findPresenterCalls('a\nb\nawait Sharing.shareAsync(uri);')).toEqual([ + { name: 'Sharing.shareAsync', line: 3 }, + ]); + }); + + it('tolerates whitespace around the member access and the call parens', () => { + expect(findPresenterCalls('RNIap . requestPurchase ({});')).toEqual([ + { name: 'RNIap.requestPurchase', line: 1 }, + ]); + }); + }); + + describe('the matcher stays silent on everything that is not a call', () => { + it('ignores a comment naming a presenter', () => { + const src = '/**\n * Never call showFeedbackWidget() here.\n */\nconst a = 1;'; + expect(findPresenterCalls(src)).toHaveLength(0); + }); + + it('ignores a line comment naming a presenter', () => { + expect(findPresenterCalls('// do not use Sharing.shareAsync(...)\nconst a = 1;')).toHaveLength(0); + }); + + it('ignores the capability probe, which is not a call', () => { + // ExternalErrorReporter.ts:619 — `typeof x.showFeedbackWidget === 'function'`. + const src = "if (typeof this.sentryModule.showFeedbackWidget === 'function') { noop(); }"; + expect(findPresenterCalls(src)).toHaveLength(0); + }); + + it('ignores the identifier inside a log-message string literal', () => { + // ExternalErrorReporter.ts:623 sits three lines from the real call. + // stripComments() blanks comments but NOT string literals, so a + // bare-identifier matcher would report a log message as a defect site. + const src = "logger.warn(LogCategory.SYSTEM, 'showFeedbackWidget failed');"; + expect(findPresenterCalls(src)).toHaveLength(0); + }); + + it('ignores a lookalike identifier that merely starts with a denylisted name', () => { + expect(findPresenterCalls('showFeedbackWidgetLater();')).toHaveLength(0); + }); + + it('still finds a real call sitting beneath a warning comment', () => { + const src = '// never call showFeedbackWidget()\nshowFeedbackWidget();'; + expect(findPresenterCalls(src)).toEqual([{ name: 'showFeedbackWidget', line: 2 }]); + }); + }); + + describe('comment-stripping cannot silently reduce this to a matcher that matches nothing', () => { + it('leaves a real source file substantially intact after stripping', () => { + // The DEBUG-390 failure mode is comment-stripping plus a narrow regex + // producing a guard that can never fire. Assert the input the matcher + // actually sees is still real code, not blanks. + // + // FEAT-570 REPOINTED THIS FIXTURE. It used to read ExternalErrorReporter.ts + // and assert `showFeedbackWidget(` survived stripping. That file no longer + // CALLS the presenter — FEAT-570 deleted the call, and every surviving + // mention of the name is prose explaining why it must not come back. So the + // stripped source correctly contains none, and the old assertion would have + // forced the call to be re-added to keep a test green. Repointed at a file + // that still carries a real, allowlisted presenter call. + const fs = require('fs'); + const path = require('path'); + const abs = path.join( + __dirname, + '../../src/features/profile/screens/ExportDataScreen.tsx', + ); + const stripped = stripComments(fs.readFileSync(abs, 'utf8')); + expect(stripped.replace(/\s/g, '').length).toBeGreaterThan(5000); + expect(stripped).toMatch(/Sharing\s*\.\s*shareAsync\s*\(/); + }); + }); + + describe('the real tree', () => { + const result = runGuard(); + + it('has no denylisted presenter call outside PRESENTER_ALLOWLIST', () => { + expect(result.unallowedPresenters).toEqual([]); + }); + + it('has no presenter ruling that outlived the call it examined', () => { + // Keyed per FILE+SYMBOL, not per file: a ruling that examined one call + // must go stale when THAT call is removed, even if a different + // denylisted call survives in the same file. A file-level key would + // silently transfer a recorded ruling onto a call it never examined, + // which is DEBUG-403's failure mode reproduced inside the fix for it. + expect(result.stalePresenters).toEqual([]); + }); + + it('does not disturb the rules it shares a script with', () => { + // A merged allowlist would cross-fire: a -allowlisted file would + // report as a stale presenter and vice versa. + expect(result.unallowed).toEqual([]); + expect(result.stale).toEqual([]); + expect(result.aliased).toEqual([]); + }); + + it('allowlists ONLY the presenter call sites with a recorded ruling', () => { + // FEAT-570 removed the DEBUG-533 entry. That deletion is the load-bearing + // artifact of the item, not bookkeeping: `Sentry.wrap` mounts + // FeedbackWidgetProvider unconditionally and dropping `feedbackIntegration` + // removes no occluder, so once the call site is gone the ONLY thing keeping + // this path closed is rule 4 refusing any new call. With no entry, a + // reintroduced `showFeedbackWidget` anywhere under app/src hard-fails. + expect(Object.keys(PRESENTER_ALLOWLIST).sort()).toEqual([ + 'src/core/services/subscription/IAPService.ts::RNIap.requestPurchase', + 'src/features/profile/screens/ExportDataScreen.tsx::Sharing.shareAsync', + ]); + }); + + it('records a substantive reason for every allowlisted presenter call', () => { + for (const [key, reason] of Object.entries(PRESENTER_ALLOWLIST)) { + expect(typeof reason).toBe('string'); + expect(reason.length).toBeGreaterThan(120); + expect(reason).toMatch(/DEBUG-533|INFRA-571/); + expect(key).toMatch(/^src\/.+::.+$/); + } + }); + + it('carries its own removal instruction in every ruling', () => { + // Whoever deletes a presenter call owns the allowlist edit. Putting the + // instruction in the ruling itself is what makes that mechanical rather + // than remembered — the CLI failure message repeats it. + for (const reason of Object.values(PRESENTER_ALLOWLIST)) { + expect(reason).toMatch(/DELETE this entry in the same commit/); + } + }); + + it('keeps measured and inferred rulings on opposite sides of the line', () => { + // Recording an unmeasured ruling as though measured is the failure + // DEBUG-533's own "MEASURED, NOT INFERRED" section was written to stop. + // + // THIS ASSERTION USED TO BE VACUOUS ON THE HALF THAT MATTERS (DEBUG-577). + // It pinned the measured entry with `toMatch(/MEASURED/)` — and /MEASURED/ + // is a substring of "NOT MEASURED", so the one check standing between a + // ruling and a false claim of evidence passed on prose asserting the exact + // opposite. A measured entry must therefore match the POSITIVE form and be + // asserted NOT to carry the negation. + // + // FEAT-570 + DEBUG-577 SWAPPED WHICH ENTRY IS THE MEASURED ONE. FEAT-570 + // removed DEBUG-533's entry with the call it examined, briefly leaving the + // allowlist wholly inferred; DEBUG-577 then measured the share sheet. So the + // partition below is not the historical one, and the count on each side is + // not what makes it correct — the prose matching its own evidence is. + const measured = [ + 'src/features/profile/screens/ExportDataScreen.tsx::Sharing.shareAsync', + ]; + for (const key of measured) { + expect(PRESENTER_ALLOWLIST[key]).toMatch(/MEASURED ON (DEVICE|SIMULATOR)/); + expect(PRESENTER_ALLOWLIST[key]).not.toMatch(/NOT MEASURED/); + } + + // IAPService remains reasoned from the presentation mechanism: DEBUG-577 + // attempted it and was blocked (mockMode = __DEV__, no .storekit config, no + // Android harness). Blocked is not measured. + const inferred = ['src/core/services/subscription/IAPService.ts::RNIap.requestPurchase']; + for (const key of inferred) { + expect(PRESENTER_ALLOWLIST[key]).toMatch(/NOT MEASURED/); + } + + // Every entry is on exactly one side. Without this the two loops above stay + // green over an entry that is on NEITHER list — which is what a third, + // unclassified ruling would be. + expect([...measured, ...inferred].sort()).toEqual(Object.keys(PRESENTER_ALLOWLIST).sort()); + + // Proof the matcher can still fire — the pair above is only worth its cost + // if the old vacuous form is demonstrably rejected by the new one. Both are + // literals, not entries, so they survive any future allowlist churn. + expect('REASONED FROM THE MECHANISM, NOT MEASURED').not.toMatch( + /MEASURED ON (DEVICE|SIMULATOR)/, + ); + expect('MEASURED ON DEVICE by DEBUG-533').toMatch(/MEASURED ON (DEVICE|SIMULATOR)/); + }); + + it('documents the denylist as non-exhaustive rather than as the set', () => { + const fs = require('fs'); + const path = require('path'); + const guard = fs.readFileSync( + path.join(__dirname, '../../scripts/check-modal-occlusion-guard.js'), + 'utf8', + ); + expect(guard).toMatch(/NON-EXHAUSTIVE/); + // `showScreenshotButton` is not a root export of @sentry/react-native + // 7.11.0, so it can never fire against the import shape this repo uses. + // It is forward-looking, and the header must not present the three + // Sentry names as a complete account of that SDK's presenters. + expect(THIRD_PARTY_PRESENTERS).toContain('showScreenshotButton'); + }); + }); +}); diff --git a/app/__tests__/safety/nativePurposeStrings.config.test.ts b/app/__tests__/safety/nativePurposeStrings.config.test.ts new file mode 100644 index 00000000..f1a66183 --- /dev/null +++ b/app/__tests__/safety/nativePurposeStrings.config.test.ts @@ -0,0 +1,208 @@ +/** + * Native purpose-string static-config pin (retro item #1) + * + * Companion to `speechRecognitionPermissions.config.test.ts` and + * `lsApplicationQueriesSchemes.config.test.ts`, and deliberately the inverse of + * both. Those enumerate the keys someone knew to write down. This one DERIVES + * the requirement from what is actually compiled into the binary, because the + * failure it exists to catch is precisely the key nobody knew to add. + * + * WHY DERIVED AND NOT ENUMERATED + * + * App Store Connect rejected v1.2.0 with ITMS-90683 for a missing + * `NSPhotoLibraryUsageDescription`. Nothing in the repo changed to cause it: + * `app/package.json` still declared `expo-file-system: ~56.0.7`, but the + * lockfile's RESOLVED version moved 56.0.7 -> 56.0.9, and 56.0.9 added + * `ios/Legacy/FileSystemHelpers.swift`, which calls `PHPhotoLibrary`. Apple + * requires the purpose string whenever the symbol is linked, used or not. + * + * An enumerated list cannot catch that class of change by construction — the + * new requirement arrives from a transitive source with no diff to review. So + * this scans the autolinked native sources and fails closed on any + * purpose-string API that is present but undeclared. + * + * WHAT IT PINS + * + * 1. Every purpose-string-requiring API symbol found in a module's `ios/` + * sources has a matching non-empty key in `app.json`'s `ios.infoPlist`, + * unless that (key, module) pair is explicitly exempted below with a reason. + * 2. The scan itself still works — see the two self-tests. A source-shape + * assertion that silently matches nothing is worse than no assertion, since + * it reads as a pass forever. + * + * WHAT IT DOES NOT PIN + * + * Whether Apple will accept the binary. Apple's static analysis is not + * published and is stricter for some frameworks than others; this asserts the + * declaration exists, not that the review passes. It also cannot see symbols + * reached only through a prebuilt `.xcframework` with no source in the package. + * + * iOS is CNG (INFRA-280), so `app.json` is the sole source of the generated + * `Info.plist` — asserting against `app.json` is asserting against the artifact. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const appJson = require('../../app.json'); + +const NODE_MODULES = path.resolve(__dirname, '../../node_modules'); + +/** + * Apple purpose-string keys and the API symbols that require them. Patterns are + * matched against native source text, so they name TYPES, not prose. + */ +const PURPOSE_STRING_APIS: ReadonlyArray<{ + key: string; + pattern: RegExp; +}> = [ + { key: 'NSPhotoLibraryUsageDescription', pattern: /\b(PHPhotoLibrary|PHAssetCreationRequest|UIImagePickerController)\b/ }, + { key: 'NSCameraUsageDescription', pattern: /\bAVCaptureDevice\b/ }, + { key: 'NSMicrophoneUsageDescription', pattern: /\b(AVAudioRecorder|requestRecordPermission)\b/ }, + { key: 'NSSpeechRecognitionUsageDescription', pattern: /\bSFSpeechRecognizer\b/ }, + { key: 'NSLocationWhenInUseUsageDescription', pattern: /\bCLLocationManager\b/ }, + { key: 'NSContactsUsageDescription', pattern: /\bCNContactStore\b/ }, + { key: 'NSCalendarsUsageDescription', pattern: /\bEKEventStore\b/ }, + { key: 'NSRemindersUsageDescription', pattern: /\bEKReminder\b/ }, + { key: 'NSFaceIDUsageDescription', pattern: /\bLAContext\b/ }, + { key: 'NSBluetoothAlwaysUsageDescription', pattern: /\bCBCentralManager\b/ }, + { key: 'NSMotionUsageDescription', pattern: /\b(CMPedometer|CMMotionActivityManager|CMSensorRecorder)\b/ }, + { key: 'NSHealthShareUsageDescription', pattern: /\bHKHealthStore\b/ }, + { key: 'NSAppleMusicUsageDescription', pattern: /\bMPMediaLibrary\b/ }, +]; + +/** + * Explicitly accepted (key, module) pairs. Each needs a reason that says why + * the symbol cannot reach a user-visible permission prompt. Adding a row here + * is a deliberate, reviewable act — which is the point. + */ +const EXEMPTIONS: ReadonlyArray<{ + key: string; + module: string; + reason: string; +}> = [ + { + key: 'NSMotionUsageDescription', + module: 'expo-sensors', + reason: + 'Only useBugReportShake.ts consumes expo-sensors, via Accelerometer (CMMotionManager), ' + + 'which iOS does not gate behind a purpose string. The CMPedometer reference lives in ' + + 'PedometerModule.swift, which no app code reaches. Revisit if anything imports Pedometer.', + }, +]; + +/** Native source files belonging to a package's own `ios/` directory. */ +function collectNativeSources(): Array<{ module: string; file: string }> { + const out: Array<{ module: string; file: string }> = []; + + const packageDirs: Array<{ module: string; dir: string }> = []; + for (const entry of fs.readdirSync(NODE_MODULES, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('@')) { + const scopeDir = path.join(NODE_MODULES, entry.name); + for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) { + if (scoped.isDirectory()) { + packageDirs.push({ module: `${entry.name}/${scoped.name}`, dir: path.join(scopeDir, scoped.name) }); + } + } + } else if (!entry.name.startsWith('.')) { + packageDirs.push({ module: entry.name, dir: path.join(NODE_MODULES, entry.name) }); + } + } + + const walk = (module: string, dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + walk(module, full); + } else if (/\.(swift|m|mm)$/.test(e.name)) { + out.push({ module, file: full }); + } + } + }; + + for (const { module, dir } of packageDirs) { + const iosDir = path.join(dir, 'ios'); + if (fs.existsSync(iosDir)) walk(module, iosDir); + } + return out; +} + +const SOURCES = collectNativeSources(); + +/** key -> modules that reference it, excluding exempted pairs. */ +function offendersByKey(): Map> { + const found = new Map>(); + for (const { module, file } of SOURCES) { + let text: string; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const { key, pattern } of PURPOSE_STRING_APIS) { + if (!pattern.test(text)) continue; + const exempt = EXEMPTIONS.some((x) => x.key === key && x.module === module); + if (exempt) continue; + if (!found.has(key)) found.set(key, new Set()); + found.get(key)!.add(module); + } + } + return found; +} + +describe('Native purpose strings — scan integrity', () => { + it('found a non-trivial set of native sources to scan', () => { + // Guards the silent-no-op failure mode: if autolinking, hoisting, or the + // directory layout changes such that nothing is scanned, every assertion + // below would pass vacuously and go on passing forever. + expect(SOURCES.length).toBeGreaterThan(200); + }); + + it('still matches a known-bad source string', () => { + const known = 'let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)'; + const photo = PURPOSE_STRING_APIS.find((a) => a.key === 'NSPhotoLibraryUsageDescription'); + expect(photo).toBeDefined(); + expect(photo!.pattern.test(known)).toBe(true); + }); + + it('every exemption names a module that still ships native sources', () => { + // A stale exemption silently widens the gate. If the module is gone, the + // row must go with it. + const modules = new Set(SOURCES.map((s) => s.module)); + for (const x of EXEMPTIONS) { + expect({ exemption: x.module, present: modules.has(x.module) }).toEqual({ + exemption: x.module, + present: true, + }); + } + }); +}); + +describe('Native purpose strings — iOS infoPlist contract', () => { + const infoPlist = appJson?.expo?.ios?.infoPlist ?? {}; + const offenders = offendersByKey(); + + it('declares a purpose string for every linked API that requires one', () => { + const missing = [...offenders.entries()] + .filter(([key]) => typeof infoPlist[key] !== 'string' || infoPlist[key].length === 0) + .map(([key, modules]) => `${key} (required by: ${[...modules].sort().join(', ')})`) + .sort(); + + // Named rather than counted: the failure message IS the fix instruction. + expect(missing).toEqual([]); + }); + + it.each( + [...offenders.keys()].sort().map((key) => [key, [...(offenders.get(key) ?? [])].sort().join(', ')]), + )('%s is declared (required by %s)', (key) => { + expect(typeof infoPlist[key as string]).toBe('string'); + expect((infoPlist[key as string] as string).length).toBeGreaterThan(0); + }); +}); diff --git a/app/__tests__/safety/perfHudGate.config.test.ts b/app/__tests__/safety/perfHudGate.config.test.ts new file mode 100644 index 00000000..1b561f14 --- /dev/null +++ b/app/__tests__/safety/perfHudGate.config.test.ts @@ -0,0 +1,120 @@ +/** + * Frame-probe HUD scoping pin (INFRA-373) + * + * `EXPO_PUBLIC_PERF_HUD` mounts `BreathingFrameProbe` on `PracticeTimerScreen` + * and renders a frame-delivery readout over the breathing practice. A debug + * overlay appearing during a breathing exercise is a user-visible defect in a + * wellness app, which is why the flag's Risk score exists. + * + * WHY THE PIN IS "ENABLED NOWHERE" RATHER THAN "ONE PROFILE ONLY". + * `e2eSeedGate.config.test.ts` — the model for this file — asserts its var is set + * in exactly one EAS profile, because the safety gate needs a build that carries + * it. This flag needs no such build. INFRA-309's AC assumed a dedicated + * `perf-device` EAS profile with ad-hoc provisioning; that was struck once + * INFRA-383 moved the local Release build off EAS and INFRA-424 built the + * physical-device path, so the probe is enabled by a shell variable at local + * build time and never by committed config. The boundary is therefore stronger + * than the seed gate's: no profile may carry it at all. + * + * The schema default is the second half of the boundary. `booleanString` with + * `.default('false')` means a build that never sets the var cannot render the HUD + * by omission — absence is off, rather than absence being a parse error someone + * silences by adding the var somewhere convenient. + * + * SOURCE ASSERTIONS STRIP COMMENTS FIRST (DEBUG-390). This codebase names + * anti-patterns in prose deliberately, and both files asserted on below mention + * `EXPO_PUBLIC_PERF_HUD` in their comments — including the string `'false'`. A + * bare `toContain` would match the commentary rather than the code, in both + * directions: a positive assertion could be satisfied by a comment that says the + * opposite of what the code does. Comment-stripping plus a narrow regex is also + * exactly the combination that can silently match nothing, so each matcher below + * is proved to fire against a known-good literal and to reject a known-bad one. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const HUD_VAR = 'EXPO_PUBLIC_PERF_HUD'; +const APP_ROOT = path.join(__dirname, '..', '..'); + +/** Remove block and line comments so assertions describe code, not commentary. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function readStripped(...segments: string[]): string { + return stripComments(fs.readFileSync(path.join(APP_ROOT, ...segments), 'utf8')); +} + +const easJson = JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'eas.json'), 'utf8')) as { + build: Record }>; +}; + +const SCHEMA_DEFAULT_RE = new RegExp(`${HUD_VAR}:\\s*booleanString\\.default\\('false'\\)`); +const MOUNT_GUARD_RE = new RegExp(`env\\.${HUD_VAR}\\s*===\\s*'true'\\s*&&`); + +describe(`${HUD_VAR} is enabled in no committed configuration`, () => { + // Every profile, not a hand-listed subset: a profile added later must fail this + // by default rather than be silently exempt because nobody updated the list. + it.each(Object.keys(easJson.build))('is absent from build.%s.env', (profile) => { + const env = easJson.build[profile]?.env ?? {}; + expect(Object.prototype.hasOwnProperty.call(env, HUD_VAR)).toBe(false); + }); + + it('eas.json declares at least one profile, so the check above is not vacuous', () => { + expect(Object.keys(easJson.build).length).toBeGreaterThan(0); + }); +}); + +describe(`${HUD_VAR} defaults to off, so omission cannot enable the HUD`, () => { + const envSource = readStripped('src', 'core', 'config', 'env.ts'); + + it('stripping left real code behind, not an empty string', () => { + expect(envSource.length).toBeGreaterThan(1000); + expect(envSource).toContain('envSchema'); + }); + + it('declares the flag as booleanString defaulting to false', () => { + expect(SCHEMA_DEFAULT_RE.test(envSource)).toBe(true); + }); + + it('the default matcher can go red', () => { + expect(SCHEMA_DEFAULT_RE.test(`${HUD_VAR}: booleanString.default('true'),`)).toBe(false); + expect(SCHEMA_DEFAULT_RE.test(`${HUD_VAR}: booleanString,`)).toBe(false); + expect(SCHEMA_DEFAULT_RE.test(`${HUD_VAR}: booleanString.default('false'),`)).toBe(true); + }); +}); + +describe('the probe is mounted only behind the flag', () => { + const screenSource = readStripped( + 'src', + 'features', + 'learn', + 'practices', + 'PracticeTimerScreen.tsx', + ); + + it('stripping left real code behind, not an empty string', () => { + expect(screenSource.length).toBeGreaterThan(1000); + expect(screenSource).toContain('BreathingCircle'); + }); + + it('gates the render on the flag', () => { + expect(MOUNT_GUARD_RE.test(screenSource)).toBe(true); + }); + + it('mounts the probe exactly once, so no ungated second render exists', () => { + const mounts = screenSource.match(/ { + expect(MOUNT_GUARD_RE.test('{true && }')).toBe(false); + expect(MOUNT_GUARD_RE.test(`{env.${HUD_VAR} !== 'true' && }`)).toBe( + false, + ); + expect(MOUNT_GUARD_RE.test(`{env.${HUD_VAR} === 'true' && }`)).toBe( + true, + ); + }); +}); diff --git a/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx b/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx new file mode 100644 index 00000000..e66b1359 --- /dev/null +++ b/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx @@ -0,0 +1,283 @@ +/** + * DEBUG-575 finding 2 — no overlay may hold the root slot on a crisis route. + * + * THE DEFECT. `RootOverlaySlot` paints above EVERY navigator route, including + * `CrisisResources` (a `Stack.Screen` with `presentation: 'modal'` — a JS stack + * modal, so there is no separate native window to escape into). An overlay left + * holding the slot therefore covered the destination the crisis button had just + * navigated to. Not dimmed: DEBUG-406 made these backdrops OPAQUE to satisfy + * WCAG 1.4.11, and the overlay root claims the touch responder, so the crisis + * screen was invisible AND inert. `RootCrisisButton` then suppressed itself on + * that route, so the affordance the user had just pressed vanished with nothing + * replacing it — a zero-988 state produced BY the crisis tap. + * + * This violates the invariant `crisis-zero-988-windows.test.tsx` already states: + * a route may suppress the root crisis overlay ONLY IF every reachable render + * state of that route mounts its own crisis affordance. + * + * WHY A UNIT TEST AND NOT A STATIC RULE. No static analysis reaches this. The + * two-list reconciliation, INFRA-531's crisis-import detector and + * check-modal-occlusion-guard.js all missed it, and none of them could catch it: + * "an overlay published into a slot that paints above the navigator, while the + * navigator's active route is the crisis destination" is a RUNTIME relation + * between two independent subtrees. All three defects on this branch were found + * by running the thing, not by reading it. + * + * The Maestro segment covers the user-visible half. This covers the invariant + * directly, in milliseconds, and can go red. + */ +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import { + useRootOverlayStore, + useRootOverlay, + CRISIS_DESTINATION_ROUTES, + SCREEN_OWNED_988_ROUTES, +} from '@/core/navigation/rootOverlaySlot'; +import { SUPPRESSED_ROUTES } from '@/features/crisis/components/RootCrisisButton'; + +const node = overlay; + +const reset = () => + useRootOverlayStore.setState({ + ownerId: null, + node: null, + overlayForbiddenRouteActive: false, + }); + +describe('DEBUG-575 · the slot is released when a crisis route becomes active', () => { + beforeEach(reset); + + it('releases an overlay that is holding the slot', () => { + const s = useRootOverlayStore.getState(); + s.claim('weekly-reflection-composer', node); + expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer'); + + useRootOverlayStore.getState().syncActiveRoute('CrisisResources'); + + const after = useRootOverlayStore.getState(); + expect(after.ownerId).toBeNull(); + expect(after.node).toBeNull(); + }); + + it('releases unconditionally — the owner cannot veto it', () => { + // Deliberately NOT the guarded `release(id)` path: the owner did not ask for + // this and must not be able to keep the slot. 988 wins over what is on screen. + useRootOverlayStore.getState().claim('session-note-composer', node); + useRootOverlayStore.getState().syncActiveRoute('CrisisResources'); + expect(useRootOverlayStore.getState().ownerId).toBeNull(); + }); + + it('refuses a NEW claim while a crisis route is active', () => { + useRootOverlayStore.getState().syncActiveRoute('CrisisResources'); + useRootOverlayStore.getState().claim('weekly-reflection-composer', node); + expect(useRootOverlayStore.getState().ownerId).toBeNull(); + }); + + it('allows claims again once the user leaves the crisis route', () => { + useRootOverlayStore.getState().syncActiveRoute('CrisisResources'); + useRootOverlayStore.getState().syncActiveRoute('Main'); + useRootOverlayStore.getState().claim('weekly-reflection-composer', node); + expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer'); + }); + + it('leaves the slot alone on every route that is not forbidden', () => { + // Route-keyed, NOT release-on-any-navigation: killing overlays on unrelated + // pushes would be a behaviour change nobody asked for. + // + // FEAT-570 NARROWED THIS LIST, deliberately. It used to include + // `AssessmentFlow` and `LegalGate`, pinning that they do NOT release. Those + // two moved into SCREEN_OWNED_988_ROUTES and now DO release — see the + // FEAT-570 block below for why refuse-only was not enough. The routes left + // here are the ones this assertion still means. + useRootOverlayStore.getState().claim('weekly-reflection-composer', node); + for (const route of ['Main', 'VoiceReflection', 'Learn', 'ExportData']) { + useRootOverlayStore.getState().syncActiveRoute(route); + expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer'); + } + }); + + it('is keyed on its own constant, not on SUPPRESSED_ROUTES', () => { + // SUPPRESSED_ROUTES means "the FAB steps aside here" and also holds + // AssessmentFlow and LegalGate, which are not crisis DESTINATIONS. Reusing a + // set whose meaning is adjacent-but-different is how the guidance/ and + // consent/ two-list failures started. + expect(CRISIS_DESTINATION_ROUTES).toEqual(['CrisisResources']); + expect(CRISIS_DESTINATION_ROUTES).not.toContain('AssessmentFlow'); + expect(CRISIS_DESTINATION_ROUTES).not.toContain('LegalGate'); + }); + + it('tolerates an undefined route name without releasing', () => { + useRootOverlayStore.getState().claim('weekly-reflection-composer', node); + useRootOverlayStore.getState().syncActiveRoute(undefined); + expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer'); + }); +}); + +/** + * FEAT-570 — the slot also refuses, and releases, on routes where the SCREEN + * owns the 988 affordance. + * + * THE GAP THIS CLOSES. `CRISIS_DESTINATION_ROUTES` is strictly narrower than + * `RootCrisisButton.SUPPRESSED_ROUTES`. On `AssessmentFlow` and `LegalGate` the + * root FAB deliberately hides and the screen supplies its own route to 988 + * (`EnhancedAssessmentFlow`'s own control; `CombinedLegalGateScreen`'s pinned + * pre-consent footer). An overlay published there paints an OPAQUE backdrop over + * the only affordance present, and `NavigatorA11yHost` additionally prunes the + * whole navigator subtree from the accessibility tree while the slot is held — + * so the state is zero-988 for sighted and assistive users alike. + * + * It was latent while both claimants lived in `features/insights/` and were + * reachable only by a deliberate tap on Insights. FEAT-570 adds the first + * claimant armed at the APP ROOT, which is what makes it live. + * + * WHY REFUSING THE CLAIM IS NOT ENOUGH — the finding that produced this block. + * Refusal covers one ordering (route becomes active, then something claims). The + * other ordering is reachable: `useBugReportShake()` is called at `App.tsx:49`, + * inside `App()` and above `NavigationContainer`, so the gesture is armed while + * `CleanRootNavigator` is still rendering `LoadingScreen`. A claim can therefore + * be standing BEFORE any route exists to check, and the navigator's first real + * route on a fresh install is `LegalGate`. `useRootOverlay`'s claim effect also + * has no dependency array, so it races `onReady`'s first `syncActiveRoute` on + * every launch. Release is the half that covers those orderings. + * + * ONE PREDICATE, NOT TWO FLAGS. The store carries a single + * `overlayForbiddenRouteActive` computed from the union of the two constants. + * Two booleans with adjacent meanings is the `guidance/` + `consent/` + * two-list drift failure rebuilt inside the slot itself. + */ +describe('FEAT-570 · routes where the screen owns the 988 affordance', () => { + beforeEach(reset); + + it.each([...SCREEN_OWNED_988_ROUTES])('refuses a new claim on %s', (route) => { + useRootOverlayStore.getState().syncActiveRoute(route); + useRootOverlayStore.getState().claim('bug-report-form', node); + expect(useRootOverlayStore.getState().ownerId).toBeNull(); + }); + + it.each([...SCREEN_OWNED_988_ROUTES])( + 'releases an overlay already holding the slot when %s becomes active', + (route) => { + // The boot-race half. Refusal alone leaves this state standing. + useRootOverlayStore.getState().claim('bug-report-form', node); + useRootOverlayStore.getState().syncActiveRoute(route); + expect(useRootOverlayStore.getState().ownerId).toBeNull(); + expect(useRootOverlayStore.getState().node).toBeNull(); + }, + ); + + it('allows claims again once the user leaves a screen-owned route', () => { + useRootOverlayStore.getState().syncActiveRoute('LegalGate'); + useRootOverlayStore.getState().syncActiveRoute('Main'); + useRootOverlayStore.getState().claim('bug-report-form', node); + expect(useRootOverlayStore.getState().ownerId).toBe('bug-report-form'); + }); + + it('keeps its own named constant, distinct from the crisis-destination set', () => { + // The two sets encode different REASONS, which is what makes their + // maintenance rules different. CRISIS_DESTINATION_ROUTES means "the user was + // sent here FOR 988 and an overlay covers the destination". + // SCREEN_OWNED_988_ROUTES means "the FAB stepped aside because the screen + // owns the affordance, and an overlay covers THAT". Merging them would make + // CrisisResources and LegalGate look like the same kind of thing, and the + // next editor would reasonably delete one. + expect(SCREEN_OWNED_988_ROUTES).toEqual(['AssessmentFlow', 'LegalGate']); + expect(SCREEN_OWNED_988_ROUTES).not.toContain('CrisisResources'); + }); +}); + +/** + * FEAT-570 — the two-list reconciliation, made mechanical. + * + * `rootOverlaySlot`'s docblock rules out importing `SUPPRESSED_ROUTES` in the + * SOURCE, and that rule is correct: the sets mean different things and reuse is + * how the `guidance/` and `consent/` failures started. But refusing to relate + * them at all recreates the same hazard from the other side — add a route to + * `SUPPRESSED_ROUTES` and a new zero-988 window opens here silently. + * + * A TEST may import both, and this is where the relation is enforced. Superset, + * not equality, so over-refusal by the slot stays legal; the literal snapshot + * above makes any addition a reviewed edit rather than a silent one. + */ +describe('FEAT-570 · every FAB-suppressed route is forbidden to the slot', () => { + const covered = new Set([...CRISIS_DESTINATION_ROUTES, ...SCREEN_OWNED_988_ROUTES]); + + it.each([...SUPPRESSED_ROUTES])( + '%s is covered by the slot’s forbidden set', + (route) => { + expect(covered.has(route)).toBe(true); + }, + ); + + it('is not vacuous — SUPPRESSED_ROUTES is populated and is the real set', () => { + // Without this, an emptied SUPPRESSED_ROUTES would make every case above + // pass by having nothing to iterate. + expect(SUPPRESSED_ROUTES.size).toBeGreaterThanOrEqual(3); + expect(SUPPRESSED_ROUTES.has('CrisisResources')).toBe(true); + expect(SUPPRESSED_ROUTES.has('AssessmentFlow')).toBe(true); + expect(SUPPRESSED_ROUTES.has('LegalGate')).toBe(true); + }); + + it('would go red if a route were added to SUPPRESSED_ROUTES and not here', () => { + // Proves the relation can still fail — the matcher is not merely reporting + // a superset that happens to be true today. + const withNewRoute = new Set([...SUPPRESSED_ROUTES, 'SomeFutureSuppressedRoute']); + const uncovered = [...withNewRoute].filter((r) => !covered.has(r)); + expect(uncovered).toEqual(['SomeFutureSuppressedRoute']); + }); +}); + +/** + * FEAT-570 — a REFUSED claimant has to be told, or the refusal is worse than + * the thing it prevents. + * + * `claim()` used to log and return with no signal, and `useRootOverlay`'s revoke + * effect only fires for an owner that WAS holding — so a claimant refused at the + * door was never told. Combined with the claim effect having no dependency array + * (it re-runs on every render), a refused overlay leaves `visible` true, re-claims + * on every render, and then pops at the user the instant they leave the route. + * + * The two `features/insights/` composers cannot reach this: they are mounted + * inside `InsightsScreen` and can never be rendered while a forbidden route is + * active. An overlay armed at the app root can, which is why this only becomes a + * defect with FEAT-570's claimant. + */ +describe('FEAT-570 · refusal is signalled back to the owner', () => { + beforeEach(reset); + + it('claim() reports whether the slot was actually taken', () => { + expect(useRootOverlayStore.getState().claim('bug-report-form', node)).toBe(true); + reset(); + useRootOverlayStore.getState().syncActiveRoute('LegalGate'); + expect(useRootOverlayStore.getState().claim('bug-report-form', node)).toBe(false); + }); + + it('calls onRevoked when the claim is refused at the door', () => { + const onRevoked = jest.fn(); + useRootOverlayStore.getState().syncActiveRoute('AssessmentFlow'); + + const Overlay: React.FC = () => { + useRootOverlay('bug-report-form', true, () => node, onRevoked); + return null; + }; + render(); + + expect(useRootOverlayStore.getState().ownerId).toBeNull(); + expect(onRevoked).toHaveBeenCalled(); + }); + + it('does not call onRevoked when the claim succeeds', () => { + // Anti-vacuity: proves the assertion above is reading the refusal and not + // just any render of the hook. + const onRevoked = jest.fn(); + const Overlay: React.FC = () => { + useRootOverlay('bug-report-form', true, () => node, onRevoked); + return null; + }; + render(); + + expect(useRootOverlayStore.getState().ownerId).toBe('bug-report-form'); + expect(onRevoked).not.toHaveBeenCalled(); + }); +}); diff --git a/app/__tests__/safety/rootOverlayFocusTrap.test.tsx b/app/__tests__/safety/rootOverlayFocusTrap.test.tsx new file mode 100644 index 00000000..1100c365 --- /dev/null +++ b/app/__tests__/safety/rootOverlayFocusTrap.test.tsx @@ -0,0 +1,173 @@ +/** + * DEBUG-575 — the focus trap for root-slot overlays lives on the navigator host, + * and the crisis affordances stay OUTSIDE it. + * + * The defect this pins against: `accessibilityViewIsModal` on an overlay + * published into `RootOverlaySlot` pruned both `RootCrisisButton` and + * `CrisisKeyboardAccessory` out of the accessibility tree, because the slot + * renders a bare fragment and the overlay is therefore their direct native + * SIBLING. Measured on the gate sim: zero `crisis-button-root` nodes with the + * weekly-reflection composer open, one again once dismissed, with the button + * painted on screen the whole time. A zero-988 state for assistive technology + * that no screenshot and no `` guard could catch. + * + * `modalOcclusionConversions.test.tsx` pins the negative half (the two slot + * composers must NOT set the prop). This file pins the positive half: something + * still traps focus, and it excludes the crisis affordances. + * + * WHY THE WIRING IS PINNED FROM SOURCE. `CleanRootNavigator` cannot be rendered + * here — importing it drags in the whole screen tree and several transitive deps + * sit outside `transformIgnorePatterns`, the same constraint + * `CleanTabNavigator.accessibility.test.tsx` records. The host component itself + * is therefore tested behaviourally, and only its PLACEMENT is read from source. + * Comments are stripped first: `CleanRootNavigator` and both composers now carry + * prose naming this anti-pattern, which is exactly the DEBUG-390 collision where + * a bare identifier match fails on correct code. + */ +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import { Text } from 'react-native'; +import { render, act } from '@testing-library/react-native'; + +import NavigatorA11yHost from '@/core/navigation/NavigatorA11yHost'; +import { + useRootOverlayStore, + useIsRootOverlayOccupied, +} from '@/core/navigation/rootOverlaySlot'; + +const navigatorSource = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'core', 'navigation', 'CleanRootNavigator.tsx'), + 'utf8', +); + +/** Source with block and line comments removed (DEBUG-390). */ +const stripped = navigatorSource + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + +describe('DEBUG-575 · NavigatorA11yHost hides its subtree on demand', () => { + it('hides descendants from assistive tech while an overlay holds the slot', () => { + const { getByTestId } = render( + , + ); + // `includeHiddenElements` is REQUIRED here and is itself evidence: RNTL + // excludes accessibility-hidden nodes from queries by default, so the host + // is only findable this way precisely BECAUSE the hiding took effect. A + // future regression that drops the props makes the plain query start + // working — which is why the sibling test below asserts the enabled case + // with a plain query. + const host = getByTestId('navigator-a11y-host', { includeHiddenElements: true }); + // Both platforms. accessibilityViewIsModal is iOS-only and was never the + // right tool here; importantForAccessibility is what carries Android, where + // nothing trapped focus at all before this. + expect(host.props.accessibilityElementsHidden).toBe(true); + expect(host.props.importantForAccessibility).toBe('no-hide-descendants'); + }); + + it('leaves the subtree fully reachable when the slot is empty', () => { + const { getByTestId } = render( + , + ); + // Plain query, deliberately: with the slot empty the navigator must be + // reachable by assistive tech, so RNTL must find it without the escape hatch. + const host = getByTestId('navigator-a11y-host'); + expect(host.props.accessibilityElementsHidden).toBe(false); + expect(host.props.importantForAccessibility).toBe('auto'); + }); + + it('never sets accessibilityViewIsModal — that is the defect, not the fix', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId('navigator-a11y-host', { includeHiddenElements: true }).props + .accessibilityViewIsModal, + ).not.toBe(true); + }); +}); + +describe('DEBUG-575 · slot occupancy drives the trap', () => { + afterEach(() => { + act(() => useRootOverlayStore.getState().release('probe')); + }); + + const Probe: React.FC = () => ( + {String(useIsRootOverlayOccupied())} + ); + + it('is false with an empty slot and true once an overlay claims it', () => { + const { getByTestId } = render(); + expect(getByTestId('occupied').props.children).toBe('false'); + + act(() => useRootOverlayStore.getState().claim('probe', o)); + expect(getByTestId('occupied').props.children).toBe('true'); + + act(() => useRootOverlayStore.getState().release('probe')); + expect(getByTestId('occupied').props.children).toBe('false'); + }); +}); + +describe('DEBUG-575 · the crisis affordances are outside the host', () => { + // The load-bearing structural claim. If any crisis affordance moved INSIDE the + // host, hiding the navigator would hide 988 too — reintroducing the very + // zero-affordance state this fix removes, and the flow would not catch it + // because the flow asserts the button is reachable while the sheet is up, + // which is exactly when the host is hidden. + const idx = (needle: string) => stripped.indexOf(needle); + + it('wraps the Stack.Navigator, and only the Stack.Navigator', () => { + const hostOpen = idx(''); + const hostClose = idx(''); + + expect(hostOpen).toBeGreaterThan(-1); + expect(hostClose).toBeGreaterThan(-1); + expect(hostOpen).toBeLessThan(navOpen); + expect(navClose).toBeLessThan(hostClose); + }); + + it('leaves the slot and both crisis affordances outside it', () => { + const hostClose = idx(''); + for (const affordance of [ + ' { + expect(stripped).toMatch(/ { + // Prove each matcher fires against a literal known-bad string, and that the + // stripper did not eat the file — a vacuous `indexOf` of -1 would otherwise + // make the ordering assertions silently meaningless. + expect('