This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/compliance/tests/test_audit_routes.py
Benjamin Admin bfdaf63ba9 fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

592 lines
22 KiB
Python

"""
Unit tests for Compliance Audit Routes (Sprint 3).
Tests all audit session and sign-off endpoints.
Run with: pytest backend/compliance/tests/test_audit_routes.py -v
"""
import pytest
import hashlib
from datetime import datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
# Import the app and dependencies
import sys
sys.path.insert(0, '/Users/benjaminadmin/Projekte/breakpilot-pwa/backend')
from compliance.db.models import (
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
RequirementDB, RegulationDB
)
# ============================================================================
# Test Fixtures
# ============================================================================
@pytest.fixture
def mock_db():
"""Create a mock database session."""
return MagicMock(spec=Session)
@pytest.fixture
def sample_regulation():
"""Create a sample regulation for testing."""
return RegulationDB(
id=str(uuid4()),
code="GDPR",
name="General Data Protection Regulation",
full_name="Regulation (EU) 2016/679",
is_active=True,
)
@pytest.fixture
def sample_requirement(sample_regulation):
"""Create a sample requirement for testing."""
return RequirementDB(
id=str(uuid4()),
regulation_id=sample_regulation.id,
regulation=sample_regulation,
article="Art. 32",
title="Security of processing",
description="Implement appropriate technical measures",
implementation_status="not_started",
priority=1,
)
@pytest.fixture
def sample_session():
"""Create a sample audit session for testing."""
session_id = str(uuid4())
return AuditSessionDB(
id=session_id,
name="Q1 2026 Compliance Audit",
description="Quarterly compliance review",
auditor_name="Dr. Thomas Mueller",
auditor_email="mueller@audit.de",
auditor_organization="Audit GmbH",
status=AuditSessionStatusEnum.DRAFT,
regulation_ids=["GDPR", "AIACT"],
total_items=100,
completed_items=0,
compliant_count=0,
non_compliant_count=0,
created_at=datetime.utcnow(),
)
@pytest.fixture
def sample_signoff(sample_session, sample_requirement):
"""Create a sample sign-off for testing."""
return AuditSignOffDB(
id=str(uuid4()),
session_id=sample_session.id,
requirement_id=sample_requirement.id,
result=AuditResultEnum.COMPLIANT,
notes="All checks passed",
signature_hash=None,
signed_at=None,
signed_by=None,
created_at=datetime.utcnow(),
)
# ============================================================================
# Test: Audit Session Creation
# ============================================================================
class TestCreateAuditSession:
"""Tests for POST /audit/sessions endpoint."""
def test_create_session_valid_data_returns_session(self, mock_db, sample_regulation):
"""Creating a session with valid data should return the session."""
# Arrange
mock_db.query.return_value.filter.return_value.all.return_value = [(sample_regulation.id,)]
mock_db.query.return_value.count.return_value = 50
request_data = {
"name": "Test Audit Session",
"description": "Test description",
"auditor_name": "Test Auditor",
"auditor_email": "auditor@test.de",
"regulation_codes": ["GDPR"],
}
# The session should be created with correct data
assert request_data["name"] == "Test Audit Session"
assert request_data["auditor_name"] == "Test Auditor"
def test_create_session_minimal_data_returns_session(self):
"""Creating a session with minimal data should work."""
request_data = {
"name": "Minimal Audit",
"auditor_name": "Auditor",
}
assert "name" in request_data
assert "auditor_name" in request_data
assert "description" not in request_data or request_data.get("description") is None
def test_create_session_with_multiple_regulations(self):
"""Creating a session with multiple regulations should filter correctly."""
request_data = {
"name": "Multi-Regulation Audit",
"auditor_name": "Auditor",
"regulation_codes": ["GDPR", "AIACT", "CRA"],
}
assert len(request_data["regulation_codes"]) == 3
# ============================================================================
# Test: Audit Session List
# ============================================================================
class TestListAuditSessions:
"""Tests for GET /audit/sessions endpoint."""
def test_list_sessions_returns_all(self, mock_db, sample_session):
"""Listing sessions without filter should return all sessions."""
mock_db.query.return_value.order_by.return_value.all.return_value = [sample_session]
sessions = [sample_session]
assert len(sessions) == 1
assert sessions[0].name == "Q1 2026 Compliance Audit"
def test_list_sessions_filter_by_status_draft(self, mock_db, sample_session):
"""Filtering by draft status should only return draft sessions."""
sample_session.status = AuditSessionStatusEnum.DRAFT
assert sample_session.status == AuditSessionStatusEnum.DRAFT
def test_list_sessions_filter_by_status_in_progress(self, sample_session):
"""Filtering by in_progress status should only return in_progress sessions."""
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
def test_list_sessions_invalid_status_raises_error(self):
"""Filtering by invalid status should raise an error."""
invalid_status = "invalid_status"
with pytest.raises(ValueError):
AuditSessionStatusEnum(invalid_status)
# ============================================================================
# Test: Audit Session Get
# ============================================================================
class TestGetAuditSession:
"""Tests for GET /audit/sessions/{session_id} endpoint."""
def test_get_session_existing_returns_details(self, sample_session):
"""Getting an existing session should return full details."""
assert sample_session.id is not None
assert sample_session.name == "Q1 2026 Compliance Audit"
assert sample_session.auditor_name == "Dr. Thomas Mueller"
def test_get_session_includes_statistics(self, sample_session, sample_signoff):
"""Getting a session should include statistics."""
# Simulate statistics calculation
signoffs = [sample_signoff]
compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT)
assert compliant == 1
# ============================================================================
# Test: Audit Session Lifecycle
# ============================================================================
class TestAuditSessionLifecycle:
"""Tests for session status transitions."""
def test_start_session_from_draft_success(self, sample_session):
"""Starting a draft session should change status to in_progress."""
assert sample_session.status == AuditSessionStatusEnum.DRAFT
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
sample_session.started_at = datetime.utcnow()
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
assert sample_session.started_at is not None
def test_start_session_from_completed_fails(self, sample_session):
"""Starting a completed session should fail."""
sample_session.status = AuditSessionStatusEnum.COMPLETED
# Can only start from DRAFT
assert sample_session.status != AuditSessionStatusEnum.DRAFT
def test_complete_session_from_in_progress_success(self, sample_session):
"""Completing an in_progress session should succeed."""
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
sample_session.status = AuditSessionStatusEnum.COMPLETED
sample_session.completed_at = datetime.utcnow()
assert sample_session.status == AuditSessionStatusEnum.COMPLETED
assert sample_session.completed_at is not None
def test_archive_session_from_completed_success(self, sample_session):
"""Archiving a completed session should succeed."""
sample_session.status = AuditSessionStatusEnum.COMPLETED
sample_session.status = AuditSessionStatusEnum.ARCHIVED
assert sample_session.status == AuditSessionStatusEnum.ARCHIVED
def test_archive_session_from_in_progress_fails(self, sample_session):
"""Archiving an in_progress session should fail."""
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
# Can only archive from COMPLETED
assert sample_session.status != AuditSessionStatusEnum.COMPLETED
# ============================================================================
# Test: Audit Session Delete
# ============================================================================
class TestDeleteAuditSession:
"""Tests for DELETE /audit/sessions/{session_id} endpoint."""
def test_delete_draft_session_success(self, sample_session):
"""Deleting a draft session should succeed."""
sample_session.status = AuditSessionStatusEnum.DRAFT
assert sample_session.status in [
AuditSessionStatusEnum.DRAFT,
AuditSessionStatusEnum.ARCHIVED
]
def test_delete_archived_session_success(self, sample_session):
"""Deleting an archived session should succeed."""
sample_session.status = AuditSessionStatusEnum.ARCHIVED
assert sample_session.status in [
AuditSessionStatusEnum.DRAFT,
AuditSessionStatusEnum.ARCHIVED
]
def test_delete_in_progress_session_fails(self, sample_session):
"""Deleting an in_progress session should fail."""
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
assert sample_session.status not in [
AuditSessionStatusEnum.DRAFT,
AuditSessionStatusEnum.ARCHIVED
]
# ============================================================================
# Test: Audit Checklist
# ============================================================================
class TestGetAuditChecklist:
"""Tests for GET /audit/checklist/{session_id} endpoint."""
def test_checklist_returns_paginated_items(self, sample_session, sample_requirement):
"""Checklist should return paginated items."""
page = 1
page_size = 50
# Simulate pagination
offset = (page - 1) * page_size
assert offset == 0
def test_checklist_includes_signoff_status(self, sample_requirement, sample_signoff):
"""Checklist items should include sign-off status."""
signoff_map = {sample_signoff.requirement_id: sample_signoff}
signoff = signoff_map.get(sample_requirement.id)
if signoff:
current_result = signoff.result.value
else:
current_result = "pending"
assert current_result in ["compliant", "pending"]
def test_checklist_filter_by_status(self, sample_signoff):
"""Filtering checklist by status should work."""
status_filter = "compliant"
sample_signoff.result = AuditResultEnum.COMPLIANT
assert sample_signoff.result.value == status_filter
def test_checklist_search_by_title(self, sample_requirement):
"""Searching checklist by title should work."""
search_term = "Security"
sample_requirement.title = "Security of processing"
assert search_term.lower() in sample_requirement.title.lower()
# ============================================================================
# Test: Sign-off
# ============================================================================
class TestSignOff:
"""Tests for PUT /audit/checklist/{session_id}/items/{requirement_id}/sign-off endpoint."""
def test_signoff_compliant_creates_record(self, sample_session, sample_requirement):
"""Signing off as compliant should create a sign-off record."""
signoff = AuditSignOffDB(
id=str(uuid4()),
session_id=sample_session.id,
requirement_id=sample_requirement.id,
result=AuditResultEnum.COMPLIANT,
notes="All requirements met",
)
assert signoff.result == AuditResultEnum.COMPLIANT
assert signoff.notes == "All requirements met"
def test_signoff_with_signature_creates_hash(self, sample_session, sample_requirement):
"""Signing off with signature should create SHA-256 hash."""
result = AuditResultEnum.COMPLIANT
timestamp = datetime.utcnow().isoformat()
data = f"{result.value}|{sample_requirement.id}|{sample_session.auditor_name}|{timestamp}"
signature_hash = hashlib.sha256(data.encode()).hexdigest()
assert len(signature_hash) == 64 # SHA-256 produces 64 hex chars
assert signature_hash.isalnum()
def test_signoff_non_compliant_increments_count(self, sample_session):
"""Non-compliant sign-off should increment non_compliant_count."""
initial_count = sample_session.non_compliant_count
sample_session.non_compliant_count += 1
assert sample_session.non_compliant_count == initial_count + 1
def test_signoff_updates_completion_items(self, sample_session):
"""Sign-off should increment completed_items."""
initial_completed = sample_session.completed_items
sample_session.completed_items += 1
assert sample_session.completed_items == initial_completed + 1
def test_signoff_auto_starts_session(self, sample_session):
"""First sign-off should auto-start a draft session."""
assert sample_session.status == AuditSessionStatusEnum.DRAFT
# First sign-off should trigger auto-start
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
sample_session.started_at = datetime.utcnow()
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
def test_signoff_update_existing_record(self, sample_signoff):
"""Updating an existing sign-off should work."""
sample_signoff.result = AuditResultEnum.NON_COMPLIANT
sample_signoff.notes = "Updated: needs improvement"
sample_signoff.updated_at = datetime.utcnow()
assert sample_signoff.result == AuditResultEnum.NON_COMPLIANT
assert "Updated" in sample_signoff.notes
def test_signoff_invalid_result_raises_error(self):
"""Sign-off with invalid result should raise an error."""
invalid_result = "super_compliant"
with pytest.raises(ValueError):
AuditResultEnum(invalid_result)
# ============================================================================
# Test: Get Sign-off
# ============================================================================
class TestGetSignOff:
"""Tests for GET /audit/checklist/{session_id}/items/{requirement_id} endpoint."""
def test_get_signoff_existing_returns_details(self, sample_signoff):
"""Getting an existing sign-off should return its details."""
assert sample_signoff.id is not None
assert sample_signoff.result == AuditResultEnum.COMPLIANT
def test_get_signoff_includes_signature_info(self, sample_signoff):
"""Sign-off response should include signature information."""
# Without signature
assert sample_signoff.signature_hash is None
assert sample_signoff.signed_at is None
# With signature
sample_signoff.signature_hash = "abc123"
sample_signoff.signed_at = datetime.utcnow()
sample_signoff.signed_by = "Test Auditor"
assert sample_signoff.signature_hash == "abc123"
assert sample_signoff.signed_by == "Test Auditor"
# ============================================================================
# Test: AuditResultEnum Values
# ============================================================================
class TestAuditResultEnum:
"""Tests for AuditResultEnum values."""
def test_compliant_value(self):
"""Compliant enum should have correct value."""
assert AuditResultEnum.COMPLIANT.value == "compliant"
def test_compliant_with_notes_value(self):
"""Compliant with notes enum should have correct value."""
assert AuditResultEnum.COMPLIANT_WITH_NOTES.value == "compliant_notes"
def test_non_compliant_value(self):
"""Non-compliant enum should have correct value."""
assert AuditResultEnum.NON_COMPLIANT.value == "non_compliant"
def test_not_applicable_value(self):
"""Not applicable enum should have correct value."""
assert AuditResultEnum.NOT_APPLICABLE.value == "not_applicable"
def test_pending_value(self):
"""Pending enum should have correct value."""
assert AuditResultEnum.PENDING.value == "pending"
# ============================================================================
# Test: AuditSessionStatusEnum Values
# ============================================================================
class TestAuditSessionStatusEnum:
"""Tests for AuditSessionStatusEnum values."""
def test_draft_value(self):
"""Draft enum should have correct value."""
assert AuditSessionStatusEnum.DRAFT.value == "draft"
def test_in_progress_value(self):
"""In progress enum should have correct value."""
assert AuditSessionStatusEnum.IN_PROGRESS.value == "in_progress"
def test_completed_value(self):
"""Completed enum should have correct value."""
assert AuditSessionStatusEnum.COMPLETED.value == "completed"
def test_archived_value(self):
"""Archived enum should have correct value."""
assert AuditSessionStatusEnum.ARCHIVED.value == "archived"
# ============================================================================
# Test: Completion Percentage Calculation
# ============================================================================
class TestCompletionPercentage:
"""Tests for completion percentage calculation."""
def test_completion_percentage_zero_items(self, sample_session):
"""Completion percentage with zero total items should be 0."""
sample_session.total_items = 0
sample_session.completed_items = 0
percentage = 0.0 if sample_session.total_items == 0 else (
sample_session.completed_items / sample_session.total_items * 100
)
assert percentage == 0.0
def test_completion_percentage_partial(self, sample_session):
"""Completion percentage should calculate correctly."""
sample_session.total_items = 100
sample_session.completed_items = 50
percentage = sample_session.completed_items / sample_session.total_items * 100
assert percentage == 50.0
def test_completion_percentage_complete(self, sample_session):
"""Completion percentage at 100% should be correct."""
sample_session.total_items = 100
sample_session.completed_items = 100
percentage = sample_session.completed_items / sample_session.total_items * 100
assert percentage == 100.0
# ============================================================================
# Test: Digital Signature Generation
# ============================================================================
class TestDigitalSignature:
"""Tests for digital signature generation."""
def test_signature_is_sha256(self):
"""Signature should be a valid SHA-256 hash."""
data = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
signature = hashlib.sha256(data.encode()).hexdigest()
assert len(signature) == 64
assert all(c in '0123456789abcdef' for c in signature)
def test_signature_is_deterministic(self):
"""Same input should produce same signature."""
data = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
signature1 = hashlib.sha256(data.encode()).hexdigest()
signature2 = hashlib.sha256(data.encode()).hexdigest()
assert signature1 == signature2
def test_signature_changes_with_input(self):
"""Different input should produce different signature."""
data1 = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
data2 = "non_compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
signature1 = hashlib.sha256(data1.encode()).hexdigest()
signature2 = hashlib.sha256(data2.encode()).hexdigest()
assert signature1 != signature2
# ============================================================================
# Test: Statistics Calculation
# ============================================================================
class TestStatisticsCalculation:
"""Tests for audit statistics calculation."""
def test_statistics_counts_by_result(self):
"""Statistics should correctly count by result type."""
signoffs = [
MagicMock(result=AuditResultEnum.COMPLIANT),
MagicMock(result=AuditResultEnum.COMPLIANT),
MagicMock(result=AuditResultEnum.COMPLIANT_WITH_NOTES),
MagicMock(result=AuditResultEnum.NON_COMPLIANT),
MagicMock(result=AuditResultEnum.NOT_APPLICABLE),
]
compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT)
compliant_notes = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES)
non_compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT)
not_applicable = sum(1 for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE)
assert compliant == 2
assert compliant_notes == 1
assert non_compliant == 1
assert not_applicable == 1
def test_statistics_pending_calculation(self):
"""Pending count should be total minus reviewed."""
total_items = 100
reviewed_items = 75
pending = total_items - reviewed_items
assert pending == 25