Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/.maestro/_seeded-home.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
appId: com.being.app
tags:
- helper
name: "Helper: wait for seeded Main tab (e2e-sim)"
---
# Reusable subflow for the no-dev-client e2e-sim build (INFRA-217). Called via
# `- runFlow: _seeded-home.yaml` from each sim safety flow after a fresh
# launchApp { clearState: true, clearKeychain: true }.
#
# The e2e-sim EAS profile sets EXPO_PUBLIC_E2E_SEED_ONBOARDED=true, so App.tsx
# seeds legal consents + age verification + onboarding-complete at launch and
# CleanRootNavigator routes straight to Main. There is therefore NO LegalGate /
# onboarding preamble to traverse — we just wait for the home screen.
#
# Replaces the long `_legal-and-onboarding.yaml` traversal for the sim flows.
# That file is intentionally retained, unchanged, for the device-only
# `crisis-988-dial.yaml` flow, which runs on a non-seeded real-device build and
# still needs the full LegalGate + onboarding walk.
#
# The 90s timeout absorbs cold boot + encryption init + the async seed writes
# (SecureStore consent + AsyncStorage onboarding flag) on the slower Release build.
- extendedWaitUntil:
visible:
id: "home-screen"
timeout: 90000
2 changes: 1 addition & 1 deletion app/.maestro/crisis-button-reachability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name: "Crisis button reaches CrisisResources from every tab"
- launchApp:
clearState: true
clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179)
- runFlow: _legal-and-onboarding.yaml
- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home

# Tabs are addressed via tabBarTestID (INFRA-183, CleanTabNavigator.tsx) —
# Maestro's text: selector doesn't match the bottom-tab accessibilityText
Expand Down
2 changes: 1 addition & 1 deletion app/.maestro/gad7-severe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: "GAD-7 score >=15 shows crisis results"
- launchApp:
clearState: true
clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179)
- runFlow: _legal-and-onboarding.yaml
- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home

# INFRA-186: scrollUntilVisible required before take-gad7-button —
# the card sits further below the fold than take-phq9-button. See
Expand Down
2 changes: 1 addition & 1 deletion app/.maestro/phq9-severe-completion.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ name: "PHQ-9 score >=20 completion shows crisis results"
- launchApp:
clearState: true
clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179)
- runFlow: _legal-and-onboarding.yaml
- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home

# INFRA-186: scrollUntilVisible required before take-phq9-button —
# see q9-single-alert.yaml for the geometry rationale (card overflows the
Expand Down
2 changes: 1 addition & 1 deletion app/.maestro/q9-single-alert.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ name: "PHQ-9 Q9 single canonical alert"
- launchApp:
clearState: true
clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179)
- runFlow: _legal-and-onboarding.yaml
- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home

# Navigate to Profile → PHQ-9 entry point. INFRA-186: the PHQ-9 card sits
# below the fold on iPhone 16 Plus (its bounds extend past screen height),
Expand Down
13 changes: 13 additions & 0 deletions app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { initializeCrisisMonitoring } from './src/core/services/monitoring';
import { DataRetentionService } from './src/core/services/data-retention';
import { PostHogProvider } from './src/core/analytics';
import { closeMenu as closeDevMenu } from 'expo-dev-menu';
import { maybeSeedE2EOnboardedState } from './src/core/config/e2eSeed';

// INFRA-181: hide RN LogBox during Maestro runs. The dev warning toast (e.g.
// posthog-react-native's "usePostHog was called without a client" notice when
Expand Down Expand Up @@ -58,6 +59,18 @@ export default function App() {
);
logSystem('Encryption service initialized');

// INFRA-217: seed post-onboarding state for the e2e-sim safety gate.
// No-op unless EXPO_PUBLIC_E2E_SEED_ONBOARDED==='true' (e2e-sim profile
// only). Runs after EncryptionService.initialize() because the seeded
// consent record persists to SecureStore. Self-contained try/catch, so
// it never blocks init; gate the navigator render on its completion.
// INFRA-217: seed post-onboarding state for the e2e-sim safety gate.
// No-op unless EXPO_PUBLIC_E2E_SEED_ONBOARDED==='true' (e2e-sim profile
// only). Runs after EncryptionService.initialize() because the seeded
// consent record persists to SecureStore. Releases the seed gate that
// CleanRootNavigator awaits before resolving its initial route.
await maybeSeedE2EOnboardedState();

// Remaining init tasks are independent. allSettled (not all) so one
// best-effort failure doesn't abort the others. IAP init only runs
// when the platform supports it.
Expand Down
74 changes: 74 additions & 0 deletions app/__tests__/safety/e2eSeedGate.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* e2e-sim seed-gate config pin (INFRA-217)
*
* The Maestro safety gate runs against a no-dev-client EAS Release build whose
* `e2e-sim` profile seeds post-onboarding state at launch (consent + onboarding
* complete) so the safety flows start at the Main tab. That seed is gated by
* `EXPO_PUBLIC_E2E_SEED_ONBOARDED`, which MUST be set ONLY in the `e2e-sim`
* profile — never in a shipping build.
*
* Compliance boundary (INFRA-217 AC, `compliance` agent review): a production /
* preview / production-emergency build must be structurally incapable of
* auto-granting consent. There is deliberately NO `EXPO_PUBLIC_ENV==='production'`
* superRefine in env.ts (the `e2e-sim` profile `extends: production` and resolves
* `EXPO_PUBLIC_ENV=production`, so such a guard would refuse to boot the very
* build the gate needs). The boundary therefore rests SOLELY on this eas.json
* profile scoping — this static-config test is the durable, in-CI pin for it.
* Modeled on `lsApplicationQueriesSchemes.config.test.ts` (INFRA-184).
*/

import * as fs from 'fs';
import * as path from 'path';

const SEED_VAR = 'EXPO_PUBLIC_E2E_SEED_ONBOARDED';

const easJson = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'eas.json'), 'utf8'),
) as { build: Record<string, { env?: Record<string, string> }> };

describe('EXPO_PUBLIC_E2E_SEED_ONBOARDED is scoped to the e2e-sim profile only', () => {
it('is set to "true" in build.e2e-sim.env', () => {
expect(easJson.build['e2e-sim']?.env?.[SEED_VAR]).toBe('true');
});

// Every profile that can produce a shippable / non-e2e artifact must NOT carry
// the seed var. production-emergency is non-negotiable — it extends production
// and ships to the App Store.
it.each(['production', 'production-emergency', 'preview', 'development'])(
'is absent from build.%s.env (no auto-grant in a non-e2e build)',
(profile) => {
const env = easJson.build[profile]?.env ?? {};
expect(Object.prototype.hasOwnProperty.call(env, SEED_VAR)).toBe(false);
},
);

it('appears in exactly one build profile across all of eas.json', () => {
const profilesWithVar = Object.entries(easJson.build)
.filter(([, cfg]) => cfg.env && Object.prototype.hasOwnProperty.call(cfg.env, SEED_VAR))
.map(([name]) => name);
expect(profilesWithVar).toEqual(['e2e-sim']);
});
});

describe('env.ts defaults the seed var to disabled', () => {
// Source-level assertion: the schema must declare the seed var with a 'false'
// default so any build that does not explicitly set it (i.e. every real build)
// resolves to disabled. Reading the source keeps this pin independent of the
// module-load env validation.
const envSource = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'core', 'config', 'env.ts'),
'utf8',
);

it("registers EXPO_PUBLIC_E2E_SEED_ONBOARDED with booleanString.default('false')", () => {
expect(envSource).toMatch(
/EXPO_PUBLIC_E2E_SEED_ONBOARDED:\s*booleanString\.default\('false'\)/,
);
});

it('reads the seed var explicitly in readRawEnv (preserves Babel inlining)', () => {
expect(envSource).toMatch(
/EXPO_PUBLIC_E2E_SEED_ONBOARDED:\s*process\.env\['EXPO_PUBLIC_E2E_SEED_ONBOARDED'\]/,
);
});
});
145 changes: 145 additions & 0 deletions app/__tests__/unit/e2e-seed-onboarded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Unit tests for the e2e-sim onboarding seed gate (INFRA-217).
*
* `maybeSeedE2EOnboardedState()` writes a real post-onboarding state (onboarding
* flag + legal-gate consents + age verification + full consent record) at launch
* so the Maestro safety flows start at the Main tab instead of traversing the
* 16-question onboarding preamble on the slow e2e-sim Release build.
*
* Compliance boundary (INFRA-217 AC, `compliance` agent review):
* - The seed is a strict no-op unless `env.EXPO_PUBLIC_E2E_SEED_ONBOARDED === 'true'`
* — that var is set ONLY in the e2e-sim EAS profile. These tests pin both branches.
* - The seed uses the REAL store APIs (grantConsent / verifyAge / recordLegalGateConsents);
* it does NOT weaken `canPerformOperation(...)`. The eas.json profile scoping is pinned
* separately in `__tests__/safety/e2eSeedGate.config.test.ts`.
*/

const SEED_MODULE = '@/core/config/e2eSeed';

interface SeedMocks {
loadSettings: jest.Mock;
markOnboardingComplete: jest.Mock;
grantConsent: jest.Mock;
verifyAge: jest.Mock;
recordLegalGateConsents: jest.Mock;
logSystem: jest.Mock;
logError: jest.Mock;
}

/**
* Re-require the seed module with the env flag set to `flag` and all store /
* logging dependencies mocked. Returns the loaded module plus the mock fns so a
* test can assert which store APIs were (or were not) called.
*/
function loadSeed(flag: string | undefined): { run: () => Promise<void>; mocks: SeedMocks } {
jest.resetModules();

const mocks: SeedMocks = {
loadSettings: jest.fn().mockResolvedValue(null),
markOnboardingComplete: jest.fn().mockResolvedValue(undefined),
grantConsent: jest.fn().mockResolvedValue(undefined),
verifyAge: jest.fn().mockResolvedValue({ eligible: true, age: 36 }),
recordLegalGateConsents: jest.fn().mockResolvedValue(undefined),
logSystem: jest.fn(),
logError: jest.fn(),
};

jest.doMock('@/core/config/env', () => ({
env: { EXPO_PUBLIC_E2E_SEED_ONBOARDED: flag },
}));
jest.doMock('@/core/stores/settingsStore', () => ({
useSettingsStore: {
getState: () => ({
loadSettings: mocks.loadSettings,
markOnboardingComplete: mocks.markOnboardingComplete,
}),
},
}));
jest.doMock('@/core/stores/consentStore', () => ({
useConsentStore: {
getState: () => ({ verifyAge: mocks.verifyAge, grantConsent: mocks.grantConsent }),
},
recordLegalGateConsents: mocks.recordLegalGateConsents,
}));
jest.doMock('@/core/services/logging', () => ({
logSystem: mocks.logSystem,
logError: mocks.logError,
LogCategory: { SYSTEM: 'system' },
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(SEED_MODULE) as { maybeSeedE2EOnboardedState: () => Promise<void> };
return { run: mod.maybeSeedE2EOnboardedState, mocks };
}

describe('maybeSeedE2EOnboardedState — gate (INFRA-217)', () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});

it('is a no-op when the flag is unset (real builds)', async () => {
const { run, mocks } = loadSeed(undefined);
await run();
expect(mocks.markOnboardingComplete).not.toHaveBeenCalled();
expect(mocks.grantConsent).not.toHaveBeenCalled();
expect(mocks.recordLegalGateConsents).not.toHaveBeenCalled();
expect(mocks.verifyAge).not.toHaveBeenCalled();
});

it("is a no-op when the flag is the string 'false'", async () => {
const { run, mocks } = loadSeed('false');
await run();
expect(mocks.markOnboardingComplete).not.toHaveBeenCalled();
expect(mocks.grantConsent).not.toHaveBeenCalled();
});

it("does not treat a non-'true' truthy value ('1') as enabled", async () => {
const { run, mocks } = loadSeed('1');
await run();
expect(mocks.markOnboardingComplete).not.toHaveBeenCalled();
expect(mocks.grantConsent).not.toHaveBeenCalled();
});

describe("when the flag is exactly 'true' (e2e-sim profile)", () => {
it('marks onboarding complete (routing checks this first)', async () => {
const { run, mocks } = loadSeed('true');
await run();
expect(mocks.markOnboardingComplete).toHaveBeenCalledTimes(1);
});

it('records legal-gate consents with mental-health processing consent', async () => {
const { run, mocks } = loadSeed('true');
await run();
expect(mocks.recordLegalGateConsents).toHaveBeenCalledWith(
expect.objectContaining({
tosAccepted: true,
privacyAccepted: true,
wellnessDisclaimerAcknowledged: true,
mentalHealthProcessingConsent: true,
}),
);
});

it('grants a full consent record with an eligible (18+) age verification', async () => {
const { run, mocks } = loadSeed('true');
await run();
expect(mocks.verifyAge).toHaveBeenCalledTimes(1);
expect(mocks.grantConsent).toHaveBeenCalledTimes(1);

const [prefs, ageVerification] = mocks.grantConsent.mock.calls[0];
// mentalHealthProcessingConsent unlocks the assessment / check-in screens
// the safety flows exercise (GDPR Art. 9(2)(a)).
expect(prefs.mentalHealthProcessingConsent).toBe(true);
expect(ageVerification.verified).toBe(true);
expect(ageVerification.isEligible).toBe(true);
});

it('is non-blocking: a store failure is swallowed, not thrown', async () => {
const { run, mocks } = loadSeed('true');
mocks.grantConsent.mockRejectedValueOnce(new Error('SecureStore unavailable'));
await expect(run()).resolves.toBeUndefined();
expect(mocks.logError).toHaveBeenCalled();
});
});
});
3 changes: 3 additions & 0 deletions app/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"ios": {
"simulator": true,
"buildConfiguration": "Release"
},
"env": {
"EXPO_PUBLIC_E2E_SEED_ONBOARDED": "true"
}
},
"production-emergency": {
Expand Down
Loading
Loading