test: close conversational rectification review gaps

This commit is contained in:
Jesse_Chen
2026-07-21 15:06:42 +08:00
parent ffaa7dc9c5
commit 6827e0ca45
11 changed files with 685 additions and 54 deletions
@@ -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)
@@ -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",
)