Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
529 lines
20 KiB
Python
529 lines
20 KiB
Python
"""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
|