"""Tests for Anti-Fake-Evidence Phase 2. ~35 tests covering: - Audit trail extension (evidence review/create logging) - Assertion engine (extraction, CRUD, verify, summary) - Four-Eyes review (domain check, first/second review, same-person reject) - UI badge data (response schema includes new fields) """ from datetime import datetime, timedelta from unittest.mock import MagicMock, patch from fastapi import FastAPI from fastapi.testclient import TestClient from compliance.api.evidence_routes import ( router as evidence_router, _requires_four_eyes, _classify_confidence, _classify_truth_status, ) from compliance.api.assertion_routes import router as assertion_router from compliance.services.assertion_engine import extract_assertions, _classify_sentence from compliance.db.models import ( EvidenceConfidenceEnum, EvidenceTruthStatusEnum, ControlStatusEnum, AssertionDB, ) from classroom_engine.database import get_db # --------------------------------------------------------------------------- # App setup with mocked DB dependency # --------------------------------------------------------------------------- app = FastAPI() app.include_router(evidence_router) app.include_router(assertion_router) mock_db = MagicMock() def override_get_db(): yield mock_db app.dependency_overrides[get_db] = override_get_db client = TestClient(app) EVIDENCE_UUID = "eeee0002-aaaa-bbbb-cccc-ffffffffffff" CONTROL_UUID = "cccc0002-aaaa-bbbb-cccc-dddddddddddd" ASSERTION_UUID = "aaaa0002-bbbb-cccc-dddd-eeeeeeeeeeee" NOW = datetime(2026, 3, 23, 14, 0, 0) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def make_evidence(overrides=None): e = MagicMock() e.id = EVIDENCE_UUID e.control_id = CONTROL_UUID e.evidence_type = "test_results" e.title = "Phase 2 Test Evidence" e.description = "Testing four-eyes" e.artifact_url = "https://ci.example.com/artifact" e.artifact_path = None e.artifact_hash = "abc123" e.file_size_bytes = None e.mime_type = None e.status = MagicMock() e.status.value = "valid" e.uploaded_by = None e.source = "api" e.ci_job_id = None e.valid_from = NOW e.valid_until = NOW + timedelta(days=90) e.collected_at = NOW e.created_at = NOW e.confidence_level = EvidenceConfidenceEnum.E1 e.truth_status = EvidenceTruthStatusEnum.UPLOADED e.generation_mode = None e.may_be_used_as_evidence = True e.reviewed_by = None e.reviewed_at = None # Phase 2 fields e.approval_status = "none" e.first_reviewer = None e.first_reviewed_at = None e.second_reviewer = None e.second_reviewed_at = None e.requires_four_eyes = False if overrides: for k, v in overrides.items(): setattr(e, k, v) return e def make_assertion(overrides=None): a = MagicMock() a.id = ASSERTION_UUID a.tenant_id = "tenant-001" a.entity_type = "control" a.entity_id = CONTROL_UUID a.sentence_text = "Test assertion sentence" a.sentence_index = 0 a.assertion_type = "assertion" a.evidence_ids = [] a.confidence = 0.0 a.normative_tier = "pflicht" a.verified_by = None a.verified_at = None a.created_at = NOW a.updated_at = NOW if overrides: for k, v in overrides.items(): setattr(a, k, v) return a # =========================================================================== # 1. TestAuditTrailExtension # =========================================================================== class TestAuditTrailExtension: """Test that evidence review and create log audit trail entries.""" def test_review_evidence_logs_audit_trail(self): evidence = make_evidence() mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"confidence_level": "E2", "reviewed_by": "auditor@test.com"}, ) assert resp.status_code == 200 # db.add should be called for audit trail entries assert mock_db.add.called def test_review_evidence_records_old_and_new_confidence(self): evidence = make_evidence({"confidence_level": EvidenceConfidenceEnum.E1}) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"confidence_level": "E3", "reviewed_by": "reviewer@test.com"}, ) assert resp.status_code == 200 def test_review_evidence_records_truth_status_change(self): evidence = make_evidence({"truth_status": EvidenceTruthStatusEnum.UPLOADED}) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"truth_status": "validated_internal", "reviewed_by": "reviewer@test.com"}, ) assert resp.status_code == 200 def test_review_nonexistent_evidence_returns_404(self): mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = None resp = client.patch( "/evidence/nonexistent/review", json={"reviewed_by": "someone"}, ) assert resp.status_code == 404 def test_reject_evidence_logs_audit_trail(self): evidence = make_evidence() mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/reject", json={"reviewed_by": "auditor@test.com", "rejection_reason": "Fake evidence"}, ) assert resp.status_code == 200 data = resp.json() assert data["approval_status"] == "rejected" def test_reject_nonexistent_evidence_returns_404(self): mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = None resp = client.patch( "/evidence/nonexistent/reject", json={"reviewed_by": "someone"}, ) assert resp.status_code == 404 def test_audit_trail_query_endpoint(self): mock_db.reset_mock() trail_entry = MagicMock() trail_entry.id = "trail-001" trail_entry.entity_type = "evidence" trail_entry.entity_id = EVIDENCE_UUID trail_entry.entity_name = "Test" trail_entry.action = "review" trail_entry.field_changed = "confidence_level" trail_entry.old_value = "E1" trail_entry.new_value = "E2" trail_entry.change_summary = None trail_entry.performed_by = "auditor" trail_entry.performed_at = NOW trail_entry.checksum = "abc" mock_db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [trail_entry] resp = client.get(f"/audit-trail?entity_type=evidence&entity_id={EVIDENCE_UUID}") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 def test_audit_trail_checksum_present(self): """Audit trail entries should have a checksum for integrity.""" from compliance.api.audit_trail_utils import create_signature sig = create_signature("evidence|123|review|user@test.com") assert len(sig) == 64 # SHA-256 hex digest # =========================================================================== # 2. TestAssertionEngine # =========================================================================== class TestAssertionEngine: """Test assertion extraction and classification.""" def test_pflicht_sentence_classified_as_assertion(self): result = _classify_sentence("Die Organisation muss ein ISMS implementieren.") assert result == ("assertion", "pflicht") def test_empfehlung_sentence_classified(self): result = _classify_sentence("Die Organisation sollte regelmäßige Audits durchführen.") assert result == ("assertion", "empfehlung") def test_kann_sentence_classified(self): result = _classify_sentence("Optional kann ein externes Audit durchgeführt werden.") assert result == ("assertion", "kann") def test_rationale_sentence_classified(self): result = _classify_sentence("Dies ist erforderlich, weil Datenverlust schwere Folgen hat.") assert result == ("rationale", None) def test_fact_sentence_with_evidence_keyword(self): result = _classify_sentence("Das Zertifikat wurde am 15.03.2026 ausgestellt.") assert result == ("fact", None) def test_extract_assertions_splits_sentences(self): text = "Die Organisation muss Daten schützen. Sie sollte regelmäßig prüfen." results = extract_assertions(text, "control", "ctrl-001") assert len(results) == 2 assert results[0]["assertion_type"] == "assertion" assert results[0]["normative_tier"] == "pflicht" assert results[1]["normative_tier"] == "empfehlung" def test_extract_assertions_empty_text(self): results = extract_assertions("", "control", "ctrl-001") assert results == [] def test_extract_assertions_single_sentence(self): results = extract_assertions("Der Betreiber muss ein Audit durchführen.", "control", "ctrl-001") assert len(results) == 1 assert results[0]["normative_tier"] == "pflicht" def test_mixed_text_with_rationale(self): text = "Die Organisation muss ein ISMS implementieren. Dies ist notwendig, weil Compliance gefordert ist." results = extract_assertions(text, "control", "ctrl-001") assert len(results) == 2 types = [r["assertion_type"] for r in results] assert "assertion" in types assert "rationale" in types def test_assertion_crud_create(self): mock_db.reset_mock() mock_db.refresh.return_value = None # Mock the added object to return proper values def side_effect_add(obj): obj.id = ASSERTION_UUID obj.created_at = NOW obj.updated_at = NOW obj.sentence_index = 0 obj.confidence = 0.0 mock_db.add.side_effect = side_effect_add resp = client.post( "/assertions?tenant_id=tenant-001", json={ "entity_type": "control", "entity_id": CONTROL_UUID, "sentence_text": "Die Organisation muss ein ISMS implementieren.", "assertion_type": "assertion", "normative_tier": "pflicht", }, ) assert resp.status_code == 200 def test_assertion_verify_endpoint(self): a = make_assertion() mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = a mock_db.refresh.return_value = None resp = client.post(f"/assertions/{ASSERTION_UUID}/verify?verified_by=auditor@test.com") assert resp.status_code == 200 assert a.assertion_type == "fact" assert a.verified_by == "auditor@test.com" def test_assertion_summary(self): mock_db.reset_mock() a1 = make_assertion({"assertion_type": "assertion", "verified_by": None}) a2 = make_assertion({"assertion_type": "fact", "verified_by": "user"}) a3 = make_assertion({"assertion_type": "rationale", "verified_by": None}) mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [a1, a2, a3] # Direct .all() for no-filter case mock_db.query.return_value.all.return_value = [a1, a2, a3] resp = client.get("/assertions/summary") assert resp.status_code == 200 data = resp.json() assert data["total_assertions"] == 3 assert data["total_facts"] == 1 assert data["total_rationale"] == 1 assert data["unverified_count"] == 1 # =========================================================================== # 3. TestFourEyesReview # =========================================================================== class TestFourEyesReview: """Test Four-Eyes review process.""" def test_gov_domain_requires_four_eyes(self): assert _requires_four_eyes("gov") is True def test_priv_domain_requires_four_eyes(self): assert _requires_four_eyes("priv") is True def test_ops_domain_does_not_require_four_eyes(self): assert _requires_four_eyes("ops") is False def test_sdlc_domain_does_not_require_four_eyes(self): assert _requires_four_eyes("sdlc") is False def test_first_review_sets_first_approved(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "pending_first", }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer1@test.com"}, ) assert resp.status_code == 200 assert evidence.first_reviewer == "reviewer1@test.com" assert evidence.approval_status == "first_approved" def test_second_review_different_person_approves(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "first_approved", "first_reviewer": "reviewer1@test.com", }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer2@test.com"}, ) assert resp.status_code == 200 assert evidence.second_reviewer == "reviewer2@test.com" assert evidence.approval_status == "approved" def test_same_person_second_review_rejected(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "first_approved", "first_reviewer": "reviewer1@test.com", }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer1@test.com"}, ) assert resp.status_code == 400 assert "different" in resp.json()["detail"].lower() def test_already_approved_blocked(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "approved", }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer3@test.com"}, ) assert resp.status_code == 400 assert "already" in resp.json()["detail"].lower() def test_rejected_evidence_cannot_be_reviewed(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "rejected", }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer@test.com"}, ) assert resp.status_code == 400 def test_reject_endpoint(self): evidence = make_evidence({"requires_four_eyes": True}) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/reject", json={"reviewed_by": "auditor@test.com", "rejection_reason": "Not authentic"}, ) assert resp.status_code == 200 assert evidence.approval_status == "rejected" # =========================================================================== # 4. TestUIBadgeData # =========================================================================== class TestUIBadgeData: """Test that evidence response includes all Phase 2 fields.""" def test_evidence_response_includes_approval_status(self): evidence = make_evidence({ "approval_status": "first_approved", "first_reviewer": "reviewer1@test.com", "first_reviewed_at": NOW, "requires_four_eyes": True, }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence mock_db.refresh.return_value = None resp = client.patch( f"/evidence/{EVIDENCE_UUID}/review", json={"reviewed_by": "reviewer2@test.com"}, ) assert resp.status_code == 200 data = resp.json() assert "approval_status" in data assert "requires_four_eyes" in data assert data["requires_four_eyes"] is True def test_evidence_response_includes_four_eyes_fields(self): evidence = make_evidence({ "requires_four_eyes": True, "approval_status": "approved", "first_reviewer": "r1@test.com", "first_reviewed_at": NOW, "second_reviewer": "r2@test.com", "second_reviewed_at": NOW, }) mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = evidence # Use list endpoint mock_db.query.return_value.filter.return_value.all.return_value = [evidence] mock_db.query.return_value.all.return_value = [evidence] # Direct test via _build_evidence_response from compliance.api.evidence_routes import _build_evidence_response resp = _build_evidence_response(evidence) assert resp.approval_status == "approved" assert resp.first_reviewer == "r1@test.com" assert resp.second_reviewer == "r2@test.com" assert resp.requires_four_eyes is True def test_assertion_response_schema(self): a = make_assertion() mock_db.reset_mock() mock_db.query.return_value.filter.return_value.first.return_value = a resp = client.get(f"/assertions/{ASSERTION_UUID}") assert resp.status_code == 200 data = resp.json() assert "assertion_type" in data assert "normative_tier" in data assert "evidence_ids" in data assert "verified_by" in data def test_evidence_response_includes_confidence_and_truth(self): evidence = make_evidence({ "confidence_level": EvidenceConfidenceEnum.E3, "truth_status": EvidenceTruthStatusEnum.OBSERVED, }) from compliance.api.evidence_routes import _build_evidence_response resp = _build_evidence_response(evidence) assert resp.confidence_level == "E3" assert resp.truth_status == "observed" def test_evidence_response_none_four_eyes_fields_default(self): evidence = make_evidence() from compliance.api.evidence_routes import _build_evidence_response resp = _build_evidence_response(evidence) assert resp.approval_status == "none" assert resp.requires_four_eyes is False assert resp.first_reviewer is None