diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json index ed0dcf8b..1a35176f 100644 --- a/app/.eslint-baseline.json +++ b/app/.eslint-baseline.json @@ -74,6 +74,7 @@ "src/core/services/supabase/__tests__/SupabaseService.test.ts": 1, "src/core/services/supabase/__tests__/analyticsGate.unit.test.ts": 1, "src/core/services/supabase/__tests__/crisisTelemetryDurable.unit.test.ts": 1, + "src/core/services/supabase/__tests__/crisisTelemetryGuard.unit.test.ts": 1, "src/core/services/supabase/hooks/useCloudSync.ts": 3, "src/core/services/supabase/index.ts": 3, "src/core/stores/__tests__/consentStore.test.ts": 1, @@ -103,6 +104,7 @@ "src/features/assessment/stores/__tests__/assessmentStore.notes.test.ts": 1, "src/features/assessment/stores/__tests__/assessmentStore.test.ts": 1, "src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts": 1, + "src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts": 1, "src/features/assessment/stores/assessmentStore.ts": 26, "src/features/assessment/types/__tests__/schemas.test.ts": 1, "src/features/assessment/types/scoring.ts": 2, diff --git a/app/src/core/services/supabase/SupabaseService.ts b/app/src/core/services/supabase/SupabaseService.ts index 145d3bb1..4aef2941 100644 --- a/app/src/core/services/supabase/SupabaseService.ts +++ b/app/src/core/services/supabase/SupabaseService.ts @@ -556,6 +556,20 @@ class SupabaseService { } } + /** + * DEBUG-218: coerce a required crisis-telemetry categorical field. A missing/empty + * value degrades to an explicit 'unknown' sentinel + a high-severity log (queryable + * degradation) rather than silently coercing to the literal "undefined". The + * vital-interest event is still emitted — never dropped on a field-validation miss. + */ + private requireCrisisField(value: string | undefined | null, field: string): string { + if (value === undefined || value === null || value === '') { + logSecurity('[SupabaseService] crisis telemetry missing required field', 'high', { field }); + return 'unknown'; + } + return String(value); + } + /** * INFRA-214 T3 — Vital-interest crisis-detection telemetry. * @@ -579,9 +593,11 @@ class SupabaseService { event_type: 'crisis_detected', properties: { trigger_type: String(telemetry.trigger_type), - severity_bucket: String(telemetry.severity_bucket), + // DEBUG-218: degrade a missing field to an explicit 'unknown' sentinel + a + // high-severity log instead of String(undefined) → the literal "undefined". + severity_bucket: this.requireCrisisField(telemetry.severity_bucket, 'severity_bucket'), intervention_surfaced: Boolean(telemetry.intervention_surfaced), - assessment_type: String(telemetry.assessment_type), + assessment_type: this.requireCrisisField(telemetry.assessment_type, 'assessment_type'), }, session_id: this.sessionId, enqueued_at: Date.now(), diff --git a/app/src/core/services/supabase/__tests__/crisisTelemetryGuard.unit.test.ts b/app/src/core/services/supabase/__tests__/crisisTelemetryGuard.unit.test.ts new file mode 100644 index 00000000..aff46da4 --- /dev/null +++ b/app/src/core/services/supabase/__tests__/crisisTelemetryGuard.unit.test.ts @@ -0,0 +1,79 @@ +/** + * crisis_detected telemetry defensive guard (DEBUG-218) — UNIT + * + * Belt-and-suspenders behind the two upstream field-population fixes: if a future + * trigger is ever added without a severity/type mapping, `trackCrisisDetection` must + * degrade VISIBLY — substitute an explicit 'unknown' sentinel (never re-introduce the + * literal "undefined"), raise a high-severity security log, and STILL emit. A + * vital-interest crisis event is never dropped on a field-validation failure. + */ +import { jest } from '@jest/globals'; + +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('@supabase/supabase-js', () => ({ createClient: jest.fn(() => ({})) })); + +// Spy on the security logger to prove the "missing field" case is observable. +const mockLogSecurity = jest.fn(); +jest.mock('../../logging', () => { + const actual = jest.requireActual('../../logging') as Record; + return { ...actual, logSecurity: (...args: unknown[]) => mockLogSecurity(...args) }; +}); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import supabaseService from '@/core/services/supabase/SupabaseService'; + +describe('SupabaseService.trackCrisisDetection — missing-field guard (DEBUG-218)', () => { + let service: any; + + beforeEach(() => { + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + service = new (supabaseService as any).constructor(); + }); + + it('substitutes "unknown" (never "undefined") and still emits when a field is missing', () => { + service.trackCrisisDetection({ + trigger_type: 'phq9_suicidal_ideation', + severity_bucket: undefined as any, + intervention_surfaced: true, + assessment_type: undefined as any, + }); + + // Event still landed in the durable queue (never dropped). + expect(service.crisisAnalyticsQueue).toHaveLength(1); + const props = service.crisisAnalyticsQueue[0].properties; + expect(props.severity_bucket).toBe('unknown'); + expect(props.assessment_type).toBe('unknown'); + expect(JSON.stringify(props)).not.toContain('undefined'); + }); + + it('raises a high-severity security log so the degradation is observable', () => { + service.trackCrisisDetection({ + trigger_type: 'phq9_suicidal_ideation', + severity_bucket: undefined as any, + intervention_surfaced: true, + assessment_type: 'phq9', + }); + + expect(mockLogSecurity).toHaveBeenCalledWith( + expect.stringContaining('crisis telemetry'), + 'high', + expect.any(Object), + ); + }); + + it('leaves well-formed payloads untouched (no false-positive guard, no log)', () => { + service.trackCrisisDetection({ + trigger_type: 'gad7_severe_score', + severity_bucket: 'high', + intervention_surfaced: true, + assessment_type: 'gad7', + }); + + const props = service.crisisAnalyticsQueue[0].properties; + expect(props.severity_bucket).toBe('high'); + expect(props.assessment_type).toBe('gad7'); + expect(mockLogSecurity).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts b/app/src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts index 99f6d4e9..576b4893 100644 --- a/app/src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts +++ b/app/src/features/assessment/stores/__tests__/crisisTelemetryEmit.unit.test.ts @@ -27,21 +27,29 @@ jest.mock('@/core/services/supabase/SupabaseService', () => { import { useAssessmentStore } from '../assessmentStore'; import supabaseService from '@/core/services/supabase/SupabaseService'; +import type { CrisisDetection } from '@/features/crisis/types/safety'; // Same reference assessmentStore calls (default import → default.trackCrisisDetection). const mockTrackCrisisDetection = (supabaseService as any).trackCrisisDetection as jest.Mock; -const baseDetection = { +// DEBUG-218: fully-typed CrisisDetection (no `as any`) so the type-checker enforces the +// shape — in particular `assessmentType` is the lowercase `AssessmentType` union +// ('phq9'/'gad7'), matching what the store now emits and what the FEAT-129 +// crisis_detection_daily view groups on. +const baseDetection: CrisisDetection = { id: 'detect_1', isTriggered: true, primaryTrigger: 'phq9_suicidal_ideation', secondaryTriggers: [], severityLevel: 'critical', triggerValue: 3, // raw Q9 value present on the detection object — MUST NOT be emitted - assessmentType: 'PHQ-9', + assessmentType: 'phq9', timestamp: Date.now(), assessmentId: 'assess_1', -} as any; + userId: 'user_1', + detectionResponseTimeMs: 0, + context: { triggeringAnswers: [], timeOfDay: 'morning' }, +}; describe('handleCrisisDetection → crisis telemetry emit (INFRA-214 T3)', () => { beforeEach(() => { @@ -58,7 +66,7 @@ describe('handleCrisisDetection → crisis telemetry emit (INFRA-214 T3)', () => trigger_type: 'phq9_suicidal_ideation', severity_bucket: 'critical', intervention_surfaced: true, - assessment_type: 'PHQ-9', + assessment_type: 'phq9', }); // The raw clinical value must never be forwarded. expect('triggerValue' in payload).toBe(false); diff --git a/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts b/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts new file mode 100644 index 00000000..178593ea --- /dev/null +++ b/app/src/features/assessment/stores/__tests__/crisisTelemetryFields.regression.test.ts @@ -0,0 +1,199 @@ +/** + * crisis_detected telemetry field-population regression (DEBUG-218) — UNIT + * + * Pins the SAFETY-CRITICAL contract that BOTH crisis-detection emit paths carry a + * real `severity_bucket` and `assessment_type` — never the literal string "undefined". + * + * Before DEBUG-218, the inline PHQ-9 Q9 path AND the score-based completed path both + * built the CrisisDetection object without `severityLevel`/`assessmentType`, so + * `SupabaseService.trackCrisisDetection` coerced the missing fields via String(undefined) + * → "undefined" landed in `analytics_events` for the highest-acuity safety signal. + * + * These tests drive the REAL store actions (answerQuestion / completeAssessment) — not a + * hand-built detection — and assert on the mocked telemetry sink. Telemetry-only: the + * 988/intervention path and detection thresholds are unchanged. + */ +import { jest } from '@jest/globals'; + +// Mock the Supabase singleton entirely so we exercise the field-population contract only +// (the durable-queue internals live in the T6 landing test). jest.fn created INSIDE the +// factory — jest.mock is hoisted above module-scope consts (TDZ otherwise). +jest.mock('@/core/services/supabase/SupabaseService', () => { + const fn = jest.fn(); + return { + __esModule: true, + default: { trackCrisisDetection: fn }, + supabaseService: { trackCrisisDetection: fn }, + }; +}); + +// Emergency response calls Alert/Linking — keep them inert. +jest.mock('react-native', () => ({ + Alert: { alert: jest.fn() }, + Linking: { openURL: jest.fn() }, +})); +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('expo-secure-store'); + +// saveProgress() routes through SecureStorageService — passthrough so it never throws. +const mockWellnessBlobs: Record = {}; +jest.mock('@/core/services/security/SecureStorageService', () => ({ + __esModule: true, + default: { + storeWellnessBlob: jest.fn(async (key: string, data: unknown) => { + mockWellnessBlobs[key] = data; + return { success: true, operationType: 'store' as const, storageKey: `wellness_async_${key}`, operationTimeMs: 0, dataSize: 0 }; + }), + retrieveWellnessBlob: jest.fn(async (key: string) => mockWellnessBlobs[key] ?? null), + deleteWellnessBlob: jest.fn(async (key: string) => { delete mockWellnessBlobs[key]; }), + }, +})); + +import { useAssessmentStore } from '../assessmentStore'; +import supabaseService from '@/core/services/supabase/SupabaseService'; +import type { AssessmentResponse } from '../../types/index'; + +const mockTrack = (supabaseService as any).trackCrisisDetection as jest.Mock; + +type Ans = { questionId: string; response: AssessmentResponse }; + +// Greedy fill: q1..qN absorb the score first, so phq9_9 stays 0 unless explicitly set. +function phq9(targetScore: number): Ans[] { + const ids = ['phq9_1', 'phq9_2', 'phq9_3', 'phq9_4', 'phq9_5', 'phq9_6', 'phq9_7', 'phq9_8', 'phq9_9']; + const out: Ans[] = []; + let remaining = targetScore; + for (let i = 0; i < ids.length; i++) { + const left = ids.length - i; + const minNeeded = Math.max(0, remaining - (left - 1) * 3); + const r = Math.max(minNeeded, Math.min(3, remaining)) as AssessmentResponse; + out.push({ questionId: ids[i], response: r }); + remaining -= r; + } + return out; +} + +function gad7(targetScore: number): Ans[] { + const ids = ['gad7_1', 'gad7_2', 'gad7_3', 'gad7_4', 'gad7_5', 'gad7_6', 'gad7_7']; + const out: Ans[] = []; + let remaining = targetScore; + for (let i = 0; i < ids.length; i++) { + const left = ids.length - i; + const minNeeded = Math.max(0, remaining - (left - 1) * 3); + const r = Math.max(minNeeded, Math.min(3, remaining)) as AssessmentResponse; + out.push({ questionId: ids[i], response: r }); + remaining -= r; + } + return out; +} + +async function answer(seq: Ans[]) { + for (const a of seq) { + await useAssessmentStore.getState().answerQuestion(a.questionId, a.response); + } +} + +function lastPayload() { + return mockTrack.mock.calls[mockTrack.mock.calls.length - 1][0]; +} + +function expectNoUndefinedString(payload: Record) { + Object.values(payload).forEach((v) => expect(v).not.toBe('undefined')); + expect(JSON.stringify(payload)).not.toContain('undefined'); +} + +describe('DEBUG-218 — crisis_detected carries real severity_bucket + assessment_type', () => { + beforeEach(() => { + jest.clearAllMocks(); + useAssessmentStore.getState().resetAssessment(); + }); + + describe('inline PHQ-9 Q9 path (answerQuestion)', () => { + it('Q9>0 with full answers + low total → severity_bucket "high", assessment_type "phq9", no "undefined"', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + // Answer 1-8 = 0, then Q9 = 2 last → total 2 (<20). All 9 present at emit time. + await answer([...phq9(0).slice(0, 8), { questionId: 'phq9_9', response: 2 as AssessmentResponse }]); + + expect(mockTrack).toHaveBeenCalledTimes(1); + const p = lastPayload(); + expect(p).toEqual({ + trigger_type: 'phq9_suicidal_ideation', + severity_bucket: 'high', + intervention_surfaced: true, + assessment_type: 'phq9', + }); + expectNoUndefinedString(p); + }); + + it('Q9>0 with full answers + high total (≥20) → severity_bucket "critical"', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + // 1-8 = 3 each (24), Q9 = 1 last → total 25 (≥20). + const high: Ans[] = []; + for (let i = 1; i <= 8; i++) high.push({ questionId: `phq9_${i}`, response: 3 as AssessmentResponse }); + high.push({ questionId: 'phq9_9', response: 1 as AssessmentResponse }); + await answer(high); + + const p = lastPayload(); + expect(p.trigger_type).toBe('phq9_suicidal_ideation'); + expect(p.severity_bucket).toBe('critical'); + expect(p.assessment_type).toBe('phq9'); + expectNoUndefinedString(p); + }); + + it('Q9>0 with NO other answers yet → score-compute fails → safe "high" floor (never "undefined")', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + // Only Q9 answered → calculatePHQ9Score throws (needs 9) → fallback 'high'. + await answer([{ questionId: 'phq9_9', response: 1 as AssessmentResponse }]); + + const p = lastPayload(); + expect(p.trigger_type).toBe('phq9_suicidal_ideation'); + expect(p.severity_bucket).toBe('high'); + expect(p.assessment_type).toBe('phq9'); + expectNoUndefinedString(p); + }); + + it('Q9=0 → no crisis emit (boundary)', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + await answer([{ questionId: 'phq9_9', response: 0 as AssessmentResponse }]); + expect(mockTrack).not.toHaveBeenCalled(); + }); + }); + + describe('score-based completed path (completeAssessment) — non-Q9 crises', () => { + it('PHQ-9 ≥20 without Q9 → phq9_moderate_severe_score, severity_bucket "critical", assessment_type "phq9"', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + await answer(phq9(20)); // greedy fill leaves phq9_9 = 0 + await useAssessmentStore.getState().completeAssessment(); + + expect(mockTrack).toHaveBeenCalledTimes(1); + const p = lastPayload(); + expect(p.trigger_type).toBe('phq9_moderate_severe_score'); + expect(p.severity_bucket).toBe('critical'); + expect(p.assessment_type).toBe('phq9'); + expectNoUndefinedString(p); + }); + + it('PHQ-9 15–19 without Q9 → severity_bucket "high"', async () => { + await useAssessmentStore.getState().startAssessment('phq9'); + await answer(phq9(17)); + await useAssessmentStore.getState().completeAssessment(); + + const p = lastPayload(); + expect(p.trigger_type).toBe('phq9_moderate_severe_score'); + expect(p.severity_bucket).toBe('high'); + expect(p.assessment_type).toBe('phq9'); + expectNoUndefinedString(p); + }); + + it('GAD-7 ≥15 → gad7_severe_score, severity_bucket "high", assessment_type "gad7"', async () => { + await useAssessmentStore.getState().startAssessment('gad7'); + await answer(gad7(18)); + await useAssessmentStore.getState().completeAssessment(); + + const p = lastPayload(); + expect(p.trigger_type).toBe('gad7_severe_score'); + expect(p.severity_bucket).toBe('high'); + expect(p.assessment_type).toBe('gad7'); + expectNoUndefinedString(p); + }); + }); +}); diff --git a/app/src/features/assessment/stores/assessmentStore.ts b/app/src/features/assessment/stores/assessmentStore.ts index 9fb75e62..9069e58a 100644 --- a/app/src/features/assessment/stores/assessmentStore.ts +++ b/app/src/features/assessment/stores/assessmentStore.ts @@ -263,6 +263,39 @@ export class ClinicalScoringService { } } +/** + * DEBUG-218: severity bucket for the `crisis_detected` telemetry event. + * + * PHQ-9 (suicidal-ideation OR ≥15 score): `critical` at the active-intervention + * floor (≥20 — `PHQ9_SEVERE_THRESHOLD`), else `high`. GAD-7 severe: `high` (no + * validated `critical` tier — mirrors the canonical mapping in + * `@/features/crisis/types/safety`). Kept additive: it derives the bucket from the + * existing total, it does NOT change any detection threshold or trigger taxonomy. + */ +function crisisSeverityLevel( + type: AssessmentType, + totalScore: number, +): CrisisDetection['severityLevel'] { + if (type === 'gad7') return 'high'; + return totalScore >= CRISIS_THRESHOLDS.PHQ9_SEVERE_THRESHOLD ? 'critical' : 'high'; +} + +/** + * DEBUG-218: severity bucket for the INLINE Q9 detection, derived mid-assessment. + * `calculatePHQ9Score` needs all 9 answers; `phq9_9` is the last question so they are + * normally present, but out-of-order answering can leave them incomplete and throw — + * fall back to the safe 'high' floor (Q9>0 is never below 'high'). Never throws. + * Extracted so the inline `answerQuestion` branch stays within its complexity budget. + */ +function inlineQ9SeverityLevel(answers: AssessmentAnswer[]): CrisisDetection['severityLevel'] { + try { + const { totalScore } = ClinicalScoringService.calculatePHQ9Score(answers); + return crisisSeverityLevel('phq9', totalScore); + } catch { + return 'high'; + } +} + /** * Crisis detection and intervention service * Meets <200ms response time requirement @@ -300,7 +333,12 @@ class CrisisDetectionService { const detection = { isTriggered: true, primaryTrigger: triggerType, + secondaryTriggers: [], + // DEBUG-218: populate severityLevel + assessmentType so the score-based + // crisis_detected telemetry carries real buckets (never "undefined"). + severityLevel: crisisSeverityLevel(type, result.totalScore), triggerValue, + assessmentType: type, timestamp: Date.now(), assessmentId } as Partial as CrisisDetection; @@ -548,10 +586,15 @@ export const useAssessmentStore = create()( // Check for real-time crisis detection on specific questions if (questionId === CRISIS_THRESHOLDS.PHQ9_SUICIDAL_QUESTION_ID && response > 0) { + // DEBUG-218: populate severityLevel + assessmentType so the inline Q9 + // crisis_detected telemetry carries real buckets (never "undefined"). const detection = { isTriggered: true, primaryTrigger: 'phq9_suicidal_ideation' as const, + secondaryTriggers: [], + severityLevel: inlineQ9SeverityLevel(updatedAnswers), triggerValue: response, + assessmentType: 'phq9' as const, timestamp: Date.now(), assessmentId: state.currentSession.id } as Partial as CrisisDetection;