From 6827e0ca45e957acc36a97f42203ee259a5b4f70 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 15:06:42 +0800 Subject: [PATCH] test: close conversational rectification review gaps --- deploy/README.md | 22 +- ...orted_birth_time_flow_issues_2026_07_20.md | 6 +- .../app/api/birth-time-conversation/route.ts | 15 +- frontend/src/app/api/health/route.ts | 30 +- .../src/lib/birth-time-journey-telemetry.ts | 2 +- .../orchestrator.ts | 63 +++- .../conversational-rectification-e2e.test.ts | 109 ++++++- ...conversational-rectification-route.test.ts | 37 +++ frontend/tests/health-deployment.test.ts | 79 +++++ ...sational_rectification_postgres_runtime.py | 281 ++++++++++++++++++ ...t_conversational_rectification_security.py | 95 ++++-- 11 files changed, 685 insertions(+), 54 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 7a34c7c0..69db3983 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -77,6 +77,8 @@ ADMIN_EMAILS=... RECTIFICATION_PRICE_CREDITS=3 RECTIFICATION_V3_CREATE_ENABLED=true RECTIFICATION_V3_MIGRATIONS_READY=false +# Set only after the authenticated synthetic smoke passes on this exact image. +RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA= # Recommended multi-model catalog. The JSON references server-only keys. LLM_DEFAULT_MODEL_ID=deepseek-pro @@ -207,15 +209,26 @@ Run `cd frontend && npx supabase db push --linked` with the authorized project account. Verify the linked migration ledger contains all six versions. Do not print the database URL or any service-role credential. Then set `RECTIFICATION_V3_MIGRATIONS_READY=true`, keep -`RECTIFICATION_V3_CREATE_ENABLED=true`, and deploy the tested Git revision. +`RECTIFICATION_V3_CREATE_ENABLED=true`, leave +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and deploy the tested Git revision. +Creation is available for the authorized smoke account, but the revision is not +ready for general rollout until that smoke is recorded. -Before declaring rollout successful, fetch `https://jyotisha.chat/api/health` -and verify all of the following against the revision that passed validation: +Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full +deployment SHA, healthy dependencies, enabled creation, ready migrations, +`syntheticSmoke: pending`, and `readyForNewCases: false`. A missing, abbreviated, +malformed, or previous-revision smoke SHA must remain pending. + +After the smoke sequence below passes, set +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character +lowercase Git SHA and restart the web container. Then fetch health again and +verify all of the following against the revision that passed validation: - `deployment.gitCommit` exactly equals the tested 40-character Git SHA; - `rollout.conversationalRectificationV3.protocol` is `conversational-evidence-v3`; - `newCaseCreation` and `migrations` are `enabled` and `ready`; +- `syntheticSmoke` is `matched`; - `readyForNewCases` is `true`; - ordinary health checks remain healthy. The health response must never contain environment values or credentials. @@ -251,7 +264,8 @@ from telemetry. ### Rollback Rollback is forward-compatible and non-destructive. First set -`RECTIFICATION_V3_CREATE_ENABLED=false` and redeploy a revision that can still +`RECTIFICATION_V3_CREATE_ENABLED=false`, clear +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA`, and redeploy a revision that can still read/resume v3. Health must report `newCaseCreation: paused`. This stops only new v3 starts: keep reads, resume, answer, pause, confirmation, and saved-question handoff available for existing cases. Never reverse or delete the v3 migrations, diff --git a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md index d8f6cab3..5af3bc96 100644 --- a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md +++ b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md @@ -13,11 +13,11 @@ the tested deployment SHA. | Issue | Reported failure | Current status | Local evidence | Production closure artifact | | --- | --- | --- | --- | --- | -| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; Task 12 synthetic flow deletes the chat while retaining the account case. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. | +| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; the PG14 full-flow test deletes a real RLS-owned chat as `authenticated` and proves the account case remains. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. | | ISSUE-BT-002 | A new chat could not establish a fresh rectification interaction and unfinished progress was coupled to chat state. | verified-local | `20260720010000_conversational_rectification_schema.sql`; account-level resume in `frontend/tests/conversational-rectification-e2e.test.ts` across two route clients; Task 9 current-chat consent tests. | Authenticated new-device/new-chat resume smoke tied to the deployed SHA. | -| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; PG14 confirmation/rollback runtime suite. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. | +| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; the PG14 full-flow test rejects `05:20`, confirms exact `05:21`, and proves the old time survives until commit. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. | | ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. | -| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; `20260721010000_conversational_legacy_import_projection.sql` imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. | +| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. | ## Release decision diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index a8f5af65..6b6f504a 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -10,6 +10,7 @@ import { } from "../../../lib/conversational-rectification/errors.ts"; import { createConversationalRectificationService, + conversationalRectificationTelemetryOutcome, evidencePredatesBirthDate, type ConversationalRectificationPacketBuildInput, type ConversationalRectificationService, @@ -691,7 +692,7 @@ async function dispatch( } function telemetryPhase( - turn: ConversationalRectificationTurn | null, + turn: Pick | null, ): ConversationalRectificationTelemetryPayload["phase"] { switch (turn?.status) { case "active": return "collecting_evidence"; @@ -740,6 +741,7 @@ export function createBirthTimeConversationPostHandler( ); dependencies.createRequestId?.(request); let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown"; + let service: BirthTimeConversationRouteService | null = null; try { const authenticated = await dependencies.authenticate(request); if (!authenticated) throw new ConversationalRectificationError("authentication_required"); @@ -748,29 +750,32 @@ export function createBirthTimeConversationPostHandler( if (!parsed.success) throw new ConversationalRectificationError("invalid_command"); actionKind = parsed.data.type; - const service = await dependencies.createService(authenticated); + service = await dependencies.createService(authenticated); const turn = await dispatch(service, authenticated.userId, parsed.data); + const outcome = conversationalRectificationTelemetryOutcome(service); telemetry({ protocol: "conversational-evidence-v3", phase: telemetryPhase(turn), actionKind, resultCategory: "success", latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt), - billingState: actionKind === "start" ? "unknown" : "unchanged", + billingState: outcome?.billingState ?? (actionKind === "start" ? "unknown" : "unchanged"), errorCategory: "none", deploymentSha, }); return Response.json(turn); } catch (error) { const publicError = toConversationalRectificationPublicError(error); + const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null; dependencies.log?.({ code: publicError.code }); telemetry({ protocol: "conversational-evidence-v3", - phase: "entry", + phase: telemetryPhase(outcome?.caseStatus ? { status: outcome.caseStatus } : null), actionKind, resultCategory: telemetryResultCategory(publicError.status), latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt), - billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable", + billingState: outcome?.billingState + ?? (publicError.code === "billing_failed" ? "unknown" : "not_applicable"), errorCategory: telemetryErrorCategory(publicError.code), deploymentSha, }); diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index 5f22c0b3..99ac02e8 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -7,15 +7,12 @@ type Check = { }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -const gitCommit = - process.env.GITHUB_SHA - ?? process.env.VERCEL_GIT_COMMIT_SHA - ?? process.env.NEXT_PUBLIC_GIT_COMMIT - ?? "unknown"; -const rectificationV3CreationEnabled = - process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false"; -const rectificationV3MigrationsReady = - process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true"; +function deployedGitCommit(): string { + return process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT + ?? "unknown"; +} function envCheck(names: string[]): Check { const missing = names.filter((name) => !process.env[name]); @@ -62,6 +59,12 @@ function aggregate(checks: Record) { } export async function GET() { + const gitCommit = deployedGitCommit(); + const rectificationV3CreationEnabled = + process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false"; + const rectificationV3MigrationsReady = + process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true"; + const smokeSha = process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA?.trim().toLowerCase() ?? ""; const checks = { web: { status: "ok" } satisfies Check, supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), @@ -70,9 +73,12 @@ export async function GET() { jyotishApi: await jyotishApiCheck(), }; const status = aggregate(checks); - const rectificationV3Ready = rectificationV3CreationEnabled + const deploymentHasFullSha = /^[0-9a-f]{40}$/.test(gitCommit); + const smokeMatchesDeployment = deploymentHasFullSha && smokeSha === gitCommit; + const rectificationV3Ready = status === "ok" + && rectificationV3CreationEnabled && rectificationV3MigrationsReady - && gitCommit !== "unknown"; + && smokeMatchesDeployment; return NextResponse.json( { status, @@ -85,7 +91,7 @@ export async function GET() { protocol: "conversational-evidence-v3", newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused", migrations: rectificationV3MigrationsReady ? "ready" : "unverified", - syntheticSmoke: "required", + syntheticSmoke: smokeMatchesDeployment ? "matched" : "pending", readyForNewCases: rectificationV3Ready, }, }, diff --git a/frontend/src/lib/birth-time-journey-telemetry.ts b/frontend/src/lib/birth-time-journey-telemetry.ts index 43d73fc6..a5b23514 100644 --- a/frontend/src/lib/birth-time-journey-telemetry.ts +++ b/frontend/src/lib/birth-time-journey-telemetry.ts @@ -84,8 +84,8 @@ export function createConversationalRectificationTelemetry( sink: ConversationalRectificationTelemetrySink = conversationalRectificationConsoleSink, ): ConversationalRectificationTelemetrySink { return (input) => { - const payload = conversationalRectificationTelemetryPayloadSchema.parse(input); try { + const payload = conversationalRectificationTelemetryPayloadSchema.parse(input); sink(payload); } catch { // no-excuse-ok: observability cannot break the product request return; diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 64f85b8a..752f704d 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -99,6 +99,22 @@ export type ConversationalRectificationService = Readonly<{ confirm(userId: string, command: CommandOf<"confirm">): Promise; }>; +export type ConversationalRectificationTelemetryOutcome = Readonly<{ + billingState: "not_applicable" | "charged" | "released" | "migration_waived" | "unchanged" | "unknown"; + caseStatus: ConversationalRectificationTurn["status"] | null; +}>; + +const telemetryOutcomes = new WeakMap< + ConversationalRectificationService, + () => ConversationalRectificationTelemetryOutcome +>(); + +export function conversationalRectificationTelemetryOutcome( + service: ConversationalRectificationService, +): ConversationalRectificationTelemetryOutcome | null { + return telemetryOutcomes.get(service)?.() ?? null; +} + const transitionValidatorVersion = "conversational-rectification-orchestrator-v1"; const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; @@ -458,9 +474,25 @@ function requireMutable(current: LoadedConversationalRectificationCase) { export function createConversationalRectificationService( ports: ConversationalRectificationServicePorts, ): ConversationalRectificationService { + let lastTelemetryOutcome: ConversationalRectificationTelemetryOutcome = { + billingState: "not_applicable", + caseStatus: null, + }; + function resetTelemetryOutcome() { + lastTelemetryOutcome = { billingState: "not_applicable", caseStatus: null }; + } + function observeCase( + value: LoadedConversationalRectificationCase | StoredConversationalRectificationCase, + billingState: ConversationalRectificationTelemetryOutcome["billingState"] = "unchanged", + ) { + lastTelemetryOutcome = { billingState, caseStatus: publicTurn(value).status }; + } + async function load(userId: string, caseId: string) { try { - return requireLoaded(await ports.store.loadCase({ userId, caseId })); + const current = requireLoaded(await ports.store.loadCase({ userId, caseId })); + observeCase(current); + return current; } catch (error) { throw safeFailure(error); } @@ -481,7 +513,9 @@ export function createConversationalRectificationService( actionKind, commandFingerprint: fingerprint, }); - return receipt ? publicTurn(receipt) : null; + if (!receipt) return null; + observeCase(receipt); + return publicTurn(receipt); } catch (error) { throw safeFailure(error); } @@ -493,6 +527,7 @@ export function createConversationalRectificationService( actionId: string, pendingConsultationQuestion: string | null = null, ): Promise { + resetTelemetryOutcome(); const importer = ports.store.importLegacy; const loadLegacy = ports.loadLegacyCase; if (!importer || !loadLegacy) { @@ -502,6 +537,7 @@ export function createConversationalRectificationService( try { const existingByAction = await ports.store.loadCase({ userId, caseId: actionId }); if (existingByAction) { + observeCase(existingByAction); if (existingByAction.importedFromCaseId !== legacyCaseId || existingByAction.billingState !== "migration_waived" || existingByAction.pendingConsultationQuestion !== pendingConsultationQuestion) { @@ -513,6 +549,7 @@ export function createConversationalRectificationService( if (current?.importedFromCaseId === legacyCaseId && current.billingState === "migration_waived" && current.pendingConsultationQuestion === pendingConsultationQuestion) { + observeCase(current); return publicTurn(current); } if (current?.importedFromCaseId === legacyCaseId @@ -579,6 +616,7 @@ export function createConversationalRectificationService( validationReceipt: narrative.validationReceipt, privateCandidate, }); + observeCase(imported, "migration_waived"); return publicTurn(imported); } catch (error) { if (error instanceof ConversationalRectificationError @@ -588,6 +626,7 @@ export function createConversationalRectificationService( if (winner?.importedFromCaseId === legacyCaseId && winner.billingState === "migration_waived" && winner.pendingConsultationQuestion === pendingConsultationQuestion) { + observeCase(winner); return publicTurn(winner); } } catch { @@ -623,9 +662,10 @@ export function createConversationalRectificationService( return extracted; } - return Object.freeze({ + const service: ConversationalRectificationService = Object.freeze({ importLegacyCase, async start(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("start", rawCommand); let profile: ConversationalRectificationProfile; try { @@ -663,6 +703,7 @@ export function createConversationalRectificationService( throw safeFailure(error); } if (existing) { + observeCase(existing); if (existing.pendingConsultationQuestion !== (command.pendingConsultationQuestion ?? null)) { throw new ConversationalRectificationError("action_conflict"); } @@ -674,6 +715,7 @@ export function createConversationalRectificationService( expectedVersion: 0, actionId: command.actionId, }); + observeCase(existing, "charged"); } catch (error) { try { await ports.billing.release({ @@ -683,6 +725,7 @@ export function createConversationalRectificationService( actionId: command.actionId, price, }); + observeCase(existing, "released"); } catch { throw new ConversationalRectificationError("billing_failed"); } @@ -708,6 +751,7 @@ export function createConversationalRectificationService( price, }); reserved = reservation.billingState === "reserved"; + if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null }; const computed = await ports.buildTechnicalPacket({ userId, caseId, @@ -746,12 +790,14 @@ export function createConversationalRectificationService( validationReceipt: narrative.validationReceipt, privateCandidate, }); + observeCase(created, "unknown"); await ports.billing.complete({ userId, caseId, expectedVersion: 0, actionId: command.actionId, }); + observeCase(created, "charged"); return publicTurn(created); } catch (error) { if (reserved) { @@ -763,6 +809,10 @@ export function createConversationalRectificationService( actionId: command.actionId, price, }); + lastTelemetryOutcome = { + billingState: "released", + caseStatus: lastTelemetryOutcome.caseStatus, + }; } catch { throw new ConversationalRectificationError("billing_failed"); } @@ -772,12 +822,14 @@ export function createConversationalRectificationService( }, async resume(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("resume", rawCommand); const current = await load(userId, command.caseId); return publicTurn(current); }, async answer(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("answer", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "save_turn", fingerprint); @@ -1035,6 +1087,7 @@ export function createConversationalRectificationService( }, async pause(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("pause", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "pause", fingerprint); @@ -1081,6 +1134,7 @@ export function createConversationalRectificationService( }, async abandon(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("abandon", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "abandon", fingerprint); @@ -1124,6 +1178,7 @@ export function createConversationalRectificationService( }, async confirm(userId, rawCommand) { + resetTelemetryOutcome(); const command = parseCommand("confirm", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "confirm", fingerprint); @@ -1186,4 +1241,6 @@ export function createConversationalRectificationService( } }, }); + telemetryOutcomes.set(service, () => lastTelemetryOutcome); + return service; } diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts index 75b58a5f..234cce4e 100644 --- a/frontend/tests/conversational-rectification-e2e.test.ts +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -178,7 +178,12 @@ type Receipt = Readonly<{ row: StoredConversationalRectificationCase; }>; -function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean } = {}) { +function createSyntheticBackend(options: { + legacy?: boolean; + allowNewCaseCreation?: boolean; + packetFailure?: boolean; + packetEvidenceCalls?: string[][]; +} = {}) { const cases = new Map(); const receipts = new Map(); let activeTime = "04:58"; @@ -364,6 +369,8 @@ function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreatio }; }, async buildTechnicalPacket(input) { + if (options.packetFailure) throw new Error("synthetic packet failure"); + options.packetEvidenceCalls?.push(input.evidence.map((item) => item.id)); const ready = input.evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification").length >= 3; const packet = technicalPacket(ready); if (input.preserveCandidateRange && input.privateCandidate?.rangeStart && input.privateCandidate.rangeEnd) { @@ -428,7 +435,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato consent = clearBirthTimeConsultationConsent(consent, "chat-a"); assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice"); - const backend = createSyntheticBackend(); + const packetEvidenceCalls: string[][] = []; + const backend = createSyntheticBackend({ packetEvidenceCalls }); const telemetry: ConversationalRectificationTelemetryPayload[] = []; const handler = createBirthTimeConversationPostHandler({ authenticate: async () => ({ userId, context: {} }), @@ -450,6 +458,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato assert.equal(JSON.stringify(turn).includes("candidateWeights"), false); assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false); assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" }); + assert.equal(telemetry.at(-1)?.billingState, "charged"); assert.equal(backend.activeTime(), "04:58", "revision must retain the old active minute"); turn = await post(handler, { @@ -467,11 +476,23 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato assert.match(turn.narrative, /还缺少.*明确时间/); assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充"); + const futureEvidenceAction = "00000000-0000-4000-8000-000000009050"; + turn = await post(handler, { + type: "answer", caseId, actionId: futureEvidenceAction, + turnVersion: turn.turnVersion, domain: "career", answer: "2027年计划换工作", + }); + assert.match(turn.narrative, /未来事件只能作为背景.*不能用于校正评分/); + assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2027(未来,仅作背景)"); + const futureEvidenceId = turn.evidenceRecap.at(-1)?.id; + assert.ok(futureEvidenceId); + const historicalEvidenceIds: string[] = []; + turn = await post(handler, { type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009005", turnVersion: turn.turnVersion, domain: "career", answer: "2014年7月第一次正式入职", }); assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2014-07"); + historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id); turn = await post(handler, { type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006", turnVersion: turn.turnVersion, @@ -495,13 +516,17 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008", turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业", }); + historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id); turn = await post(secondDevice, { type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009", turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活", }); + historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id); assert.equal(turn.status, "confirming"); assert.equal(turn.candidate.representativeTime, "05:18"); assert.equal(backend.activeTime(), "04:58"); + assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false); + assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds); const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", { method: "POST", @@ -513,6 +538,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato })); assert.equal(wrong.status, 409); assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic"); + assert.equal(telemetry.at(-1)?.phase, "confirming"); + assert.equal(telemetry.at(-1)?.billingState, "unchanged"); turn = await post(secondDevice, { type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011", @@ -565,7 +592,13 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato test("legacy unfinished work imports once with migration waiver and no questionnaire or charge", async () => { const backend = createSyntheticBackend({ legacy: true }); - const first = await backend.service.start(userId, { type: "start", actionId: caseId }); + const telemetry: ConversationalRectificationTelemetryPayload[] = []; + const handler = createBirthTimeConversationPostHandler({ + authenticate: async () => ({ userId, context: {} }), + createService: async () => backend.service, + telemetry: (payload) => telemetry.push(payload), + }); + const first = await post(handler, { type: "start", actionId: caseId }); const replay = await backend.service.start(userId, { type: "start", actionId: caseId }); assert.deepEqual(replay, first); assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId); @@ -575,9 +608,10 @@ test("legacy unfinished work imports once with migration waiver and no questionn assert.deepEqual(first.candidate.rangeEnd, "05:50"); assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/); assert.equal(JSON.stringify(first).includes("choiceAnswers"), false); + assert.equal(telemetry.at(-1)?.billingState, "migration_waived"); }); -test("v3 telemetry rejects every field outside the privacy-safe category contract", () => { +test("v3 telemetry drops invalid payloads without affecting the product request", () => { const emitted: unknown[] = []; const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload)); const valid = { @@ -595,11 +629,66 @@ test("v3 telemetry rejects every field outside the privacy-safe category contrac "narrative", "eventText", "birthDate", "birthTime", "email", "userId", "accessToken", "refreshToken", "modelPrompt", "caseId", "actionId", ]) { - assert.throws(() => record({ ...valid, [forbidden]: "private" } as never)); + assert.doesNotThrow(() => record({ ...valid, [forbidden]: "private" } as never)); } assert.deepEqual(emitted, [valid]); }); +test("telemetry reports released reservations and authentication rejects before service creation", async () => { + const backend = createSyntheticBackend({ packetFailure: true }); + const telemetry: ConversationalRectificationTelemetryPayload[] = []; + const handler = createBirthTimeConversationPostHandler({ + authenticate: async () => ({ userId, context: {} }), + createService: async () => backend.service, + telemetry: (payload) => telemetry.push(payload), + }); + const failed = await handler(new Request("https://example.invalid/api/birth-time-conversation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "start", actionId: caseId }), + })); + assert.equal(failed.status, 503); + assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 0, releaseCount: 1, state: "released" }); + assert.equal(telemetry.at(-1)?.billingState, "released"); + assert.equal(telemetry.at(-1)?.errorCategory, "dependency"); + + let serviceCreations = 0; + const unauthenticated = createBirthTimeConversationPostHandler({ + authenticate: async () => null, + createService: async () => { serviceCreations += 1; return backend.service; }, + telemetry: () => undefined, + }); + const rejected = await unauthenticated(new Request("https://example.invalid/api/birth-time-conversation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "start", actionId: caseId }), + })); + assert.equal(rejected.status, 401); + assert.equal(serviceCreations, 0); +}); + +test("authenticated route remains owner-bound", async () => { + const backend = createSyntheticBackend(); + const started = await backend.service.start(userId, { type: "start", actionId: caseId }); + const foreign = createBirthTimeConversationPostHandler({ + authenticate: async () => ({ userId: "00000000-0000-4000-8000-000000009999", context: {} }), + createService: async () => backend.service, + telemetry: () => undefined, + }); + const response = await foreign(new Request("https://example.invalid/api/birth-time-conversation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "resume", + caseId, + actionId: "00000000-0000-4000-8000-000000009998", + turnVersion: started.turnVersion, + }), + })); + assert.equal(response.status, 404); + assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" }); +}); + test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => { const originalFetch = globalThis.fetch; const bodies: string[] = []; @@ -646,12 +735,20 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho GITHUB_SHA: process.env.GITHUB_SHA, RECTIFICATION_V3_CREATE_ENABLED: process.env.RECTIFICATION_V3_CREATE_ENABLED, RECTIFICATION_V3_MIGRATIONS_READY: process.env.RECTIFICATION_V3_MIGRATIONS_READY, + RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA, + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, + NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, }; process.env.GITHUB_SHA = deploymentSha; process.env.RECTIFICATION_V3_CREATE_ENABLED = "true"; process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true"; + process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = deploymentSha; + process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.invalid"; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "synthetic-public-key"; process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return"; + process.env.OPENAI_API_KEY = "synthetic-model-key"; globalThis.fetch = async () => Response.json({ status: "ok" }); try { const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`); @@ -663,7 +760,7 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho protocol: "conversational-evidence-v3", newCaseCreation: "enabled", migrations: "ready", - syntheticSmoke: "required", + syntheticSmoke: "matched", readyForNewCases: true, }, }); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 0b999c64..d4c82e9e 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -781,6 +781,43 @@ test("production packet deterministically sends only the latest six supported ev ); }); +test("persistable future background evidence never reaches the production scorer", async () => { + const scoreCalls: LifeEvent[][] = []; + const engine = packetEngine({ scoreCalls }); + const historical = [ + syntheticEvidence(61, "education", "2018-06", "month"), + syntheticEvidence(62, "relocation", "2020-09", "month"), + syntheticEvidence(63, "career", "2024-03", "month"), + ]; + const future = { + ...syntheticEvidence(64, "career", "2027", "year"), + rawText: "2027年计划换工作", + eventSummary: "计划换工作", + scoreable: false as const, + }; + + await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence: [...historical, future], + }); + + assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[ + ...historical.map((item) => item.id), + ]]); +}); + test("family evidence stays out of relationship scoring when three real scorer domains exist", async () => { const scoreCalls: LifeEvent[][] = []; const engine = packetEngine({ scoreCalls }); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index b2facec4..334ba3c1 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { GET as healthGet } from "../src/app/api/health/route.ts"; function serviceBlock(compose: string, service: string) { const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][a-z0-9_-]*:|^volumes:)`, "m")); @@ -83,3 +84,81 @@ test("production workflow rejects a SHA mismatch in verification", () => { /Verify production must use the tested SHA/, ); }); + +test("v3 readiness requires healthy dependencies and smoke proof for the exact full deployment SHA", async () => { + const keys = [ + "GITHUB_SHA", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY", + "RECTIFICATION_V3_CREATE_ENABLED", "RECTIFICATION_V3_MIGRATIONS_READY", + "RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA", + ] as const; + const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + const originalFetch = globalThis.fetch; + const currentSha = "0123456789abcdef0123456789abcdef01234567"; + const oldSha = "89abcdef0123456789abcdef0123456789abcdef"; + Object.assign(process.env, { + GITHUB_SHA: currentSha, + NEXT_PUBLIC_SUPABASE_URL: "https://example.invalid", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "synthetic-public-key", + SUPABASE_SERVICE_ROLE_KEY: "synthetic-service-key", + OPENAI_API_KEY: "synthetic-model-key", + RECTIFICATION_V3_CREATE_ENABLED: "true", + RECTIFICATION_V3_MIGRATIONS_READY: "true", + }); + globalThis.fetch = async () => Response.json({ status: "ok" }); + + async function readiness() { + const response = await healthGet(); + const body = await response.json() as { + status: string; + rollout: { conversationalRectificationV3: { + syntheticSmoke: string; + readyForNewCases: boolean; + } }; + }; + return body; + } + + try { + delete process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA; + assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, { + protocol: "conversational-evidence-v3", + newCaseCreation: "enabled", + migrations: "ready", + syntheticSmoke: "pending", + readyForNewCases: false, + }); + + process.env.GITHUB_SHA = "deadbee"; + process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = "deadbee"; + assert.equal((await readiness()).rollout.conversationalRectificationV3.readyForNewCases, false); + + process.env.GITHUB_SHA = currentSha; + process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = oldSha; + assert.equal((await readiness()).rollout.conversationalRectificationV3.syntheticSmoke, "pending"); + + process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = currentSha; + const ready = await readiness(); + assert.equal(ready.status, "ok"); + assert.deepEqual(ready.rollout.conversationalRectificationV3, { + protocol: "conversational-evidence-v3", + newCaseCreation: "enabled", + migrations: "ready", + syntheticSmoke: "matched", + readyForNewCases: true, + }); + + delete process.env.SUPABASE_SERVICE_ROLE_KEY; + const blocked = await readiness(); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.rollout.conversationalRectificationV3.syntheticSmoke, "matched"); + assert.equal(blocked.rollout.conversationalRectificationV3.readyForNewCases, false); + } finally { + globalThis.fetch = originalFetch; + for (const key of keys) { + const value = prior[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index 555b504a..850d876c 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -2257,3 +2257,284 @@ def test_import_abandon_then_paid_revision_keeps_active_time_and_charges_normall "importedFrom": None, "baseline": "04:58", } + + +def test_real_database_full_conversational_flow_survives_authenticated_chat_deletion( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000003101" + case_id = "00000000-0000-4000-8000-000000003102" + attach_action = "00000000-0000-4000-8000-000000003103" + pause_action = "00000000-0000-4000-8000-000000003104" + save_action = "00000000-0000-4000-8000-000000003105" + wrong_confirm = "00000000-0000-4000-8000-000000003106" + exact_confirm = "00000000-0000-4000-8000-000000003107" + claim_action = "00000000-0000-4000-8000-000000003108" + chat_id = "00000000-0000-4000-8000-000000003109" + question = "2027年是否适合换工作?" + question_fingerprint = hashlib.sha256(question.encode("utf-8")).hexdigest() + receipt = {"modelId": "synthetic-model", "schemaValidated": True} + _create_user(pg14_database, user_id, credits=20) + + assert _reserve(pg14_database, user_id, case_id)["credits"] == 17 + _create_case(pg14_database, user_id, case_id, _valid_declared_birth_input()) + assert _complete(pg14_database, user_id, case_id)["billing_state"] == "charged" + attached = json.loads(pg14_database.sql( + f""" + select public.attach_conversational_rectification_question( + '{user_id}'::uuid, '{case_id}'::uuid, 0, '{attach_action}'::uuid, + {_text(question)}, '{question_fingerprint}' + )::text; + """ + )) + assert attached["pending_consultation_question"] == question + + paused_turn = { + **_valid_turn(case_id), + "status": "paused", + "turnVersion": 1, + "actions": ["answer", "abandon"], + "pendingConsultationQuestion": question, + } + paused = json.loads(pg14_database.sql( + f""" + select public.pause_conversational_rectification_case( + '{user_id}'::uuid, '{case_id}'::uuid, 0, '{pause_action}'::uuid, + {_jsonb(paused_turn)}, {_jsonb(receipt)}, '{'a' * 64}' + )::text; + """ + )) + assert paused["status"] == "paused" + loaded = json.loads(pg14_database.sql( + f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text" + )) + assert loaded["status"] == "paused" + assert loaded["turn_version"] == 1 + + future_evidence = { + "id": "00000000-0000-4000-8000-000000003110", + "rawText": "2027年计划换工作", + "domain": "career", + "eventSummary": "计划换工作", + "dateValue": "2027", + "datePrecision": "year", + "extractionStatus": "clear", + "scoreable": False, + "correctsEvidenceIds": [], + } + ready_turn = { + **_valid_turn(case_id), + "status": "confirming", + "turnVersion": 2, + "candidate": { + "status": "ready_for_confirmation", + "representativeTime": "05:21", + "rangeStart": "05:10", + "rangeEnd": "05:30", + }, + "evidenceRequest": None, + "evidenceRecap": [{ + "id": future_evidence["id"], + "summary": future_evidence["eventSummary"], + "dateLabel": "2027(未来,仅作背景)", + }], + "actions": ["answer", "pause", "abandon", "confirm"], + "pendingConsultationQuestion": question, + } + saved = json.loads(pg14_database.sql(_save_statement( + user_id, + case_id, + 1, + save_action, + [future_evidence], + turn=ready_turn, + command_fingerprint="b" * 64, + ))) + assert saved["status"] == "confirming" + loaded_ready = json.loads(pg14_database.sql( + f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text" + )) + assert loaded_ready["event_evidence"][0]["scoreable"] is False + + completed_turn = { + **ready_turn, + "status": "completed", + "turnVersion": 3, + "candidate": {**ready_turn["candidate"], "status": "confirmed"}, + "actions": ["continue_original_question"], + } + mismatch = f""" + select public.confirm_conversational_rectification_candidate( + '{user_id}'::uuid, '{case_id}'::uuid, 2, '{wrong_confirm}'::uuid, + '00000000-0000-4000-8000-000000000991'::uuid, '05:20'::time, + 'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'c' * 64}' + ); + """ + assert pg14_database.rejects(mismatch) + assert pg14_database.sql( + f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid" + ) == "04:58" + + confirmed = json.loads(pg14_database.sql( + f""" + select public.confirm_conversational_rectification_candidate( + '{user_id}'::uuid, '{case_id}'::uuid, 2, '{exact_confirm}'::uuid, + '00000000-0000-4000-8000-000000000991'::uuid, '05:21'::time, + 'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'d' * 64}' + )::text; + """ + )) + assert confirmed["status"] == "completed" + assert pg14_database.sql( + f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid" + ) == "05:21" + + claimed = json.loads(pg14_database.sql( + f""" + select public.claim_conversational_rectification_handoff( + '{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid, + '{question_fingerprint}' + )::text; + """ + )) + assert claimed["status"] == "claimed" + request_id = claimed["requestId"] + executing = json.loads(pg14_database.sql( + f""" + select public.begin_conversational_rectification_handoff_execution( + '{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid, + '{request_id}'::uuid, '{question_fingerprint}' + )::text; + """ + )) + assert executing["status"] == "ready" + reservation = json.loads(pg14_database.sql( + f""" + select row_to_json(result)::text from public.begin_consultation_credit( + '{user_id}'::uuid, '{request_id}' + ) result; + """ + )) + assert reservation["success"] is True + settlement = json.loads(pg14_database.sql( + f""" + select public.settle_conversational_rectification_handoff( + '{user_id}'::uuid, '{case_id}'::uuid, '{claim_action}'::uuid, + '{request_id}'::uuid, true + )::text; + """ + )) + assert settlement["status"] == "consumed" + + pg14_database.sql( + f"insert into public.chat_sessions (id, user_id) values ('{chat_id}'::uuid, '{user_id}'::uuid)" + ) + pg14_database.sql( + """ + create or replace function auth.uid() returns uuid + language sql stable set search_path = '' + as 'select nullif(pg_catalog.current_setting(''request.jwt.claim.sub'', true), '''')::uuid'; + """ + ) + try: + pg14_database.sql( + f""" + begin; + set local role authenticated; + select pg_catalog.set_config('request.jwt.claim.sub', '{user_id}', true); + delete from public.chat_sessions where id = '{chat_id}'::uuid; + commit; + """ + ) + finally: + pg14_database.sql( + """ + create or replace function auth.uid() returns uuid + language sql stable set search_path = '' as 'select null::uuid'; + """ + ) + durable = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'chatCount', (select pg_catalog.count(*) from public.chat_sessions where id = '{chat_id}'::uuid), + 'caseCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases where id = '{case_id}'::uuid), + 'credits', credits, + 'rectificationCharges', (select pg_catalog.count(*) from public.birth_time_rectification_billing where case_id = '{case_id}'::uuid and state = 'charged'), + 'handoff', (select state from public.birth_time_rectification_question_handoffs where case_id = '{case_id}'::uuid), + 'question', (select pending_consultation_question from public.birth_time_rectification_cases where id = '{case_id}'::uuid) + )::text + from public.profiles where id = '{user_id}'::uuid; + """ + )) + assert durable == { + "chatCount": 0, + "caseCount": 1, + "credits": 16, + "rectificationCharges": 1, + "handoff": "consumed", + "question": None, + } + + +def test_all_v3_tables_and_public_rpcs_are_service_role_only(pg14_database: PgDatabase) -> None: + tables = [ + "birth_time_rectification_cases", + "birth_time_rectification_turns", + "birth_time_rectification_event_evidence", + "birth_time_rectification_billing", + "birth_time_rectification_action_receipts", + "birth_time_rectification_question_handoffs", + "birth_time_rectification_handoff_attach_receipts", + "birth_time_rectification_handoff_settlements", + ] + for table in tables: + privileges = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'anon', pg_catalog.has_table_privilege('anon', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'), + 'authenticated', pg_catalog.has_table_privilege('authenticated', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'), + 'serviceRole', pg_catalog.has_table_privilege('service_role', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE') + )::text; + """ + )) + assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}, table + + rpc_names = [ + "reserve_conversational_rectification_fee", + "complete_conversational_rectification_fee", + "release_conversational_rectification_fee", + "create_conversational_rectification_case", + "load_conversational_rectification_case", + "replay_conversational_rectification_action", + "save_conversational_rectification_turn", + "pause_conversational_rectification_case", + "abandon_conversational_rectification_case", + "confirm_conversational_rectification_candidate", + "import_legacy_conversational_rectification_case", + "attach_conversational_rectification_question", + "load_conversational_rectification_handoff", + "claim_conversational_rectification_handoff", + "begin_conversational_rectification_handoff_execution", + "settle_conversational_rectification_handoff", + ] + rows = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object( + 'name', proc.proname, + 'anon', pg_catalog.has_function_privilege('anon', proc.oid, 'EXECUTE'), + 'authenticated', pg_catalog.has_function_privilege('authenticated', proc.oid, 'EXECUTE'), + 'serviceRole', pg_catalog.has_function_privilege('service_role', proc.oid, 'EXECUTE') + ) order by proc.proname)::text + from pg_catalog.pg_proc proc + join pg_catalog.pg_namespace namespace on namespace.oid = proc.pronamespace + where namespace.nspname = 'public' + and proc.proname = any({_text('{' + ','.join(rpc_names) + '}')}::text[]); + """ + )) + assert {row["name"] for row in rows} == set(rpc_names) + assert all(row == { + "name": row["name"], + "anon": False, + "authenticated": False, + "serviceRole": True, + } for row in rows) diff --git a/tests/test_conversational_rectification_security.py b/tests/test_conversational_rectification_security.py index 61965afb..5ea6f215 100644 --- a/tests/test_conversational_rectification_security.py +++ b/tests/test_conversational_rectification_security.py @@ -4,7 +4,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations" -V3_MIGRATIONS = tuple(sorted(MIGRATIONS.glob("2026072*conversational*.sql"))) + ( +V3_MIGRATIONS = (MIGRATIONS / "20260717020000_birth_time_journey.sql",) + tuple( + sorted(MIGRATIONS.glob("2026072*conversational*.sql")) +) + ( MIGRATIONS / "20260720040000_rectification_question_handoff.sql", ) @@ -13,12 +15,24 @@ def _sql() -> str: return "\n".join(path.read_text(encoding="utf-8") for path in V3_MIGRATIONS) +def _function_body(name: str) -> str: + matches = re.findall( + rf"create or replace function public\.{re.escape(name)}\([\s\S]*?\)\s*" + rf"returns[\s\S]*?\bas \$\$([\s\S]*?)\$\$;", + _sql().lower(), + ) + assert matches, f"missing function body: {name}" + return matches[-1] + + def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None: sql = _sql().lower() tables = ( + "birth_time_rectification_cases", "birth_time_rectification_turns", "birth_time_rectification_event_evidence", "birth_time_rectification_billing", + "birth_time_rectification_action_receipts", "birth_time_rectification_question_handoffs", "birth_time_rectification_handoff_attach_receipts", "birth_time_rectification_handoff_settlements", @@ -26,49 +40,90 @@ def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None: for table in tables: assert f"alter table public.{table} enable row level security" in sql assert re.search(rf"revoke all on table public\.{table}\s+from (?:public, )?anon, authenticated", sql) - assert not re.search(rf"grant (?:select|insert|update|delete|all).+public\.{table}.+to authenticated", sql) - mutation_rpcs = ( + public_rpcs = ( + "reserve_conversational_rectification_fee", + "complete_conversational_rectification_fee", + "release_conversational_rectification_fee", "create_conversational_rectification_case", + "load_conversational_rectification_case", + "replay_conversational_rectification_action", "save_conversational_rectification_turn", "pause_conversational_rectification_case", "abandon_conversational_rectification_case", "confirm_conversational_rectification_candidate", "import_legacy_conversational_rectification_case", "attach_conversational_rectification_question", + "load_conversational_rectification_handoff", "claim_conversational_rectification_handoff", + "begin_conversational_rectification_handoff_execution", "settle_conversational_rectification_handoff", ) - for function in mutation_rpcs: + for function in public_rpcs: assert re.search(rf"revoke all on function public\.{function}\(", sql) assert not re.search(rf"grant execute on function public\.{function}\([\s\S]{{0,800}}?to (?:anon|authenticated)", sql) def test_mutations_bind_owner_version_action_and_fingerprint_before_writes() -> None: - sql = _sql().lower() - for field in ("p_user_id", "p_case_id", "p_expected_version", "p_action_id"): - assert field in sql - assert "p_command_fingerprint" in sql - assert "p_question_fingerprint" in sql - assert "for update" in sql - assert "conversational_stale_turn" in sql - assert "conversational_action_conflict" in sql - assert "question_fingerprint" in sql + # This is an explicit per-RPC guard matrix. It prevents an unrelated + # function elsewhere in the migration bundle from satisfying a global + # keyword search. Runtime adversarial/no-side-effect behavior is covered + # by test_conversational_rectification_postgres_runtime.py. + guard_matrix = { + "reserve_conversational_rectification_fee": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update", + ), + "complete_conversational_rectification_fee": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update", + ), + "release_conversational_rectification_fee": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update", + ), + "create_conversational_rectification_case": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "v_fingerprint", "for update", + ), + "save_conversational_rectification_turn": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update", + ), + "pause_conversational_rectification_case": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update", + ), + "abandon_conversational_rectification_case": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update", + ), + "confirm_conversational_rectification_candidate": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update", + ), + "import_legacy_conversational_rectification_case": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_legacy_case_id", "for update", + ), + "attach_conversational_rectification_question": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update", + ), + "claim_conversational_rectification_handoff": ( + "p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update", + ), + "begin_conversational_rectification_handoff_execution": ( + "p_user_id", "p_case_id", "p_expected_version", "p_claim_action_id", "p_request_id", "p_question_fingerprint", "for update", + ), + "settle_conversational_rectification_handoff": ( + "p_user_id", "p_case_id", "p_claim_action_id", "p_request_id", "for update", + ), + } + for function, guards in guard_matrix.items(): + body = _function_body(function) + for guard in guards: + assert guard in body, f"{function} is missing guard {guard}" -def test_public_projection_excludes_private_weights_and_future_evidence_is_not_scored() -> None: +def test_public_projection_excludes_private_weights() -> None: contracts = (ROOT / "frontend/src/lib/conversational-rectification/contracts.ts").read_text(encoding="utf-8") projection = (ROOT / "frontend/src/lib/conversational-rectification/technical-packet.ts").read_text(encoding="utf-8") - route = (ROOT / "frontend/src/app/api/birth-time-conversation/route.ts").read_text(encoding="utf-8") - orchestrator = (ROOT / "frontend/src/lib/conversational-rectification/orchestrator.ts").read_text(encoding="utf-8") public_region = contracts[contracts.index("const candidateSchema"):contracts.index("export type ConversationalRectificationTurn")] assert "candidateWeights" not in public_region assert "partitionIds" not in public_region assert "candidateWeights" not in projection[projection.index("export function projectRectificationTechnicalPacket"):] - assert "item.scoreable !== true" in route - assert "futureWindows" in orchestrator - assert "scoreable: false" in orchestrator def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None: @@ -78,7 +133,7 @@ def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None: r"eyj[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+", r"bearer\s+[a-z0-9._-]{20,}", r"supabase_service_role_key\s*=", - r"openai_api_key\s*=", + r"openai_api_key\s*=\s*[\"'](?:sk-|[a-z0-9]{32})", r"refresh_token", r"auth-token", )