from __future__ import annotations import unittest from contextlib import asynccontextmanager from pathlib import Path from unittest.mock import AsyncMock, patch from uuid import UUID, uuid4 from fastapi import HTTPException from fastapi import FastAPI from pydantic import ValidationError from .deps import Principal, Role from .routes import rupture_repairs from .services import rupture_repair_store def _principal(role: Role = Role.LEARNER) -> Principal: return Principal( user_id=str(uuid4()), role=role, cohort_ids=["g3-cohort"], ) class RuptureRepairStoreContractTests(unittest.TestCase): def test_canonical_hash_is_order_independent(self) -> None: self.assertEqual( rupture_repair_store._canonical_hash({"b": 2, "a": 1}), rupture_repair_store._canonical_hash({"a": 1, "b": 2}), ) def test_human_views_keep_learner_and_supervisor_projections_separate(self) -> None: self.assertEqual( rupture_repair_store._human_view(_principal(Role.LEARNER)), "counselor", ) self.assertEqual( rupture_repair_store._human_view(_principal(Role.TEACHER)), "supervisor", ) def test_evidence_must_be_nonempty_and_unique(self) -> None: with self.assertRaises(rupture_repair_store.RuptureRepairStateError): rupture_repair_store._ensure_unique_nonempty_evidence(()) evidence = uuid4() with self.assertRaises(rupture_repair_store.RuptureRepairStateError): rupture_repair_store._ensure_unique_nonempty_evidence((evidence, evidence)) def test_internal_request_requires_explicit_evaluator_model_provenance(self) -> None: with self.assertRaises(ValidationError): rupture_repairs.InternalRuptureObservationRequest( episode_key="episode-1", idempotency_key=uuid4(), event_kind="rupture.detected", from_state=None, to_state="onset", rupture_type="withdrawal", source_kind="model_inferred", perspective="independent_observer", ai_view="evaluator", confidence=0.9, uncertainty=0.1, evidence_turn_ids=[uuid4()], ) def test_runtime_request_cannot_claim_independent_observer_perspective(self) -> None: with self.assertRaises(ValidationError): rupture_repairs.InternalRuptureObservationRequest( episode_key="episode-1", idempotency_key=uuid4(), event_kind="rupture.detected", from_state=None, to_state="onset", rupture_type="withdrawal", source_kind="observed_runtime", perspective="independent_observer", ai_view="evaluator", confidence=0.9, uncertainty=0.1, evidence_turn_ids=[uuid4()], ) def test_reconciliation_disposition_is_typed_against_deep_status(self) -> None: with self.assertRaises(ValidationError): rupture_repairs.InternalReconciliationRequest( idempotency_key=uuid4(), fast_warning_observation_id=uuid4(), fast_warning_id="warning-1", provisional_status="missed", deep_status="partial", disposition="superseded_resolved", uncertainty=0.1, model_run_id=uuid4(), ai_view="evaluator", ) def test_response_has_no_total_score_and_keeps_safety_as_reference(self) -> None: payload = { "session_id": uuid4(), "requested_view": "counselor", "clinical_claim_allowed": False, "episodes": [], } response = rupture_repairs.RuptureRepairReadModelResponse.model_validate(payload) dumped = response.model_dump(mode="json") self.assertNotIn("total", dumped) self.assertNotIn("score", dumped) self.assertFalse(dumped["clinical_claim_allowed"]) class RuptureRepairStoreAsyncTests(unittest.IsolatedAsyncioTestCase): async def test_existing_idempotency_key_returns_same_id(self) -> None: identifier = uuid4() conn = AsyncMock() conn.fetchrow.return_value = { "observation_id": identifier, "content_hash": "a" * 64, } result = await rupture_repair_store._existing_by_idempotency( conn, table="app.rupture_observation_event", id_column="observation_id", session_id=uuid4(), idempotency_key=uuid4(), content_hash="a" * 64, ) self.assertEqual(result, identifier) async def test_idempotency_key_reuse_with_different_content_is_conflict(self) -> None: conn = AsyncMock() conn.fetchrow.return_value = { "observation_id": uuid4(), "content_hash": "a" * 64, } with self.assertRaises(rupture_repair_store.RuptureRepairConflictError): await rupture_repair_store._existing_by_idempotency( conn, table="app.rupture_observation_event", id_column="observation_id", session_id=uuid4(), idempotency_key=uuid4(), content_hash="b" * 64, ) async def test_evaluator_observation_creates_episode_and_event(self) -> None: session_id = uuid4() case_id = uuid4() learner_id = uuid4() episode_id = uuid4() observation_id = uuid4() turn_id = uuid4() conn = AsyncMock() conn.fetchrow.side_effect = [ {"id": session_id, "case_id": case_id, "learner_id": learner_id}, {"episode_id": episode_id}, None, {"observation_id": observation_id}, ] result = await rupture_repair_store.append_evaluator_observation( conn=conn, session_id=session_id, episode_key="episode-1", idempotency_key=uuid4(), event_kind="rupture.detected", from_state=None, to_state="onset", rupture_type="withdrawal", source_kind="observed_runtime", perspective="runtime_observation", ai_view="evaluator", confidence=0.8, uncertainty=0.2, evidence_turn_ids=(turn_id,), counterevidence=(), model_run_id=None, ) self.assertEqual( result, {"episode_id": episode_id, "observation_id": observation_id}, ) self.assertEqual(conn.fetchrow.await_count, 4) async def test_evaluator_store_rejects_non_evaluator_ai_view_before_db(self) -> None: conn = AsyncMock() with self.assertRaisesRegex( rupture_repair_store.RuptureRepairStateError, "ai_view=evaluator", ): await rupture_repair_store.append_evaluator_observation( conn=conn, session_id=uuid4(), episode_key="episode-1", idempotency_key=uuid4(), event_kind="rupture.detected", from_state=None, to_state="onset", rupture_type="withdrawal", source_kind="observed_runtime", perspective="runtime_observation", ai_view="supervisor", confidence=0.8, uncertainty=0.2, evidence_turn_ids=(uuid4(),), counterevidence=(), model_run_id=None, ) conn.execute.assert_not_awaited() async def test_learner_cannot_append_human_correction(self) -> None: with self.assertRaisesRegex( rupture_repair_store.RuptureRepairStateError, "teacher or admin", ): await rupture_repair_store.append_human_correction( principal=_principal(Role.LEARNER), session_id=uuid4(), episode_id=uuid4(), idempotency_key=uuid4(), supersedes_observation_id=uuid4(), rupture_type="withdrawal", corrected_status="partial", uncertainty=0.1, evidence_turn_ids=(uuid4(),), counterevidence=(), correction_reason="근거 재평가", ) async def test_teacher_correction_appends_human_rated_supersession(self) -> None: principal = _principal(Role.TEACHER) session_id = uuid4() episode_id = uuid4() target_id = uuid4() correction_id = uuid4() conn = AsyncMock() conn.fetchrow.side_effect = [ {"id": session_id, "case_id": uuid4(), "learner_id": uuid4()}, {"to_state": "partial"}, None, {"observation_id": correction_id}, ] @asynccontextmanager async def fake_acquire(**kwargs): self.assertEqual(kwargs["role"], "teacher") self.assertEqual(kwargs["cohort_ids"], ["g3-cohort"]) yield conn with patch.object(rupture_repair_store.db, "acquire", fake_acquire): result = await rupture_repair_store.append_human_correction( principal=principal, session_id=session_id, episode_id=episode_id, idempotency_key=uuid4(), supersedes_observation_id=target_id, rupture_type="withdrawal", corrected_status="resolved", uncertainty=0.1, evidence_turn_ids=(uuid4(),), counterevidence=(), correction_reason="후속 반응 근거 확인", ) self.assertEqual(result, correction_id) insert_args = conn.fetchrow.await_args_list[-1].args self.assertIn("human.corrected", insert_args[0]) self.assertIn("human_rated", insert_args[0]) self.assertEqual(insert_args[5], "partial") self.assertEqual(insert_args[6], "resolved") async def test_empty_read_model_is_role_safe_for_learner(self) -> None: principal = _principal(Role.LEARNER) session_id = uuid4() conn = AsyncMock() conn.fetchrow.return_value = { "id": session_id, "case_id": uuid4(), "learner_id": UUID(principal.user_id), } conn.fetch.return_value = [] @asynccontextmanager async def fake_acquire(**kwargs): self.assertEqual(kwargs["role"], "learner") yield conn with patch.object(rupture_repair_store.db, "acquire", fake_acquire): result = await rupture_repair_store.read_rupture_repairs( principal=principal, session_id=session_id, ) self.assertEqual(result["requested_view"], "counselor") self.assertEqual(result["episodes"], []) self.assertFalse(result["clinical_claim_allowed"]) episode_query = conn.fetch.await_args.args self.assertEqual(episode_query[-1], "counselor") async def test_get_route_maps_not_found_to_404(self) -> None: with patch.object( rupture_repairs.rupture_repair_store, "read_rupture_repairs", AsyncMock( side_effect=rupture_repair_store.RuptureRepairNotFoundError( "not visible" ) ), ): with self.assertRaises(HTTPException) as captured: await rupture_repairs.get_rupture_repairs( session_id=uuid4(), principal=_principal(), ) self.assertEqual(captured.exception.status_code, 404) async def test_internal_route_forwards_explicit_ai_view(self) -> None: episode_id = uuid4() observation_id = uuid4() body = rupture_repairs.InternalRuptureObservationRequest( episode_key="episode-1", idempotency_key=uuid4(), event_kind="rupture.detected", from_state=None, to_state="onset", rupture_type="withdrawal", source_kind="observed_runtime", perspective="runtime_observation", ai_view="evaluator", confidence=0.8, uncertainty=0.2, evidence_turn_ids=[uuid4()], ) mocked = AsyncMock( return_value={ "episode_id": episode_id, "observation_id": observation_id, } ) with patch.object( rupture_repairs.rupture_repair_store, "append_evaluator_observation", mocked, ): response = await rupture_repairs.create_internal_rupture_observation( session_id=uuid4(), body=body, conn=AsyncMock(), ) self.assertEqual(response.observation_id, observation_id) self.assertEqual(mocked.await_args.kwargs["ai_view"], "evaluator") class RuptureInternalAuthenticationTests(unittest.IsolatedAsyncioTestCase): TOKEN = "g3-test-token-with-at-least-32-characters-0001" @staticmethod def _settings(token: str): return rupture_repairs.Settings( _env_file=None, rupture_internal_token=token, ) async def test_unconfigured_token_disables_endpoint_before_db_acquire(self) -> None: reached = False async def fake_provider(): nonlocal reached reached = True yield AsyncMock() dependency = rupture_repairs.rupture_internal_evaluator_db( settings=self._settings(""), presented_token=None, ) with patch.object( rupture_repairs, "_evaluator_db_provider", fake_provider ): with self.assertRaises(HTTPException) as captured: await anext(dependency) self.assertEqual(captured.exception.status_code, 503) self.assertEqual( captured.exception.detail, "internal rupture ingestion is unavailable", ) self.assertFalse(reached) async def test_missing_header_is_401_before_db_acquire(self) -> None: reached = False async def fake_provider(): nonlocal reached reached = True yield AsyncMock() dependency = rupture_repairs.rupture_internal_evaluator_db( settings=self._settings(self.TOKEN), presented_token=None, ) with patch.object( rupture_repairs, "_evaluator_db_provider", fake_provider ): with self.assertRaises(HTTPException) as captured: await anext(dependency) self.assertEqual(captured.exception.status_code, 401) self.assertFalse(reached) async def test_mismatched_header_is_403_and_uses_compare_digest(self) -> None: reached = False async def fake_provider(): nonlocal reached reached = True yield AsyncMock() dependency = rupture_repairs.rupture_internal_evaluator_db( settings=self._settings(self.TOKEN), presented_token="wrong-token-that-must-not-be-reflected", ) with ( patch.object(rupture_repairs, "_evaluator_db_provider", fake_provider), patch.object( rupture_repairs.secrets, "compare_digest", wraps=rupture_repairs.secrets.compare_digest, ) as compared, ): with self.assertRaises(HTTPException) as captured: await anext(dependency) self.assertEqual(captured.exception.status_code, 403) self.assertEqual(captured.exception.detail, "internal authentication failed") compared.assert_called_once_with( "wrong-token-that-must-not-be-reflected", self.TOKEN ) self.assertNotIn("wrong-token", str(captured.exception.detail)) self.assertNotIn(self.TOKEN, str(captured.exception.detail)) self.assertFalse(reached) async def test_valid_token_reaches_evaluator_db_provider(self) -> None: connection = AsyncMock() reached = 0 async def fake_provider(): nonlocal reached reached += 1 yield connection dependency = rupture_repairs.rupture_internal_evaluator_db( settings=self._settings(self.TOKEN), presented_token=self.TOKEN, ) with patch.object( rupture_repairs, "_evaluator_db_provider", fake_provider ): result = await anext(dependency) await dependency.aclose() self.assertIs(result, connection) self.assertEqual(reached, 1) async def test_short_configured_token_is_fail_closed_as_unavailable(self) -> None: dependency = rupture_repairs.rupture_internal_evaluator_db( settings=self._settings("too-short"), presented_token="too-short", ) with self.assertRaises(HTTPException) as captured: await anext(dependency) self.assertEqual(captured.exception.status_code, 503) async def test_secret_setting_repr_and_json_do_not_expose_token(self) -> None: settings = self._settings(self.TOKEN) self.assertNotIn(self.TOKEN, repr(settings)) self.assertNotIn(self.TOKEN, settings.model_dump_json()) self.assertEqual( settings.rupture_internal_token.get_secret_value(), self.TOKEN, ) async def test_openapi_documents_header_only_on_internal_writes(self) -> None: app = FastAPI() app.include_router(rupture_repairs.router) paths = app.openapi()["paths"] for path in ( "/internal/sessions/{session_id}/ruptures/observations", "/internal/sessions/{session_id}/ruptures/{episode_id}/reconciliations", ): header_names = { item["name"] for item in paths[path]["post"].get("parameters", []) if item["in"] == "header" } self.assertEqual( header_names, {rupture_repairs.INTERNAL_TOKEN_HEADER}, ) for path, method in ( ("/sessions/{session_id}/ruptures", "get"), ("/sessions/{session_id}/ruptures/{episode_id}/corrections", "post"), ): header_names = { item["name"] for item in paths[path][method].get("parameters", []) if item["in"] == "header" } self.assertNotIn(rupture_repairs.INTERNAL_TOKEN_HEADER, header_names) class RuptureRepairSchemaStaticTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.sql = ( Path(__file__).resolve().parents[3] / "infra" / "db" / "init" / "09_rupture_repair.sql" ).read_text(encoding="utf-8") def test_schema_has_four_append_only_ledgers_and_rls(self) -> None: for table in ( "app.rupture_episode", "app.rupture_observation_event", "app.rupture_reconciliation_revision", "app.rupture_safety_reference", ): self.assertIn(f"CREATE TABLE IF NOT EXISTS {table}", self.sql) self.assertIn(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY", self.sql) self.assertEqual(self.sql.count("audit.reject_measurement_mutation()"), 4) def test_schema_requires_turn_ownership_model_run_and_idempotency(self) -> None: self.assertIn("rupture evidence turns must belong to its session", self.sql) self.assertIn("rupture model_run must belong to its session", self.sql) self.assertIn("reconciliation model_run must belong to its session", self.sql) self.assertGreaterEqual(self.sql.count("UNIQUE (session_id, idempotency_key)"), 2) def test_safety_table_is_reference_only(self) -> None: safety_sql = self.sql.split( "CREATE TABLE IF NOT EXISTS app.rupture_safety_reference", 1 )[1].split(");", 1)[0] self.assertIn("safety_event_id", safety_sql) self.assertNotIn("confidence", safety_sql) self.assertNotIn("status", safety_sql) self.assertNotIn("score", safety_sql) if __name__ == "__main__": unittest.main()