From d15d9b9152d063348265a739bfd464ffeb426d97 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 30 Apr 2026 22:26:59 -0400 Subject: [PATCH] fix: scrub training public import response --- .../training/training_case_service.py | 2 +- .../test_training_public_api_contract_m8.py | 122 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/services/training/training_case_service.py b/src/services/training/training_case_service.py index c84bc0f..5845da9 100644 --- a/src/services/training/training_case_service.py +++ b/src/services/training/training_case_service.py @@ -96,7 +96,7 @@ class TrainingCaseService: return _sanitize_public_export(export_training_cases(cases)) def import_cases(self, payload: dict) -> dict: - return import_training_cases(payload, repository=self.repository) + return _sanitize_public_export(import_training_cases(payload, repository=self.repository)) def _sanitize_public_export(value): diff --git a/tests/unit/test_training_public_api_contract_m8.py b/tests/unit/test_training_public_api_contract_m8.py index 694b7aa..dfa5632 100644 --- a/tests/unit/test_training_public_api_contract_m8.py +++ b/tests/unit/test_training_public_api_contract_m8.py @@ -260,6 +260,75 @@ def _public_import_package() -> dict: } +def _nested_public_import_package() -> dict: + return { + "version": 1, + "items": [ + { + "external_id": "m19-import-ref " + INTERNAL_FIELD, + "title": "Imported response privacy", + "scenario": { + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "start_ts": "2026-04-22T09:00:00Z", + "decision_ts": "2026-04-22T10:00:00Z", + "end_ts": "2026-04-22T11:00:00Z", + "source_event_refs": [ + { + "kind": "sweep_event", + "id": 19, + "answer": NESTED_PRIVATE_MARKERS[0], + INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1], + }, + {"kind": "context", "id": 20, "label": PRIVATE_FIELD_TEXT}, + ], + }, + "skill_tags": ["sweep", "context_reading"], + "difficulty": "medium", + "status": "draft", + "answer": { + "side": NESTED_PRIVATE_MARKERS[0], + "nested": { + "close": NESTED_PRIVATE_MARKERS[1], + INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[2], + }, + }, + "rubric": [ + { + "category": NESTED_PRIVATE_MARKERS[2], + "max_score": 5, + "answer": NESTED_PRIVATE_MARKERS[0], + INTERNAL_FIELD: {"detail": NESTED_PRIVATE_MARKERS[1]}, + } + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "sweep", + "prompt": "Describe the public confirmation.", + "required_annotation_types": ["text"], + "max_score": 5, + "meta": { + "public_hint": "Focus on context.", + "answer": NESTED_PRIVATE_MARKERS[0], + INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1], + "nested": {"answer": NESTED_PRIVATE_MARKERS[2]}, + }, + } + ], + "metadata": { + "chapter_ref": "chapter_19", + "public_label": "visible", + "answer": NESTED_PRIVATE_MARKERS[0], + INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1], + "nested": {"answer": NESTED_PRIVATE_MARKERS[2]}, + }, + } + ], + } + + def _dict_keys(value) -> list[str]: if isinstance(value, dict): keys = [] @@ -411,6 +480,59 @@ class TrainingPublicApiContractM8Tests(unittest.TestCase): with self.subTest(private_text=private_text): self.assertNotIn(private_text, serialized) + def test_public_training_import_route_scrubs_nested_solution_material_from_response(self) -> None: + repository = _PublicTrainingRepository() + service = TrainingCaseService(repository=repository) + dependencies = _training_dependencies(service) + + status, payload = server.route_request( + "/training/cases/import", + {}, + dependencies=dependencies, + method="POST", + body=_nested_public_import_package(), + ) + + self.assertEqual(status, 201) + self.assertEqual(payload["imported"], 1) + self.assertEqual(payload["case_ids"], [41]) + item = payload["items"][0] + self.assertEqual( + set(item), + {"id", "title", "scenario", "skill_tags", "difficulty", "status", "external_id"}, + ) + self.assertEqual(item["id"], 41) + self.assertEqual(item["title"], "Imported response privacy") + self.assertEqual(item["external_id"], "m19-import-ref private solution field") + self.assertEqual(item["skill_tags"], ["sweep", "context_reading"]) + self.assertEqual(item["difficulty"], "medium") + self.assertEqual(item["status"], "draft") + self.assertEqual( + item["scenario"], + { + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "start_ts": "2026-04-22T09:00:00Z", + "decision_ts": "2026-04-22T10:00:00Z", + "end_ts": "2026-04-22T11:00:00Z", + "source_event_refs": [ + {"id": 19, "kind": "sweep_event"}, + {"id": 20, "kind": "context", "label": "public text mentions private solution field"}, + ], + }, + ) + + serialized = json.dumps(payload, sort_keys=True) + self.assertNotIn(INTERNAL_FIELD, serialized) + for private_text in NESTED_PRIVATE_MARKERS: + with self.subTest(private_text=private_text): + self.assertNotIn(private_text, serialized) + + keys = _dict_keys(payload) + self.assertNotIn("answer", keys) + self.assertNotIn(INTERNAL_FIELD, keys) + def test_public_training_list_detail_routes_scrub_nested_solution_material(self) -> None: repository = _NestedPublicTrainingRepository() service = TrainingCaseService(repository=repository)