fix(quality): Ruff/CVE/TS-Fixes, 104 neue Tests, Complexity-Refactoring
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s

- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell
- CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns)
- TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes
- Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed
- Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A)
- Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-07 19:00:33 +01:00
parent 6509e64dd9
commit 95fcba34cd
124 changed files with 2533 additions and 15709 deletions
@@ -36,7 +36,6 @@ async def list_ai_systems(
db: Session = Depends(get_db),
):
"""List all registered AI systems."""
import uuid as _uuid
query = db.query(AISystemDB)
if classification:
@@ -88,7 +87,6 @@ async def create_ai_system(
):
"""Register a new AI system."""
import uuid as _uuid
from datetime import datetime
try:
cls_enum = AIClassificationEnum(data.classification) if data.classification else AIClassificationEnum.UNCLASSIFIED
@@ -26,7 +26,7 @@ from ..db.models import (
)
from .schemas import (
CreateAuditSessionRequest, AuditSessionResponse, AuditSessionSummary, AuditSessionDetailResponse,
AuditSessionListResponse, SignOffRequest, SignOffResponse,
SignOffRequest, SignOffResponse,
AuditChecklistItem, AuditChecklistResponse, AuditStatistics,
PaginationMeta,
)
@@ -164,7 +164,7 @@ async def get_audit_session(
completion_percentage=session.completion_percentage,
)
return AuditSessionDetail(
return AuditSessionDetailResponse(
id=session.id,
name=session.name,
description=session.description,
@@ -12,7 +12,6 @@ from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.banner_models import (
@@ -317,12 +316,12 @@ async def get_site_config(
categories = db.query(BannerCategoryConfigDB).filter(
BannerCategoryConfigDB.site_config_id == config.id,
BannerCategoryConfigDB.is_active == True,
BannerCategoryConfigDB.is_active,
).order_by(BannerCategoryConfigDB.sort_order).all()
vendors = db.query(BannerVendorConfigDB).filter(
BannerVendorConfigDB.site_config_id == config.id,
BannerVendorConfigDB.is_active == True,
BannerVendorConfigDB.is_active,
).all()
result = _site_config_to_dict(config)
@@ -96,8 +96,8 @@ def generate_change_requests_for_use_case(
trigger_type="use_case_high_risk",
target_document_type="dsfa",
proposal_title=f"DSFA erstellen für '{title}' (Risiko: {risk_level})",
proposal_body=f"Ein neuer Use Case mit hohem Risiko wurde erstellt. "
f"Art. 35 DSGVO verlangt eine DSFA für Hochrisiko-Verarbeitungen.",
proposal_body="Ein neuer Use Case mit hohem Risiko wurde erstellt. "
"Art. 35 DSGVO verlangt eine DSFA für Hochrisiko-Verarbeitungen.",
proposed_changes={
"source": "use_case",
"title": title,
@@ -14,8 +14,7 @@ Endpoints:
import json
import logging
from datetime import datetime
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
@@ -11,7 +11,6 @@ Endpoints:
import json
import logging
import uuid
from typing import Optional
from fastapi import APIRouter, HTTPException, Header
@@ -127,16 +126,68 @@ class AuditListResponse(BaseModel):
# SQL column lists — keep in sync with SELECT/INSERT
# =============================================================================
_BASE_COLUMNS = """id, tenant_id, company_name, legal_form, industry, founded_year,
business_model, offerings, company_size, employee_count, annual_revenue,
headquarters_country, headquarters_city, has_international_locations,
international_countries, target_markets, primary_jurisdiction,
is_data_controller, is_data_processor, uses_ai, ai_use_cases,
dpo_name, dpo_email, legal_contact_name, legal_contact_email,
machine_builder, is_complete, completed_at, created_at, updated_at,
repos, document_sources, processing_systems, ai_systems, technical_contacts,
subject_to_nis2, subject_to_ai_act, subject_to_iso27001,
supervisory_authority, review_cycle_months"""
_BASE_COLUMNS_LIST = [
"id", "tenant_id", "company_name", "legal_form", "industry", "founded_year",
"business_model", "offerings", "company_size", "employee_count", "annual_revenue",
"headquarters_country", "headquarters_city", "has_international_locations",
"international_countries", "target_markets", "primary_jurisdiction",
"is_data_controller", "is_data_processor", "uses_ai", "ai_use_cases",
"dpo_name", "dpo_email", "legal_contact_name", "legal_contact_email",
"machine_builder", "is_complete", "completed_at", "created_at", "updated_at",
"repos", "document_sources", "processing_systems", "ai_systems", "technical_contacts",
"subject_to_nis2", "subject_to_ai_act", "subject_to_iso27001",
"supervisory_authority", "review_cycle_months",
]
_BASE_COLUMNS = ", ".join(_BASE_COLUMNS_LIST)
# Per-field defaults and type coercions for row_to_response.
# Each entry is (field_name, default_value, expected_type_or_None).
# - expected_type: if set, the value is checked with isinstance; if it fails,
# default_value is used instead.
# - Special sentinels: "STR" means str(value), "STR_OR_NONE" means str(v) if v else None.
_FIELD_DEFAULTS = {
"id": (None, "STR"),
"tenant_id": (None, None),
"company_name": ("", None),
"legal_form": ("GmbH", None),
"industry": ("", None),
"founded_year": (None, None),
"business_model": ("B2B", None),
"offerings": ([], list),
"company_size": ("small", None),
"employee_count": ("1-9", None),
"annual_revenue": ("< 2 Mio", None),
"headquarters_country": ("DE", None),
"headquarters_city": ("", None),
"has_international_locations": (False, None),
"international_countries": ([], list),
"target_markets": (["DE"], list),
"primary_jurisdiction": ("DE", None),
"is_data_controller": (True, None),
"is_data_processor": (False, None),
"uses_ai": (False, None),
"ai_use_cases": ([], list),
"dpo_name": (None, None),
"dpo_email": (None, None),
"legal_contact_name": (None, None),
"legal_contact_email": (None, None),
"machine_builder": (None, dict),
"is_complete": (False, None),
"completed_at": (None, "STR_OR_NONE"),
"created_at": (None, "STR"),
"updated_at": (None, "STR"),
"repos": ([], list),
"document_sources": ([], list),
"processing_systems": ([], list),
"ai_systems": ([], list),
"technical_contacts": ([], list),
"subject_to_nis2": (False, None),
"subject_to_ai_act": (False, None),
"subject_to_iso27001": (False, None),
"supervisory_authority": (None, None),
"review_cycle_months": (12, None),
}
# =============================================================================
@@ -144,50 +195,29 @@ _BASE_COLUMNS = """id, tenant_id, company_name, legal_form, industry, founded_ye
# =============================================================================
def row_to_response(row) -> CompanyProfileResponse:
"""Convert a DB row to response model."""
return CompanyProfileResponse(
id=str(row[0]),
tenant_id=row[1],
company_name=row[2] or "",
legal_form=row[3] or "GmbH",
industry=row[4] or "",
founded_year=row[5],
business_model=row[6] or "B2B",
offerings=row[7] if isinstance(row[7], list) else [],
company_size=row[8] or "small",
employee_count=row[9] or "1-9",
annual_revenue=row[10] or "< 2 Mio",
headquarters_country=row[11] or "DE",
headquarters_city=row[12] or "",
has_international_locations=row[13] or False,
international_countries=row[14] if isinstance(row[14], list) else [],
target_markets=row[15] if isinstance(row[15], list) else ["DE"],
primary_jurisdiction=row[16] or "DE",
is_data_controller=row[17] if row[17] is not None else True,
is_data_processor=row[18] or False,
uses_ai=row[19] or False,
ai_use_cases=row[20] if isinstance(row[20], list) else [],
dpo_name=row[21],
dpo_email=row[22],
legal_contact_name=row[23],
legal_contact_email=row[24],
machine_builder=row[25] if isinstance(row[25], dict) else None,
is_complete=row[26] or False,
completed_at=str(row[27]) if row[27] else None,
created_at=str(row[28]),
updated_at=str(row[29]),
# Phase 2 fields (indices 30-39)
repos=row[30] if isinstance(row[30], list) else [],
document_sources=row[31] if isinstance(row[31], list) else [],
processing_systems=row[32] if isinstance(row[32], list) else [],
ai_systems=row[33] if isinstance(row[33], list) else [],
technical_contacts=row[34] if isinstance(row[34], list) else [],
subject_to_nis2=row[35] or False,
subject_to_ai_act=row[36] or False,
subject_to_iso27001=row[37] or False,
supervisory_authority=row[38],
review_cycle_months=row[39] or 12,
)
"""Convert a DB row to response model using zip-based column mapping."""
raw = dict(zip(_BASE_COLUMNS_LIST, row))
coerced: dict = {}
for col in _BASE_COLUMNS_LIST:
default, expected_type = _FIELD_DEFAULTS[col]
value = raw[col]
if expected_type == "STR":
coerced[col] = str(value)
elif expected_type == "STR_OR_NONE":
coerced[col] = str(value) if value else None
elif expected_type is not None:
# Type-checked field (list / dict): use value only if it matches
coerced[col] = value if isinstance(value, expected_type) else default
else:
# is_data_controller needs special None-check (True when NULL)
if col == "is_data_controller":
coerced[col] = value if value is not None else default
else:
coerced[col] = value or default if default is not None else value
return CompanyProfileResponse(**coerced)
def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], changed_by: Optional[str]):
@@ -12,7 +12,7 @@ Endpoints:
import logging
from datetime import datetime
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
@@ -21,7 +21,7 @@ Usage:
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Callable
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
@@ -42,7 +42,7 @@ def generate_loeschfristen_drafts(ctx: dict) -> list[dict]:
"responsible": ctx.get("dpo_name", "DSB"),
"status": "draft",
"review_cycle_months": ctx.get("review_cycle_months", 12),
"notes": f"Automatisch generiert aus Stammdaten. Bitte prüfen und anpassen.",
"notes": "Automatisch generiert aus Stammdaten. Bitte prüfen und anpassen.",
}
policies.append(policy)
@@ -51,7 +51,6 @@ def generate_tom_drafts(ctx: dict) -> list[dict]:
measures.extend(_AI_ACT_TOMS)
# Enrich with metadata
company = ctx.get("company_name", "")
result = []
for i, m in enumerate(measures, 1):
result.append({
@@ -33,7 +33,6 @@ from classroom_engine.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dsfa", tags=["compliance-dsfa"])
from .tenant_utils import get_tenant_id as _shared_get_tenant_id
# Legacy compat — still used by _get_tenant_id() below; will be removed once
# all call-sites switch to Depends(get_tenant_id).
@@ -855,7 +854,7 @@ async def approve_dsfa(
if request.approved:
new_status = "approved"
row = db.execute(
db.execute(
text("""
UPDATE compliance_dsfas
SET status = 'approved', approved_by = :approved_by, approved_at = NOW(), updated_at = NOW()
@@ -866,7 +865,7 @@ async def approve_dsfa(
).fetchone()
else:
new_status = "needs-update"
row = db.execute(
db.execute(
text("""
UPDATE compliance_dsfas
SET status = 'needs-update', updated_at = NOW()
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text, func, and_, or_, cast, String
from sqlalchemy import text, func, and_, or_
from classroom_engine.database import get_db
from ..db.dsr_models import (
@@ -574,7 +574,7 @@ async def get_published_templates(
"""Gibt publizierte Vorlagen zurueck."""
query = db.query(DSRTemplateDB).filter(
DSRTemplateDB.tenant_id == uuid.UUID(tenant_id),
DSRTemplateDB.is_active == True,
DSRTemplateDB.is_active,
DSRTemplateDB.language == language,
)
if request_type:
@@ -6,14 +6,12 @@ Inklusive Versionierung, Approval-Workflow, Vorschau und Send-Logging.
"""
import uuid
import re
from datetime import datetime
from typing import Optional, List, Dict, Any
from typing import Optional, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.email_template_models import (
@@ -182,7 +180,7 @@ async def get_stats(
base = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid)
total = base.count()
active = base.filter(EmailTemplateDB.is_active == True).count()
active = base.filter(EmailTemplateDB.is_active).count()
# Count templates with published versions
published_count = 0
@@ -248,7 +248,231 @@ async def upload_evidence(
# ============================================================================
# CI/CD Evidence Collection
# CI/CD Evidence Collection — helpers
# ============================================================================
# Map CI source names to the corresponding control IDs
SOURCE_CONTROL_MAP = {
"sast": "SDLC-001",
"dependency_scan": "SDLC-002",
"secret_scan": "SDLC-003",
"code_review": "SDLC-004",
"sbom": "SDLC-005",
"container_scan": "SDLC-006",
"test_results": "AUD-001",
}
def _parse_ci_evidence(data: dict) -> dict:
"""
Parse and validate incoming CI evidence data.
Returns a dict with:
- report_json: str (serialised JSON)
- report_hash: str (SHA-256 hex digest)
- evidence_status: str ("valid" or "failed")
- findings_count: int
- critical_findings: int
"""
report_json = json.dumps(data) if data else "{}"
report_hash = hashlib.sha256(report_json.encode()).hexdigest()
findings_count = 0
critical_findings = 0
if data and isinstance(data, dict):
# Semgrep format
if "results" in data:
findings_count = len(data.get("results", []))
critical_findings = len([
r for r in data.get("results", [])
if r.get("extra", {}).get("severity", "").upper() in ["CRITICAL", "HIGH"]
])
# Trivy format
elif "Results" in data:
for result in data.get("Results", []):
vulns = result.get("Vulnerabilities", [])
findings_count += len(vulns)
critical_findings += len([
v for v in vulns
if v.get("Severity", "").upper() in ["CRITICAL", "HIGH"]
])
# Generic findings array
elif "findings" in data:
findings_count = len(data.get("findings", []))
# SBOM format - just count components
elif "components" in data:
findings_count = len(data.get("components", []))
evidence_status = "failed" if critical_findings > 0 else "valid"
return {
"report_json": report_json,
"report_hash": report_hash,
"evidence_status": evidence_status,
"findings_count": findings_count,
"critical_findings": critical_findings,
}
def _store_evidence(
db: Session,
*,
control_db_id: str,
source: str,
parsed: dict,
ci_job_id: str,
ci_job_url: str,
report_data: dict,
) -> EvidenceDB:
"""
Persist a CI evidence item to the database and write the report file.
Returns the created EvidenceDB instance (already committed).
"""
findings_count = parsed["findings_count"]
critical_findings = parsed["critical_findings"]
# Build title and description
title = f"{source.upper()} Report - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
description = "Automatically collected from CI/CD pipeline"
if findings_count > 0:
description += f"\n- Total findings: {findings_count}"
if critical_findings > 0:
description += f"\n- Critical/High findings: {critical_findings}"
if ci_job_id:
description += f"\n- CI Job ID: {ci_job_id}"
if ci_job_url:
description += f"\n- CI Job URL: {ci_job_url}"
# Store report file
upload_dir = f"/tmp/compliance_evidence/ci/{source}"
os.makedirs(upload_dir, exist_ok=True)
file_name = f"{source}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{parsed['report_hash'][:8]}.json"
file_path = os.path.join(upload_dir, file_name)
with open(file_path, "w") as f:
json.dump(report_data or {}, f, indent=2)
# Create evidence record
evidence = EvidenceDB(
id=str(uuid_module.uuid4()),
control_id=control_db_id,
evidence_type=f"ci_{source}",
title=title,
description=description,
artifact_path=file_path,
artifact_hash=parsed["report_hash"],
file_size_bytes=len(parsed["report_json"]),
mime_type="application/json",
source="ci_pipeline",
ci_job_id=ci_job_id,
valid_from=datetime.utcnow(),
valid_until=datetime.utcnow() + timedelta(days=90),
status=EvidenceStatusEnum(parsed["evidence_status"]),
)
db.add(evidence)
db.commit()
db.refresh(evidence)
return evidence
def _extract_findings_detail(report_data: dict) -> dict:
"""
Extract severity-bucketed finding counts from report data.
Returns dict with keys: critical, high, medium, low.
"""
findings_detail = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
}
if not report_data:
return findings_detail
# Semgrep format
if "results" in report_data:
for r in report_data.get("results", []):
severity = r.get("extra", {}).get("severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
elif severity in ["LOW", "INFO"]:
findings_detail["low"] += 1
# Trivy format
elif "Results" in report_data:
for result in report_data.get("Results", []):
for v in result.get("Vulnerabilities", []):
severity = v.get("Severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
elif severity == "LOW":
findings_detail["low"] += 1
# Generic findings with severity
elif "findings" in report_data:
for f in report_data.get("findings", []):
severity = f.get("severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
else:
findings_detail["low"] += 1
return findings_detail
def _update_risks(db: Session, *, source: str, control_id: str, ci_job_id: str, report_data: dict):
"""
Update risk status based on new evidence.
Uses AutoRiskUpdater to update Control status and linked Risks based on
severity-bucketed findings. Returns the update result or None on error.
"""
findings_detail = _extract_findings_detail(report_data)
try:
auto_updater = AutoRiskUpdater(db)
risk_update_result = auto_updater.process_evidence_collect_request(
tool=source,
control_id=control_id,
evidence_type=f"ci_{source}",
timestamp=datetime.utcnow().isoformat(),
commit_sha=report_data.get("commit_sha", "unknown") if report_data else "unknown",
ci_job_id=ci_job_id,
findings=findings_detail,
)
logger.info(f"Auto-risk update completed for {control_id}: "
f"control_updated={risk_update_result.control_updated}, "
f"risks_affected={len(risk_update_result.risks_affected)}")
return risk_update_result
except Exception as e:
logger.error(f"Auto-risk update failed for {control_id}: {str(e)}")
return None
# ============================================================================
# CI/CD Evidence Collection — endpoint
# ============================================================================
@router.post("/evidence/collect")
@@ -274,17 +498,6 @@ async def collect_ci_evidence(
- secret_scan: Secret detection (Gitleaks, TruffleHog)
- code_review: Code review metrics
"""
# Map source to control_id
SOURCE_CONTROL_MAP = {
"sast": "SDLC-001",
"dependency_scan": "SDLC-002",
"secret_scan": "SDLC-003",
"code_review": "SDLC-004",
"sbom": "SDLC-005",
"container_scan": "SDLC-006",
"test_results": "AUD-001",
}
if source not in SOURCE_CONTROL_MAP:
raise HTTPException(
status_code=400,
@@ -302,173 +515,38 @@ async def collect_ci_evidence(
detail=f"Control {control_id} not found. Please seed the database first."
)
# Parse and validate report data
report_json = json.dumps(report_data) if report_data else "{}"
report_hash = hashlib.sha256(report_json.encode()).hexdigest()
# --- 1. Parse and validate report data ---
parsed = _parse_ci_evidence(report_data)
# Determine evidence status based on report content
evidence_status = "valid"
findings_count = 0
critical_findings = 0
if report_data:
# Try to extract findings from common report formats
if isinstance(report_data, dict):
# Semgrep format
if "results" in report_data:
findings_count = len(report_data.get("results", []))
critical_findings = len([
r for r in report_data.get("results", [])
if r.get("extra", {}).get("severity", "").upper() in ["CRITICAL", "HIGH"]
])
# Trivy format
elif "Results" in report_data:
for result in report_data.get("Results", []):
vulns = result.get("Vulnerabilities", [])
findings_count += len(vulns)
critical_findings += len([
v for v in vulns
if v.get("Severity", "").upper() in ["CRITICAL", "HIGH"]
])
# Generic findings array
elif "findings" in report_data:
findings_count = len(report_data.get("findings", []))
# SBOM format - just count components
elif "components" in report_data:
findings_count = len(report_data.get("components", []))
# If critical findings exist, mark as failed
if critical_findings > 0:
evidence_status = "failed"
# Create evidence title
title = f"{source.upper()} Report - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
description = f"Automatically collected from CI/CD pipeline"
if findings_count > 0:
description += f"\n- Total findings: {findings_count}"
if critical_findings > 0:
description += f"\n- Critical/High findings: {critical_findings}"
if ci_job_id:
description += f"\n- CI Job ID: {ci_job_id}"
if ci_job_url:
description += f"\n- CI Job URL: {ci_job_url}"
# Store report file
upload_dir = f"/tmp/compliance_evidence/ci/{source}"
os.makedirs(upload_dir, exist_ok=True)
file_name = f"{source}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{report_hash[:8]}.json"
file_path = os.path.join(upload_dir, file_name)
with open(file_path, "w") as f:
json.dump(report_data or {}, f, indent=2)
# Create evidence record directly
evidence = EvidenceDB(
id=str(uuid_module.uuid4()),
control_id=control.id,
evidence_type=f"ci_{source}",
title=title,
description=description,
artifact_path=file_path,
artifact_hash=report_hash,
file_size_bytes=len(report_json),
mime_type="application/json",
source="ci_pipeline",
# --- 2. Store evidence in DB and write report file ---
evidence = _store_evidence(
db,
control_db_id=control.id,
source=source,
parsed=parsed,
ci_job_id=ci_job_id,
valid_from=datetime.utcnow(),
valid_until=datetime.utcnow() + timedelta(days=90),
status=EvidenceStatusEnum(evidence_status),
ci_job_url=ci_job_url,
report_data=report_data,
)
db.add(evidence)
db.commit()
db.refresh(evidence)
# =========================================================================
# AUTOMATIC RISK UPDATE
# Update Control status and linked Risks based on findings
# =========================================================================
risk_update_result = None
try:
# Extract detailed findings for risk assessment
findings_detail = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
}
if report_data:
# Semgrep format
if "results" in report_data:
for r in report_data.get("results", []):
severity = r.get("extra", {}).get("severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
elif severity in ["LOW", "INFO"]:
findings_detail["low"] += 1
# Trivy format
elif "Results" in report_data:
for result in report_data.get("Results", []):
for v in result.get("Vulnerabilities", []):
severity = v.get("Severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
elif severity == "LOW":
findings_detail["low"] += 1
# Generic findings with severity
elif "findings" in report_data:
for f in report_data.get("findings", []):
severity = f.get("severity", "").upper()
if severity == "CRITICAL":
findings_detail["critical"] += 1
elif severity == "HIGH":
findings_detail["high"] += 1
elif severity == "MEDIUM":
findings_detail["medium"] += 1
else:
findings_detail["low"] += 1
# Use AutoRiskUpdater to update Control status and Risks
auto_updater = AutoRiskUpdater(db)
risk_update_result = auto_updater.process_evidence_collect_request(
tool=source,
control_id=control_id,
evidence_type=f"ci_{source}",
timestamp=datetime.utcnow().isoformat(),
commit_sha=report_data.get("commit_sha", "unknown") if report_data else "unknown",
ci_job_id=ci_job_id,
findings=findings_detail,
)
logger.info(f"Auto-risk update completed for {control_id}: "
f"control_updated={risk_update_result.control_updated}, "
f"risks_affected={len(risk_update_result.risks_affected)}")
except Exception as e:
logger.error(f"Auto-risk update failed for {control_id}: {str(e)}")
# --- 3. Automatic risk update ---
risk_update_result = _update_risks(
db,
source=source,
control_id=control_id,
ci_job_id=ci_job_id,
report_data=report_data,
)
return {
"success": True,
"evidence_id": evidence.id,
"control_id": control_id,
"source": source,
"status": evidence_status,
"findings_count": findings_count,
"critical_findings": critical_findings,
"artifact_path": file_path,
"status": parsed["evidence_status"],
"findings_count": parsed["findings_count"],
"critical_findings": parsed["critical_findings"],
"artifact_path": evidence.artifact_path,
"message": f"Evidence collected successfully for control {control_id}",
"auto_risk_update": {
"enabled": True,
@@ -20,13 +20,13 @@ import asyncio
from typing import Optional, List, Dict
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db import RegulationRepository, RequirementRepository
from ..db.models import RegulationDB, RequirementDB, RegulationTypeEnum
from ..db.models import RegulationDB, RegulationTypeEnum
from ..services.rag_client import get_rag_client, RAGSearchResult
logger = logging.getLogger(__name__)
@@ -185,6 +185,169 @@ def _build_existing_articles(
return {r.article for r in existing}
# ---------------------------------------------------------------------------
# Extraction helpers — independently testable
# ---------------------------------------------------------------------------
def _parse_rag_results(
all_results: List[RAGSearchResult],
regulation_codes: Optional[List[str]] = None,
) -> dict:
"""
Filter, deduplicate, and group RAG search results by regulation code.
Returns a dict with:
- deduped_by_reg: Dict[str, List[tuple[str, RAGSearchResult]]]
- skipped_no_article: List[RAGSearchResult]
- unique_count: int
"""
# Filter by regulation_codes if requested
if regulation_codes:
all_results = [
r for r in all_results
if r.regulation_code in regulation_codes
]
# Deduplicate at result level (regulation_code + article)
seen: set[tuple[str, str]] = set()
unique_count = 0
for r in sorted(all_results, key=lambda x: x.score, reverse=True):
article = _normalize_article(r)
if not article:
continue
key = (r.regulation_code, article)
if key not in seen:
seen.add(key)
unique_count += 1
# Group by regulation_code
by_reg: Dict[str, List[tuple[str, RAGSearchResult]]] = {}
skipped_no_article: List[RAGSearchResult] = []
for r in all_results:
article = _normalize_article(r)
if not article:
skipped_no_article.append(r)
continue
key_r = r.regulation_code or "UNKNOWN"
if key_r not in by_reg:
by_reg[key_r] = []
by_reg[key_r].append((article, r))
# Deduplicate within groups
deduped_by_reg: Dict[str, List[tuple[str, RAGSearchResult]]] = {}
for reg_code, items in by_reg.items():
seen_articles: set[str] = set()
deduped: List[tuple[str, RAGSearchResult]] = []
for art, r in sorted(items, key=lambda x: x[1].score, reverse=True):
if art not in seen_articles:
seen_articles.add(art)
deduped.append((art, r))
deduped_by_reg[reg_code] = deduped
return {
"deduped_by_reg": deduped_by_reg,
"skipped_no_article": skipped_no_article,
"unique_count": unique_count,
}
def _store_requirements(
db: Session,
deduped_by_reg: Dict[str, List[tuple[str, "RAGSearchResult"]]],
dry_run: bool,
) -> dict:
"""
Persist extracted requirements to the database (or simulate in dry_run mode).
Returns a dict with:
- created_count: int
- skipped_dup_count: int
- failed_count: int
- result_items: List[ExtractedRequirement]
"""
req_repo = RequirementRepository(db)
created_count = 0
skipped_dup_count = 0
failed_count = 0
result_items: List[ExtractedRequirement] = []
for reg_code, items in deduped_by_reg.items():
if not items:
continue
# Find or create regulation
try:
first_result = items[0][1]
regulation_name = first_result.regulation_name or first_result.regulation_short or reg_code
if dry_run:
# For dry_run, fake a regulation id
regulation_id = f"dry-run-{reg_code}"
existing_articles: set[str] = set()
else:
reg = _get_or_create_regulation(db, reg_code, regulation_name)
regulation_id = reg.id
existing_articles = _build_existing_articles(db, regulation_id)
except Exception as e:
logger.error("Failed to get/create regulation %s: %s", reg_code, e)
failed_count += len(items)
continue
for article, r in items:
title = _derive_title(r.text, article)
if article in existing_articles:
skipped_dup_count += 1
result_items.append(ExtractedRequirement(
regulation_code=reg_code,
article=article,
title=title,
requirement_text=r.text[:1000],
source_url=r.source_url,
score=r.score,
action="skipped_duplicate",
))
continue
if not dry_run:
try:
req_repo.create(
regulation_id=regulation_id,
article=article,
title=title,
description=f"Extrahiert aus RAG-Korpus (Collection: {r.category or r.regulation_code}). Score: {r.score:.2f}",
requirement_text=r.text[:2000],
breakpilot_interpretation=None,
is_applicable=True,
priority=2,
)
existing_articles.add(article) # prevent intra-batch duplication
created_count += 1
except Exception as e:
logger.error("Failed to create requirement %s/%s: %s", reg_code, article, e)
failed_count += 1
continue
else:
created_count += 1 # dry_run: count as would-create
result_items.append(ExtractedRequirement(
regulation_code=reg_code,
article=article,
title=title,
requirement_text=r.text[:1000],
source_url=r.source_url,
score=r.score,
action="created" if not dry_run else "would_create",
))
return {
"created_count": created_count,
"skipped_dup_count": skipped_dup_count,
"failed_count": failed_count,
"result_items": result_items,
}
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@@ -225,126 +388,19 @@ async def extract_requirements_from_rag(
logger.info("RAG extraction: %d raw results from %d collections", len(all_results), len(collections))
# --- 2. Filter by regulation_codes if requested ---
if body.regulation_codes:
all_results = [
r for r in all_results
if r.regulation_code in body.regulation_codes
]
# --- 2. Parse, filter, deduplicate, and group ---
parsed = _parse_rag_results(all_results, body.regulation_codes)
deduped_by_reg = parsed["deduped_by_reg"]
skipped_no_article = parsed["skipped_no_article"]
# --- 3. Deduplicate at result level (regulation_code + article) ---
seen: set[tuple[str, str]] = set()
unique_results: List[RAGSearchResult] = []
for r in sorted(all_results, key=lambda x: x.score, reverse=True):
article = _normalize_article(r)
if not article:
continue
key = (r.regulation_code, article)
if key not in seen:
seen.add(key)
unique_results.append(r)
logger.info("RAG extraction: %d unique (regulation, article) pairs", parsed["unique_count"])
logger.info("RAG extraction: %d unique (regulation, article) pairs", len(unique_results))
# --- 4. Group by regulation_code and process ---
by_reg: Dict[str, List[tuple[str, RAGSearchResult]]] = {}
skipped_no_article: List[RAGSearchResult] = []
for r in all_results:
article = _normalize_article(r)
if not article:
skipped_no_article.append(r)
continue
key_r = r.regulation_code or "UNKNOWN"
if key_r not in by_reg:
by_reg[key_r] = []
by_reg[key_r].append((article, r))
# Deduplicate within groups
deduped_by_reg: Dict[str, List[tuple[str, RAGSearchResult]]] = {}
for reg_code, items in by_reg.items():
seen_articles: set[str] = set()
deduped: List[tuple[str, RAGSearchResult]] = []
for art, r in sorted(items, key=lambda x: x[1].score, reverse=True):
if art not in seen_articles:
seen_articles.add(art)
deduped.append((art, r))
deduped_by_reg[reg_code] = deduped
# --- 5. Create requirements ---
req_repo = RequirementRepository(db)
created_count = 0
skipped_dup_count = 0
failed_count = 0
result_items: List[ExtractedRequirement] = []
for reg_code, items in deduped_by_reg.items():
if not items:
continue
# Find or create regulation
try:
first_result = items[0][1]
regulation_name = first_result.regulation_name or first_result.regulation_short or reg_code
if body.dry_run:
# For dry_run, fake a regulation id
regulation_id = f"dry-run-{reg_code}"
existing_articles: set[str] = set()
else:
reg = _get_or_create_regulation(db, reg_code, regulation_name)
regulation_id = reg.id
existing_articles = _build_existing_articles(db, regulation_id)
except Exception as e:
logger.error("Failed to get/create regulation %s: %s", reg_code, e)
failed_count += len(items)
continue
for article, r in items:
title = _derive_title(r.text, article)
if article in existing_articles:
skipped_dup_count += 1
result_items.append(ExtractedRequirement(
regulation_code=reg_code,
article=article,
title=title,
requirement_text=r.text[:1000],
source_url=r.source_url,
score=r.score,
action="skipped_duplicate",
))
continue
if not body.dry_run:
try:
req_repo.create(
regulation_id=regulation_id,
article=article,
title=title,
description=f"Extrahiert aus RAG-Korpus (Collection: {r.category or r.regulation_code}). Score: {r.score:.2f}",
requirement_text=r.text[:2000],
breakpilot_interpretation=None,
is_applicable=True,
priority=2,
)
existing_articles.add(article) # prevent intra-batch duplication
created_count += 1
except Exception as e:
logger.error("Failed to create requirement %s/%s: %s", reg_code, article, e)
failed_count += 1
continue
else:
created_count += 1 # dry_run: count as would-create
result_items.append(ExtractedRequirement(
regulation_code=reg_code,
article=article,
title=title,
requirement_text=r.text[:1000],
source_url=r.source_url,
score=r.score,
action="created" if not body.dry_run else "would_create",
))
# --- 3. Create requirements ---
store_result = _store_requirements(db, deduped_by_reg, body.dry_run)
created_count = store_result["created_count"]
skipped_dup_count = store_result["skipped_dup_count"]
failed_count = store_result["failed_count"]
result_items = store_result["result_items"]
message = (
f"{'[DRY RUN] ' if body.dry_run else ''}"
@@ -24,7 +24,7 @@ Endpoints:
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Any
from typing import Optional, List
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Header
@@ -14,7 +14,7 @@ Provides endpoints for ISO 27001 certification-ready ISMS management:
import uuid
import hashlib
from datetime import datetime, date
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.orm import Session
@@ -53,7 +53,7 @@ from .schemas import (
# Readiness
ISMSReadinessCheckResponse, ISMSReadinessCheckRequest, PotentialFinding,
# Audit Trail
AuditTrailResponse, AuditTrailEntry, PaginationMeta,
AuditTrailResponse, PaginationMeta,
# Overview
ISO27001OverviewResponse, ISO27001ChapterStatus
)
@@ -673,10 +673,6 @@ async def list_findings(
ofi_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.OFI)
open_count = sum(1 for f in findings if f.status != FindingStatusEnum.CLOSED)
# Add is_blocking property to each finding
for f in findings:
f.is_blocking = f.finding_type == FindingTypeEnum.MAJOR and f.status != FindingStatusEnum.CLOSED
return AuditFindingListResponse(
findings=findings,
total=len(findings),
@@ -746,7 +742,6 @@ async def create_finding(data: AuditFindingCreate, db: Session = Depends(get_db)
db.commit()
db.refresh(finding)
finding.is_blocking = finding.finding_type == FindingTypeEnum.MAJOR
return finding
@@ -775,7 +770,6 @@ async def update_finding(
db.commit()
db.refresh(finding)
finding.is_blocking = finding.finding_type == FindingTypeEnum.MAJOR and finding.status != FindingStatusEnum.CLOSED
return finding
@@ -824,7 +818,6 @@ async def close_finding(
db.commit()
db.refresh(finding)
finding.is_blocking = False
return finding
@@ -1271,10 +1264,9 @@ async def run_readiness_check(
# Chapter 6: Planning - Risk Assessment
from ..db.models import RiskDB
risks = db.query(RiskDB).filter(RiskDB.status == "open").count()
risks_without_treatment = db.query(RiskDB).filter(
RiskDB.status == "open",
RiskDB.treatment_plan == None
RiskDB.treatment_plan is None
).count()
if risks_without_treatment > 0:
potential_majors.append(PotentialFinding(
@@ -1299,7 +1291,7 @@ async def run_readiness_check(
# SoA
soa_total = db.query(StatementOfApplicabilityDB).count()
soa_unapproved = db.query(StatementOfApplicabilityDB).filter(
StatementOfApplicabilityDB.approved_at == None
StatementOfApplicabilityDB.approved_at is None
).count()
if soa_total == 0:
potential_majors.append(PotentialFinding(
@@ -1525,7 +1517,7 @@ async def get_iso27001_overview(db: Session = Depends(get_db)):
soa_total = db.query(StatementOfApplicabilityDB).count()
soa_approved = db.query(StatementOfApplicabilityDB).filter(
StatementOfApplicabilityDB.approved_at != None
StatementOfApplicabilityDB.approved_at is not None
).count()
soa_all_approved = soa_total > 0 and soa_approved == soa_total
@@ -671,7 +671,7 @@ async def get_my_consents(
.filter(
UserConsentDB.tenant_id == tid,
UserConsentDB.user_id == user_id,
UserConsentDB.withdrawn_at == None,
UserConsentDB.withdrawn_at is None,
)
.order_by(UserConsentDB.consented_at.desc())
.all()
@@ -694,8 +694,8 @@ async def check_consent(
UserConsentDB.tenant_id == tid,
UserConsentDB.user_id == user_id,
UserConsentDB.document_type == document_type,
UserConsentDB.consented == True,
UserConsentDB.withdrawn_at == None,
UserConsentDB.consented,
UserConsentDB.withdrawn_at is None,
)
.order_by(UserConsentDB.consented_at.desc())
.first()
@@ -757,10 +757,10 @@ async def get_consent_stats(
total = base.count()
active = base.filter(
UserConsentDB.consented == True,
UserConsentDB.withdrawn_at == None,
UserConsentDB.consented,
UserConsentDB.withdrawn_at is None,
).count()
withdrawn = base.filter(UserConsentDB.withdrawn_at != None).count()
withdrawn = base.filter(UserConsentDB.withdrawn_at is not None).count()
# By document type
by_type = {}
@@ -314,9 +314,9 @@ async def update_legal_template(
raise HTTPException(status_code=400, detail="No fields to update")
if "document_type" in updates and updates["document_type"] not in VALID_DOCUMENT_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid document_type")
raise HTTPException(status_code=400, detail="Invalid document_type")
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status")
raise HTTPException(status_code=400, detail="Invalid status")
set_clauses = ["updated_at = :updated_at"]
params: Dict[str, Any] = {
+9 -17
View File
@@ -16,11 +16,10 @@ import logging
logger = logging.getLogger(__name__)
import os
from datetime import datetime, timedelta
from typing import Optional, List
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, BackgroundTasks
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -31,22 +30,16 @@ from ..db import (
RequirementRepository,
ControlRepository,
EvidenceRepository,
RiskRepository,
AuditExportRepository,
ControlStatusEnum,
ControlDomainEnum,
RiskLevelEnum,
EvidenceStatusEnum,
)
from ..db.models import EvidenceDB, ControlDB
from ..services.seeder import ComplianceSeeder
from ..services.export_generator import AuditExportGenerator
from ..services.auto_risk_updater import AutoRiskUpdater, ScanType
from .schemas import (
RegulationCreate, RegulationResponse, RegulationListResponse,
RegulationResponse, RegulationListResponse,
RequirementCreate, RequirementResponse, RequirementListResponse,
ControlCreate, ControlUpdate, ControlResponse, ControlListResponse, ControlReviewRequest,
MappingCreate, MappingResponse, MappingListResponse,
ControlUpdate, ControlResponse, ControlListResponse, ControlReviewRequest,
ExportRequest, ExportResponse, ExportListResponse,
SeedRequest, SeedResponse,
# Pagination schemas
@@ -381,7 +374,6 @@ async def delete_requirement(requirement_id: str, db: Session = Depends(get_db))
async def update_requirement(requirement_id: str, updates: dict, db: Session = Depends(get_db)):
"""Update a requirement with implementation/audit details."""
from ..db.models import RequirementDB
from datetime import datetime
requirement = db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
if not requirement:
@@ -870,8 +862,8 @@ async def init_tables(db: Session = Depends(get_db)):
"""Create compliance tables if they don't exist."""
from classroom_engine.database import engine
from ..db.models import (
RegulationDB, RequirementDB, ControlDB, ControlMappingDB,
EvidenceDB, RiskDB, AuditExportDB, AISystemDB
RegulationDB, RequirementDB, ControlMappingDB,
RiskDB, AuditExportDB, AISystemDB
)
try:
@@ -971,8 +963,8 @@ async def seed_database(
"""Seed the compliance database with initial data."""
from classroom_engine.database import engine
from ..db.models import (
RegulationDB, RequirementDB, ControlDB, ControlMappingDB,
EvidenceDB, RiskDB, AuditExportDB
RegulationDB, RequirementDB, ControlMappingDB,
RiskDB, AuditExportDB
)
try:
@@ -496,57 +496,6 @@ class SeedResponse(BaseModel):
counts: Dict[str, int]
# ============================================================================
# PDF Extraction Schemas
# ============================================================================
class BSIAspectResponse(BaseModel):
"""Response schema for an extracted BSI-TR Pruefaspekt."""
aspect_id: str
title: str
full_text: str
category: str
page_number: int
section: str
requirement_level: str
source_document: str
keywords: List[str] = []
related_aspects: List[str] = []
class PDFExtractionResponse(BaseModel):
"""Response for PDF extraction operation."""
success: bool
source_document: str
total_aspects: int
aspects: List[BSIAspectResponse]
statistics: Dict[str, Any]
requirements_created: int = 0
class PDFExtractionRequest(BaseModel):
"""Request to extract requirements from a PDF."""
document_code: str # e.g., "BSI-TR-03161-2"
save_to_db: bool = True
force: bool = False
# ============================================================================
# Paginated Response Schemas (after all Response classes are defined)
# ============================================================================
class PaginatedRequirementResponse(BaseModel):
"""Paginated response for requirements."""
data: List[RequirementResponse]
pagination: PaginationMeta
class PaginatedControlResponse(BaseModel):
"""Paginated response for controls."""
data: List[ControlResponse]
pagination: PaginationMeta
class PaginatedEvidenceResponse(BaseModel):
"""Paginated response for evidence."""
data: List[EvidenceResponse]
@@ -257,18 +257,6 @@ def map_osv_severity(vuln: dict) -> tuple[str, float]:
severity = "MEDIUM"
cvss = 5.0
# Check severity array
for sev in vuln.get("severity", []):
if sev.get("type") == "CVSS_V3":
score_str = sev.get("score", "")
# Extract base score from CVSS vector
try:
import re as _re
# CVSS vectors don't contain the score directly, try database_specific
pass
except Exception:
pass
# Check database_specific for severity
db_specific = vuln.get("database_specific", {})
if "severity" in db_specific:
@@ -21,9 +21,8 @@ Endpoints:
GET /api/v1/admin/compliance-report Compliance report
"""
import uuid
from datetime import datetime
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel, Field
@@ -155,7 +154,7 @@ async def list_sources(
"""List all allowed sources with optional filters."""
query = db.query(AllowedSourceDB)
if active_only:
query = query.filter(AllowedSourceDB.active == True)
query = query.filter(AllowedSourceDB.active)
if source_type:
query = query.filter(AllowedSourceDB.source_type == source_type)
if license:
@@ -527,8 +526,8 @@ async def get_policy_audit(
async def get_policy_stats(db: Session = Depends(get_db)):
"""Get dashboard statistics for source policy."""
total_sources = db.query(AllowedSourceDB).count()
active_sources = db.query(AllowedSourceDB).filter(AllowedSourceDB.active == True).count()
pii_rules = db.query(PIIRuleDB).filter(PIIRuleDB.active == True).count()
active_sources = db.query(AllowedSourceDB).filter(AllowedSourceDB.active).count()
pii_rules = db.query(PIIRuleDB).filter(PIIRuleDB.active).count()
# Count blocked content entries from today
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -550,8 +549,8 @@ async def get_policy_stats(db: Session = Depends(get_db)):
@router.get("/compliance-report")
async def get_compliance_report(db: Session = Depends(get_db)):
"""Generate a compliance report for source policies."""
sources = db.query(AllowedSourceDB).filter(AllowedSourceDB.active == True).all()
pii_rules = db.query(PIIRuleDB).filter(PIIRuleDB.active == True).all()
sources = db.query(AllowedSourceDB).filter(AllowedSourceDB.active).all()
pii_rules = db.query(PIIRuleDB).filter(PIIRuleDB.active).all()
return {
"report_date": datetime.utcnow().isoformat(),
@@ -19,11 +19,11 @@ import json
import logging
from datetime import datetime, timezone
from typing import Optional, List, Any, Dict
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -50,10 +50,9 @@ import json
import logging
import uuid
from datetime import datetime
from typing import Optional, List
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -7,7 +7,6 @@ with all 5 version tables (DSFA, VVT, TOM, Loeschfristen, Obligations).
import json
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -19,7 +19,6 @@ import io
import logging
from datetime import datetime, timezone
from typing import Optional, List
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
@@ -13,7 +13,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, Integer, DateTime, Index, JSON
Column, Text, Boolean, Integer, DateTime, Index, JSON
)
from sqlalchemy.dialects.postgresql import UUID
@@ -14,7 +14,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, DateTime, JSON, Index
Column, Text, Boolean, DateTime, JSON, Index
)
from sqlalchemy.dialects.postgresql import UUID
@@ -13,7 +13,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, DateTime, JSON, Index, Integer
Column, String, Text, Boolean, DateTime, JSON, Index
)
from sqlalchemy.dialects.postgresql import UUID
@@ -13,7 +13,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, Integer, DateTime, JSON, Index
Column, Text, Boolean, Integer, DateTime, JSON, Index
)
from sqlalchemy.dialects.postgresql import UUID
@@ -14,10 +14,9 @@ from datetime import datetime, date
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session as DBSession
from sqlalchemy import func, and_, or_
from .models import (
ISMSScopeDB, ISMSContextDB, ISMSPolicyDB, SecurityObjectiveDB,
ISMSScopeDB, ISMSPolicyDB, SecurityObjectiveDB,
StatementOfApplicabilityDB, AuditFindingDB, CorrectiveActionDB,
ManagementReviewDB, InternalAuditDB, AuditTrailDB, ISMSReadinessCheckDB,
ApprovalStatusEnum, FindingTypeEnum, FindingStatusEnum, CAPATypeEnum
@@ -11,7 +11,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, Integer, DateTime, Index, JSON
Column, Text, Boolean, Integer, DateTime, Index, JSON
)
from sqlalchemy.dialects.postgresql import UUID
@@ -14,7 +14,6 @@ Tables:
import enum
import uuid
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import (
Column, String, Text, Integer, Boolean, DateTime, Date,
@@ -3,6 +3,7 @@ Repository layer for Compliance module.
Provides CRUD operations and business logic queries for all compliance entities.
"""
from __future__ import annotations
import uuid
from datetime import datetime, date
@@ -17,7 +18,8 @@ from .models import (
EvidenceDB, RiskDB, AuditExportDB,
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
RegulationTypeEnum, ControlDomainEnum, ControlStatusEnum,
RiskLevelEnum, EvidenceStatusEnum, ExportStatusEnum
RiskLevelEnum, EvidenceStatusEnum, ExportStatusEnum,
ServiceModuleDB, ModuleRegulationMappingDB,
)
@@ -447,7 +449,7 @@ class ControlRepository:
self.db.query(ControlDB)
.filter(
or_(
ControlDB.next_review_at == None,
ControlDB.next_review_at is None,
ControlDB.next_review_at <= datetime.utcnow()
)
)
@@ -936,7 +938,7 @@ class ServiceModuleRepository:
"""Get all modules with filters."""
from .models import ServiceModuleDB, ServiceTypeEnum
query = self.db.query(ServiceModuleDB).filter(ServiceModuleDB.is_active == True)
query = self.db.query(ServiceModuleDB).filter(ServiceModuleDB.is_active)
if service_type:
query = query.filter(ServiceModuleDB.service_type == ServiceTypeEnum(service_type))
@@ -990,8 +992,7 @@ class ServiceModuleRepository:
def get_overview(self) -> Dict[str, Any]:
"""Get overview statistics for all modules."""
from .models import ServiceModuleDB, ModuleRegulationMappingDB
from sqlalchemy import func
from .models import ModuleRegulationMappingDB
modules = self.get_all()
total = len(modules)
@@ -1035,7 +1036,6 @@ class ServiceModuleRepository:
def seed_from_data(self, services_data: List[Dict[str, Any]], force: bool = False) -> Dict[str, int]:
"""Seed modules from service_modules.py data."""
from .models import ServiceModuleDB
modules_created = 0
mappings_created = 0
@@ -12,7 +12,7 @@ Checks:
import sys
from pathlib import Path
from collections import defaultdict
from typing import Dict, List, Set
from typing import Dict, List
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -182,7 +182,7 @@ def validate_data_categories():
def main():
"""Run all validations."""
print(f"{GREEN}{'='*60}")
print(f" Breakpilot Service Module Validation")
print(" Breakpilot Service Module Validation")
print(f"{'='*60}{RESET}")
all_passed = True
@@ -11,11 +11,11 @@ Provides AI-powered features for:
import json
import logging
import re
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from enum import Enum
from .llm_provider import LLMProvider, get_shared_provider, LLMResponse
from .llm_provider import LLMProvider, get_shared_provider
from .rag_client import get_rag_client
logger = logging.getLogger(__name__)
@@ -18,27 +18,23 @@ import io
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional, Tuple
from uuid import uuid4
import hashlib
from sqlalchemy.orm import Session, selectinload
from sqlalchemy.orm import Session
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
from reportlab.lib.units import mm
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, Image, ListFlowable, ListItem, KeepTogether,
HRFlowable
PageBreak, HRFlowable
)
from reportlab.graphics.shapes import Drawing, Rect, String
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.piecharts import Pie
from ..db.models import (
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
RequirementDB, RegulationDB
AuditSessionDB, AuditSignOffDB, AuditResultEnum, RequirementDB, RegulationDB
)
logger = logging.getLogger(__name__)
@@ -12,7 +12,7 @@ Sprint 6: CI/CD Evidence Collection (2026-01-18)
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional
from dataclasses import dataclass
from enum import Enum
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from ..db.models import (
ControlDB, ControlStatusEnum,
EvidenceDB, EvidenceStatusEnum,
RiskDB, RiskLevelEnum,
RiskDB,
)
from ..db.repository import ControlRepository, EvidenceRepository, RiskRepository
@@ -189,7 +189,7 @@ class AuditExportGenerator:
self, output_dir: Path, included_regulations: Optional[List[str]]
) -> None:
"""Export regulations to JSON files."""
query = self.db.query(RegulationDB).filter(RegulationDB.is_active == True)
query = self.db.query(RegulationDB).filter(RegulationDB.is_active)
if included_regulations:
query = query.filter(RegulationDB.code.in_(included_regulations))
@@ -557,7 +557,7 @@ Generiert am: """ + datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) -> Dict[str, Any]:
"""Calculate compliance statistics."""
# Count regulations
reg_query = self.db.query(RegulationDB).filter(RegulationDB.is_active == True)
reg_query = self.db.query(RegulationDB).filter(RegulationDB.is_active)
if included_regulations:
reg_query = reg_query.filter(RegulationDB.code.in_(included_regulations))
total_regulations = reg_query.count()
@@ -26,7 +26,7 @@ import asyncio
import logging
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import Enum
import httpx
@@ -11,11 +11,9 @@ Similar pattern to edu-search and zeugnisse-crawler.
import logging
import re
import asyncio
from datetime import datetime
from typing import Dict, List, Any, Optional
from enum import Enum
import hashlib
import httpx
from bs4 import BeautifulSoup
@@ -19,16 +19,11 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from ..db.models import (
RegulationDB,
RequirementDB,
ControlDB,
ControlMappingDB,
EvidenceDB,
RiskDB,
AuditExportDB,
ControlStatusEnum,
RiskLevelEnum,
EvidenceStatusEnum,
)
from ..db.repository import (
RegulationRepository,
@@ -171,7 +166,6 @@ class ComplianceReportGenerator:
# Control status findings
by_status = ctrl_stats.get("by_status", {})
passed = by_status.get("pass", 0)
failed = by_status.get("fail", 0)
planned = by_status.get("planned", 0)
@@ -200,10 +194,8 @@ class ComplianceReportGenerator:
"""Generate compliance score section with breakdown."""
stats = self.ctrl_repo.get_statistics()
by_domain = stats.get("by_domain", {})
domain_scores = {}
controls = self.ctrl_repo.get_all()
domain_scores = {}
domain_stats = {}
for ctrl in controls:
@@ -5,8 +5,7 @@ Seeds the database with initial regulations, controls, and requirements.
"""
import logging
from typing import Dict, List, Optional
from datetime import datetime
from typing import Dict
from sqlalchemy.orm import Session
@@ -23,7 +22,6 @@ from ..db.models import (
ControlTypeEnum,
ControlDomainEnum,
ControlStatusEnum,
RiskLevelEnum,
ServiceTypeEnum,
RelevanceLevelEnum,
)
@@ -9,10 +9,9 @@ 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 unittest.mock import MagicMock
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
# Import the app and dependencies
@@ -4,10 +4,8 @@ Tests for the AutoRiskUpdater Service.
Sprint 6: CI/CD Evidence Collection & Automatic Risk Updates (2026-01-18)
"""
import pytest
from datetime import datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
from unittest.mock import MagicMock
from ..services.auto_risk_updater import (
AutoRiskUpdater,
@@ -18,9 +16,7 @@ from ..services.auto_risk_updater import (
CONTROL_SCAN_MAPPING,
)
from ..db.models import (
ControlDB, ControlStatusEnum,
EvidenceDB, EvidenceStatusEnum,
RiskDB, RiskLevelEnum,
ControlStatusEnum,
)
@@ -16,7 +16,6 @@ from compliance.db.models import (
RequirementDB, RegulationDB,
AISystemDB, AIClassificationEnum, AISystemStatusEnum,
RiskDB, RiskLevelEnum,
EvidenceDB, EvidenceStatusEnum,
)
from compliance.db.repository import RequirementRepository
@@ -16,7 +16,7 @@ Run with: pytest backend/compliance/tests/test_isms_routes.py -v
import pytest
from datetime import datetime, date
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from uuid import uuid4
from sqlalchemy.orm import Session
@@ -25,7 +25,7 @@ import sys
sys.path.insert(0, '/Users/benjaminadmin/Projekte/breakpilot-pwa/backend')
from compliance.db.models import (
ISMSScopeDB, ISMSContextDB, ISMSPolicyDB, SecurityObjectiveDB,
ISMSScopeDB, ISMSPolicyDB, SecurityObjectiveDB,
StatementOfApplicabilityDB, AuditFindingDB, CorrectiveActionDB,
ManagementReviewDB, InternalAuditDB, AuditTrailDB, ISMSReadinessCheckDB,
ApprovalStatusEnum, FindingTypeEnum, FindingStatusEnum, CAPATypeEnum
@@ -393,7 +393,7 @@ class TestAuditFinding:
# is_blocking is a property, so we check the type
is_blocking = (sample_major_finding.finding_type == FindingTypeEnum.MAJOR and
sample_major_finding.status != FindingStatusEnum.CLOSED)
assert is_blocking == True
assert is_blocking
def test_finding_has_objective_evidence(self, sample_finding):
"""Findings should have objective evidence."""
@@ -524,7 +524,7 @@ class TestISMSReadinessCheck:
readiness_score=30.0,
)
assert check.certification_possible == False
assert not check.certification_possible
assert len(check.potential_majors) >= 1
assert check.readiness_score < 100
@@ -551,7 +551,7 @@ class TestISMSReadinessCheck:
assert check.chapter_4_status == "pass"
assert check.chapter_5_status == "pass"
assert check.chapter_9_status == "pass"
assert check.certification_possible == True
assert check.certification_possible
# ============================================================================
@@ -660,7 +660,7 @@ class TestCertificationBlockers:
is_blocking = (finding.finding_type == FindingTypeEnum.MAJOR and
finding.status != FindingStatusEnum.CLOSED)
assert is_blocking == True
assert is_blocking
def test_closed_major_allows_certification(self):
"""Closed major findings should not block certification."""
@@ -677,7 +677,7 @@ class TestCertificationBlockers:
is_blocking = (finding.finding_type == FindingTypeEnum.MAJOR and
finding.status != FindingStatusEnum.CLOSED)
assert is_blocking == False
assert not is_blocking
def test_minor_findings_dont_block_certification(self):
"""Minor findings should not block certification."""
@@ -693,4 +693,4 @@ class TestCertificationBlockers:
is_blocking = (finding.finding_type == FindingTypeEnum.MAJOR and
finding.status != FindingStatusEnum.CLOSED)
assert is_blocking == False
assert not is_blocking
-415
View File
@@ -1,415 +0,0 @@
"""
Data Subject Request (DSR) Admin API - Betroffenenanfragen-Verwaltung
Admin-Endpunkte für die Verwaltung von Betroffenenanfragen nach DSGVO
"""
from fastapi import APIRouter, HTTPException, Header, Query
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
import httpx
import os
from consent_client import generate_jwt_token, JWT_SECRET
# Consent Service URL
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
router = APIRouter(prefix="/v1/admin/dsr", tags=["dsr-admin"])
# Admin User UUID (muss in der DB existieren!)
ADMIN_USER_UUID = "a0000000-0000-0000-0000-000000000001"
# Request Models
class CreateDSRRequest(BaseModel):
"""Admin-Anfrage zum manuellen Erstellen einer Betroffenenanfrage"""
request_type: str
requester_email: str
requester_name: Optional[str] = None
requester_phone: Optional[str] = None
request_details: Optional[Dict[str, Any]] = None
priority: Optional[str] = None # normal, high, expedited
source: Optional[str] = "admin_panel"
class UpdateDSRRequest(BaseModel):
"""Anfrage zum Aktualisieren einer Betroffenenanfrage"""
status: Optional[str] = None
priority: Optional[str] = None
processing_notes: Optional[str] = None
class UpdateStatusRequest(BaseModel):
"""Anfrage zum Ändern des Status"""
status: str
comment: Optional[str] = None
class VerifyIdentityRequest(BaseModel):
"""Anfrage zur Identitätsverifizierung"""
method: str # id_card, passport, video_call, email, phone, other
class AssignRequest(BaseModel):
"""Anfrage zur Zuweisung"""
assignee_id: str
class ExtendDeadlineRequest(BaseModel):
"""Anfrage zur Fristverlängerung"""
reason: str
days: Optional[int] = 60
class CompleteDSRRequest(BaseModel):
"""Anfrage zum Abschließen einer Betroffenenanfrage"""
summary: str
result_data: Optional[Dict[str, Any]] = None
class RejectDSRRequest(BaseModel):
"""Anfrage zum Ablehnen einer Betroffenenanfrage"""
reason: str
legal_basis: str # Art. 17(3)a, Art. 17(3)b, Art. 17(3)c, Art. 17(3)d, Art. 17(3)e, Art. 12(5)
class SendCommunicationRequest(BaseModel):
"""Anfrage zum Senden einer Kommunikation"""
communication_type: str
template_version_id: Optional[str] = None
custom_subject: Optional[str] = None
custom_body: Optional[str] = None
variables: Optional[Dict[str, str]] = None
class UpdateExceptionCheckRequest(BaseModel):
"""Anfrage zum Aktualisieren einer Ausnahmeprüfung"""
applies: bool
notes: Optional[str] = None
class CreateTemplateVersionRequest(BaseModel):
"""Anfrage zum Erstellen einer Vorlagen-Version"""
version: str
language: Optional[str] = "de"
subject: str
body_html: str
body_text: Optional[str] = None
# Helper für Admin Token
def get_admin_token(authorization: Optional[str]) -> str:
if authorization:
parts = authorization.split(" ")
if len(parts) == 2 and parts[0] == "Bearer":
return parts[1]
# Für Entwicklung: Generiere einen Admin-Token
return generate_jwt_token(
user_id=ADMIN_USER_UUID,
email="admin@breakpilot.app",
role="admin"
)
async def proxy_request(method: str, path: str, token: str, json_data=None, query_params=None):
"""Proxied Anfragen an den Go Consent Service"""
url = f"{CONSENT_SERVICE_URL}/api/v1/admin{path}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
try:
if method == "GET":
response = await client.get(url, headers=headers, params=query_params, timeout=30.0)
elif method == "POST":
response = await client.post(url, headers=headers, json=json_data, timeout=30.0)
elif method == "PUT":
response = await client.put(url, headers=headers, json=json_data, timeout=30.0)
elif method == "DELETE":
response = await client.delete(url, headers=headers, timeout=30.0)
else:
raise HTTPException(status_code=400, detail="Invalid method")
if response.status_code >= 400:
error_detail = response.json() if response.content else {"error": "Unknown error"}
raise HTTPException(status_code=response.status_code, detail=error_detail)
return response.json() if response.content else {"success": True}
except httpx.RequestError as e:
raise HTTPException(status_code=503, detail=f"Consent Service unavailable: {str(e)}")
# ==========================================
# DSR List & Statistics
# ==========================================
@router.get("")
async def admin_list_dsr(
status: Optional[str] = Query(None, description="Filter by status"),
request_type: Optional[str] = Query(None, description="Filter by request type"),
assigned_to: Optional[str] = Query(None, description="Filter by assignee"),
priority: Optional[str] = Query(None, description="Filter by priority"),
overdue_only: bool = Query(False, description="Only overdue requests"),
search: Optional[str] = Query(None, description="Search term"),
from_date: Optional[str] = Query(None, description="From date (YYYY-MM-DD)"),
to_date: Optional[str] = Query(None, description="To date (YYYY-MM-DD)"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
authorization: Optional[str] = Header(None)
):
"""Gibt alle Betroffenenanfragen mit Filtern zurück"""
token = get_admin_token(authorization)
params = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if request_type:
params["request_type"] = request_type
if assigned_to:
params["assigned_to"] = assigned_to
if priority:
params["priority"] = priority
if overdue_only:
params["overdue_only"] = "true"
if search:
params["search"] = search
if from_date:
params["from_date"] = from_date
if to_date:
params["to_date"] = to_date
return await proxy_request("GET", "/dsr", token, query_params=params)
@router.get("/stats")
async def admin_get_dsr_stats(authorization: Optional[str] = Header(None)):
"""Gibt Dashboard-Statistiken für Betroffenenanfragen zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", "/dsr/stats", token)
# ==========================================
# Single DSR Management
# ==========================================
@router.get("/{dsr_id}")
async def admin_get_dsr(dsr_id: str, authorization: Optional[str] = Header(None)):
"""Gibt Details einer Betroffenenanfrage zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", f"/dsr/{dsr_id}", token)
@router.post("")
async def admin_create_dsr(
request: CreateDSRRequest,
authorization: Optional[str] = Header(None)
):
"""Erstellt eine Betroffenenanfrage manuell"""
token = get_admin_token(authorization)
return await proxy_request("POST", "/dsr", token, request.dict(exclude_none=True))
@router.put("/{dsr_id}")
async def admin_update_dsr(
dsr_id: str,
request: UpdateDSRRequest,
authorization: Optional[str] = Header(None)
):
"""Aktualisiert eine Betroffenenanfrage"""
token = get_admin_token(authorization)
return await proxy_request("PUT", f"/dsr/{dsr_id}", token, request.dict(exclude_none=True))
@router.post("/{dsr_id}/status")
async def admin_update_dsr_status(
dsr_id: str,
request: UpdateStatusRequest,
authorization: Optional[str] = Header(None)
):
"""Ändert den Status einer Betroffenenanfrage"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/status", token, request.dict(exclude_none=True))
# ==========================================
# DSR Workflow Actions
# ==========================================
@router.post("/{dsr_id}/verify-identity")
async def admin_verify_identity(
dsr_id: str,
request: VerifyIdentityRequest,
authorization: Optional[str] = Header(None)
):
"""Verifiziert die Identität des Antragstellers"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/verify-identity", token, request.dict())
@router.post("/{dsr_id}/assign")
async def admin_assign_dsr(
dsr_id: str,
request: AssignRequest,
authorization: Optional[str] = Header(None)
):
"""Weist eine Betroffenenanfrage einem Bearbeiter zu"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/assign", token, request.dict())
@router.post("/{dsr_id}/extend")
async def admin_extend_deadline(
dsr_id: str,
request: ExtendDeadlineRequest,
authorization: Optional[str] = Header(None)
):
"""Verlängert die Bearbeitungsfrist (max. 2 weitere Monate nach Art. 12(3))"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/extend", token, request.dict())
@router.post("/{dsr_id}/complete")
async def admin_complete_dsr(
dsr_id: str,
request: CompleteDSRRequest,
authorization: Optional[str] = Header(None)
):
"""Schließt eine Betroffenenanfrage erfolgreich ab"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/complete", token, request.dict(exclude_none=True))
@router.post("/{dsr_id}/reject")
async def admin_reject_dsr(
dsr_id: str,
request: RejectDSRRequest,
authorization: Optional[str] = Header(None)
):
"""Lehnt eine Betroffenenanfrage mit Rechtsgrundlage ab"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/reject", token, request.dict())
# ==========================================
# DSR History & Communications
# ==========================================
@router.get("/{dsr_id}/history")
async def admin_get_dsr_history(dsr_id: str, authorization: Optional[str] = Header(None)):
"""Gibt die Status-Historie einer Betroffenenanfrage zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", f"/dsr/{dsr_id}/history", token)
@router.get("/{dsr_id}/communications")
async def admin_get_dsr_communications(dsr_id: str, authorization: Optional[str] = Header(None)):
"""Gibt die Kommunikationshistorie einer Betroffenenanfrage zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", f"/dsr/{dsr_id}/communications", token)
@router.post("/{dsr_id}/communicate")
async def admin_send_communication(
dsr_id: str,
request: SendCommunicationRequest,
authorization: Optional[str] = Header(None)
):
"""Sendet eine Kommunikation zum Antragsteller"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/communicate", token, request.dict(exclude_none=True))
# ==========================================
# Exception Checks (Art. 17)
# ==========================================
@router.get("/{dsr_id}/exception-checks")
async def admin_get_exception_checks(dsr_id: str, authorization: Optional[str] = Header(None)):
"""Gibt die Ausnahmeprüfungen für Löschanfragen (Art. 17) zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", f"/dsr/{dsr_id}/exception-checks", token)
@router.post("/{dsr_id}/exception-checks/init")
async def admin_init_exception_checks(dsr_id: str, authorization: Optional[str] = Header(None)):
"""Initialisiert die Ausnahmeprüfungen für eine Löschanfrage"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/exception-checks/init", token)
@router.put("/{dsr_id}/exception-checks/{check_id}")
async def admin_update_exception_check(
dsr_id: str,
check_id: str,
request: UpdateExceptionCheckRequest,
authorization: Optional[str] = Header(None)
):
"""Aktualisiert eine einzelne Ausnahmeprüfung"""
token = get_admin_token(authorization)
return await proxy_request("PUT", f"/dsr/{dsr_id}/exception-checks/{check_id}", token, request.dict(exclude_none=True))
# ==========================================
# Deadline Processing
# ==========================================
@router.post("/deadlines/process")
async def admin_process_deadlines(authorization: Optional[str] = Header(None)):
"""Verarbeitet Fristen und sendet Warnungen (für Cronjob)"""
token = get_admin_token(authorization)
return await proxy_request("POST", "/dsr/deadlines/process", token)
# ==========================================
# DSR Templates Router
# ==========================================
templates_router = APIRouter(prefix="/v1/admin/dsr-templates", tags=["dsr-templates"])
@templates_router.get("")
async def admin_get_templates(authorization: Optional[str] = Header(None)):
"""Gibt alle DSR-Vorlagen zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", "/dsr-templates", token)
@templates_router.get("/published")
async def admin_get_published_templates(
request_type: Optional[str] = Query(None, description="Filter by request type"),
language: str = Query("de", description="Language"),
authorization: Optional[str] = Header(None)
):
"""Gibt alle veröffentlichten Vorlagen für die Auswahl zurück"""
token = get_admin_token(authorization)
params = {"language": language}
if request_type:
params["request_type"] = request_type
return await proxy_request("GET", "/dsr-templates/published", token, query_params=params)
@templates_router.get("/{template_id}/versions")
async def admin_get_template_versions(template_id: str, authorization: Optional[str] = Header(None)):
"""Gibt alle Versionen einer Vorlage zurück"""
token = get_admin_token(authorization)
return await proxy_request("GET", f"/dsr-templates/{template_id}/versions", token)
@templates_router.post("/{template_id}/versions")
async def admin_create_template_version(
template_id: str,
request: CreateTemplateVersionRequest,
authorization: Optional[str] = Header(None)
):
"""Erstellt eine neue Version einer Vorlage"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr-templates/{template_id}/versions", token, request.dict(exclude_none=True))
@templates_router.post("/versions/{version_id}/publish")
async def admin_publish_template_version(version_id: str, authorization: Optional[str] = Header(None)):
"""Veröffentlicht eine Vorlagen-Version"""
token = get_admin_token(authorization)
return await proxy_request("POST", f"/dsr-template-versions/{version_id}/publish", token)
-111
View File
@@ -1,111 +0,0 @@
"""
Data Subject Request (DSR) API - Betroffenenanfragen nach DSGVO
Benutzer-Endpunkte zum Erstellen und Verwalten eigener Betroffenenanfragen
"""
from fastapi import APIRouter, HTTPException, Header, Query
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, EmailStr
import httpx
import os
from consent_client import generate_jwt_token, JWT_SECRET
# Consent Service URL
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
router = APIRouter(prefix="/v1/dsr", tags=["dsr"])
# Request Models
class CreateDSRRequest(BaseModel):
"""Anfrage zum Erstellen einer Betroffenenanfrage"""
request_type: str # access, rectification, erasure, restriction, portability
requester_email: Optional[str] = None
requester_name: Optional[str] = None
requester_phone: Optional[str] = None
request_details: Optional[Dict[str, Any]] = None
# Helper to extract token
def get_token(authorization: Optional[str]) -> str:
if authorization:
parts = authorization.split(" ")
if len(parts) == 2 and parts[0] == "Bearer":
return parts[1]
raise HTTPException(status_code=401, detail="Authorization required")
async def proxy_request(method: str, path: str, token: str, json_data=None, query_params=None):
"""Proxied Anfragen an den Go Consent Service"""
url = f"{CONSENT_SERVICE_URL}/api/v1{path}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
try:
if method == "GET":
response = await client.get(url, headers=headers, params=query_params, timeout=10.0)
elif method == "POST":
response = await client.post(url, headers=headers, json=json_data, timeout=10.0)
elif method == "PUT":
response = await client.put(url, headers=headers, json=json_data, timeout=10.0)
elif method == "DELETE":
response = await client.delete(url, headers=headers, timeout=10.0)
else:
raise HTTPException(status_code=400, detail="Invalid method")
if response.status_code >= 400:
error_detail = response.json() if response.content else {"error": "Unknown error"}
raise HTTPException(status_code=response.status_code, detail=error_detail)
return response.json() if response.content else {"success": True}
except httpx.RequestError as e:
raise HTTPException(status_code=503, detail=f"Consent Service unavailable: {str(e)}")
# ==========================================
# User DSR Endpoints
# ==========================================
@router.post("")
async def create_dsr(
request: CreateDSRRequest,
authorization: str = Header(...)
):
"""
Erstellt eine neue Betroffenenanfrage.
request_type muss einer der folgenden Werte sein:
- access: Auskunftsrecht (Art. 15 DSGVO)
- rectification: Recht auf Berichtigung (Art. 16 DSGVO)
- erasure: Recht auf Löschung (Art. 17 DSGVO)
- restriction: Recht auf Einschränkung (Art. 18 DSGVO)
- portability: Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
"""
token = get_token(authorization)
return await proxy_request("POST", "/dsr", token, request.dict(exclude_none=True))
@router.get("")
async def get_my_dsrs(authorization: str = Header(...)):
"""Gibt alle eigenen Betroffenenanfragen zurück"""
token = get_token(authorization)
return await proxy_request("GET", "/dsr", token)
@router.get("/{dsr_id}")
async def get_my_dsr(dsr_id: str, authorization: str = Header(...)):
"""Gibt Details einer eigenen Betroffenenanfrage zurück"""
token = get_token(authorization)
return await proxy_request("GET", f"/dsr/{dsr_id}", token)
@router.post("/{dsr_id}/cancel")
async def cancel_my_dsr(dsr_id: str, authorization: str = Header(...)):
"""Storniert eine eigene Betroffenenanfrage"""
token = get_token(authorization)
return await proxy_request("POST", f"/dsr/{dsr_id}/cancel", token)
-10
View File
@@ -4,23 +4,13 @@ BreakPilot Middleware Stack
This module provides middleware components for the FastAPI backend:
- Request-ID: Adds unique request identifiers for tracing
- Security Headers: Adds security headers to all responses
- Rate Limiter: Protects against abuse (Valkey-based)
- PII Redactor: Redacts sensitive data from logs
- Input Gate: Validates request body size and content types
"""
from .request_id import RequestIDMiddleware, get_request_id
from .security_headers import SecurityHeadersMiddleware
from .rate_limiter import RateLimiterMiddleware
from .pii_redactor import PIIRedactor, redact_pii
from .input_gate import InputGateMiddleware
__all__ = [
"RequestIDMiddleware",
"get_request_id",
"SecurityHeadersMiddleware",
"RateLimiterMiddleware",
"PIIRedactor",
"redact_pii",
"InputGateMiddleware",
]
+3 -2
View File
@@ -17,13 +17,13 @@ annotated-types==0.7.0
# Authentication
PyJWT==2.10.1
python-multipart==0.0.20
python-multipart>=0.0.22
# AI / Anthropic (compliance AI assistant)
anthropic==0.75.0
# PDF Generation (GDPR export, audit reports)
weasyprint==66.0
weasyprint>=68.0
reportlab==4.2.5
Jinja2==3.1.6
@@ -48,3 +48,4 @@ redis==5.2.1
# Security: Pin transitive dependencies to patched versions
idna>=3.7
cryptography>=42.0.0
pillow>=12.1.1
@@ -50,7 +50,7 @@ class TestRowToResponse:
"""Tests for DB row to response conversion."""
def _make_row(self, **overrides):
"""Create a mock DB row with 30 fields."""
"""Create a mock DB row with 40 fields (matching row_to_response indices)."""
defaults = [
"uuid-123", # 0: id
"default", # 1: tenant_id
@@ -82,6 +82,17 @@ class TestRowToResponse:
"2026-01-01", # 27: completed_at
"2026-01-01", # 28: created_at
"2026-01-01", # 29: updated_at
# Phase 2 fields (indices 30-39)
[], # 30: repos
[], # 31: document_sources
[], # 32: processing_systems
[], # 33: ai_systems
[], # 34: technical_contacts
False, # 35: subject_to_nis2
False, # 36: subject_to_ai_act
False, # 37: subject_to_iso27001
None, # 38: supervisory_authority
12, # 39: review_cycle_months
]
return tuple(defaults)
+1 -1
View File
@@ -429,7 +429,7 @@ class TestGetTenantId:
assert _get_tenant_id("my-tenant") == "my-tenant"
def test_default_constant_value(self):
assert DEFAULT_TENANT_ID == "default"
assert DEFAULT_TENANT_ID == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# =============================================================================
@@ -252,3 +252,268 @@ class TestEvidenceCIStatus:
MockRepo.return_value.get_all.return_value = []
response = client.get("/evidence/ci-status", params={"control_id": CONTROL_UUID})
assert response.status_code == 200
def test_ci_status_without_control_id(self):
"""GET /evidence/ci-status without control_id returns all CI evidence."""
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = []
mock_db.query.return_value = mock_query
response = client.get("/evidence/ci-status")
assert response.status_code == 200
data = response.json()
assert data["period_days"] == 30
assert data["total_evidence"] == 0
assert data["controls"] == []
def test_ci_status_custom_days_param(self):
"""GET /evidence/ci-status with custom days lookback."""
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = []
mock_db.query.return_value = mock_query
response = client.get("/evidence/ci-status", params={"days": 7})
assert response.status_code == 200
data = response.json()
assert data["period_days"] == 7
class TestCollectCIEvidence:
"""Tests for POST /evidence/collect."""
def test_collect_sast_evidence_success(self):
"""Collect SAST evidence with Semgrep-format report data."""
ctrl = make_control({"control_id": "SDLC-001"})
evidence = make_evidence({
"evidence_type": "ci_sast",
"source": "ci_pipeline",
"ci_job_id": "job-456",
})
with patch("compliance.api.evidence_routes.ControlRepository") as MockCtrlRepo, \
patch("compliance.api.evidence_routes._store_evidence", return_value=evidence), \
patch("compliance.api.evidence_routes._update_risks", return_value=None):
MockCtrlRepo.return_value.get_by_control_id.return_value = ctrl
response = client.post(
"/evidence/collect",
params={"source": "sast", "ci_job_id": "job-456"},
json={"results": [
{"check_id": "python.lang.security", "extra": {"severity": "MEDIUM"}},
]},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["source"] == "sast"
assert data["control_id"] == "SDLC-001"
def test_collect_unknown_source_returns_400(self):
"""Unknown source should return 400."""
response = client.post(
"/evidence/collect",
params={"source": "unknown_tool"},
json={},
)
assert response.status_code == 400
assert "Unknown source" in response.json()["detail"]
def test_collect_control_not_found_returns_404(self):
"""If the mapped control does not exist in DB, return 404."""
with patch("compliance.api.evidence_routes.ControlRepository") as MockCtrlRepo:
MockCtrlRepo.return_value.get_by_control_id.return_value = None
response = client.post(
"/evidence/collect",
params={"source": "sast"},
json={"results": []},
)
assert response.status_code == 404
assert "SDLC-001" in response.json()["detail"]
def test_collect_with_null_report_data(self):
"""Collect with no report data body (None)."""
ctrl = make_control({"control_id": "SDLC-002"})
evidence = make_evidence({
"evidence_type": "ci_dependency_scan",
"source": "ci_pipeline",
})
with patch("compliance.api.evidence_routes.ControlRepository") as MockCtrlRepo, \
patch("compliance.api.evidence_routes._store_evidence", return_value=evidence), \
patch("compliance.api.evidence_routes._update_risks", return_value=None):
MockCtrlRepo.return_value.get_by_control_id.return_value = ctrl
response = client.post(
"/evidence/collect",
params={"source": "dependency_scan"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
def test_collect_sbom_source(self):
"""Collect SBOM evidence with components list."""
ctrl = make_control({"control_id": "SDLC-005"})
evidence = make_evidence({
"evidence_type": "ci_sbom",
"source": "ci_pipeline",
})
with patch("compliance.api.evidence_routes.ControlRepository") as MockCtrlRepo, \
patch("compliance.api.evidence_routes._store_evidence", return_value=evidence), \
patch("compliance.api.evidence_routes._update_risks", return_value=None):
MockCtrlRepo.return_value.get_by_control_id.return_value = ctrl
response = client.post(
"/evidence/collect",
params={"source": "sbom"},
json={"components": [
{"name": "fastapi", "version": "0.100.0"},
{"name": "pydantic", "version": "2.0.0"},
]},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["source"] == "sbom"
class TestParseCIEvidence:
"""Unit tests for _parse_ci_evidence helper."""
def test_parse_empty_data(self):
from compliance.api.evidence_routes import _parse_ci_evidence
result = _parse_ci_evidence({})
assert result["findings_count"] == 0
assert result["critical_findings"] == 0
assert result["evidence_status"] == "valid"
def test_parse_none_data(self):
from compliance.api.evidence_routes import _parse_ci_evidence
result = _parse_ci_evidence(None)
assert result["evidence_status"] == "valid"
assert result["report_json"] == "{}"
def test_parse_semgrep_with_critical(self):
"""Semgrep results with CRITICAL severity → status=failed."""
from compliance.api.evidence_routes import _parse_ci_evidence
data = {
"results": [
{"check_id": "sql-injection", "extra": {"severity": "CRITICAL"}},
{"check_id": "xss", "extra": {"severity": "MEDIUM"}},
]
}
result = _parse_ci_evidence(data)
assert result["findings_count"] == 2
assert result["critical_findings"] == 1
assert result["evidence_status"] == "failed"
def test_parse_trivy_format(self):
"""Trivy Results format with Vulnerabilities."""
from compliance.api.evidence_routes import _parse_ci_evidence
data = {
"Results": [
{
"Target": "python:3.11",
"Vulnerabilities": [
{"VulnerabilityID": "CVE-2024-001", "Severity": "HIGH"},
{"VulnerabilityID": "CVE-2024-002", "Severity": "LOW"},
],
}
]
}
result = _parse_ci_evidence(data)
assert result["findings_count"] == 2
assert result["critical_findings"] == 1
assert result["evidence_status"] == "failed"
def test_parse_generic_findings(self):
"""Generic findings array format."""
from compliance.api.evidence_routes import _parse_ci_evidence
data = {"findings": [{"id": "f1"}, {"id": "f2"}, {"id": "f3"}]}
result = _parse_ci_evidence(data)
assert result["findings_count"] == 3
assert result["critical_findings"] == 0
assert result["evidence_status"] == "valid"
def test_parse_sbom_components(self):
"""SBOM components → findings_count = number of components."""
from compliance.api.evidence_routes import _parse_ci_evidence
data = {"components": [{"name": "a"}, {"name": "b"}]}
result = _parse_ci_evidence(data)
assert result["findings_count"] == 2
assert result["evidence_status"] == "valid"
class TestExtractFindingsDetail:
"""Unit tests for _extract_findings_detail helper."""
def test_extract_empty(self):
from compliance.api.evidence_routes import _extract_findings_detail
result = _extract_findings_detail({})
assert result == {"critical": 0, "high": 0, "medium": 0, "low": 0}
def test_extract_none(self):
from compliance.api.evidence_routes import _extract_findings_detail
result = _extract_findings_detail(None)
assert result == {"critical": 0, "high": 0, "medium": 0, "low": 0}
def test_extract_semgrep_severities(self):
from compliance.api.evidence_routes import _extract_findings_detail
data = {
"results": [
{"extra": {"severity": "CRITICAL"}},
{"extra": {"severity": "HIGH"}},
{"extra": {"severity": "MEDIUM"}},
{"extra": {"severity": "LOW"}},
{"extra": {"severity": "INFO"}},
]
}
result = _extract_findings_detail(data)
assert result["critical"] == 1
assert result["high"] == 1
assert result["medium"] == 1
assert result["low"] == 2 # LOW + INFO both count as low
class TestListEvidenceEdgeCases:
"""Additional edge-case tests for GET /evidence."""
def test_list_filter_by_status(self):
"""Filter by status parameter."""
ev_valid = make_evidence({"status": MagicMock(value="valid")})
ev_failed = make_evidence({"status": MagicMock(value="failed")})
with patch("compliance.api.evidence_routes.EvidenceRepository") as MockRepo:
MockRepo.return_value.get_all.return_value = [ev_valid, ev_failed]
response = client.get("/evidence", params={"status": "valid"})
assert response.status_code == 200
# The route filters in-memory by status enum
data = response.json()
# At least it returns without error (status enum matching may differ with mocks)
assert "evidence" in data
def test_list_filter_invalid_status(self):
"""Invalid status value should be ignored (no crash)."""
with patch("compliance.api.evidence_routes.EvidenceRepository") as MockRepo:
MockRepo.return_value.get_all.return_value = [make_evidence()]
response = client.get("/evidence", params={"status": "nonexistent_status"})
assert response.status_code == 200
# Invalid status is silently ignored per the try/except ValueError in the route
assert response.json()["total"] == 1
def test_list_control_not_found(self):
"""GET /evidence with nonexistent control_id returns 404."""
with patch("compliance.api.evidence_routes.EvidenceRepository"), \
patch("compliance.api.evidence_routes.ControlRepository") as MockCtrlRepo:
MockCtrlRepo.return_value.get_by_control_id.return_value = None
response = client.get("/evidence", params={"control_id": "NONEXISTENT-001"})
assert response.status_code == 404
def test_list_pagination_slices_correctly(self):
"""Pagination returns correct slice while total reflects full count."""
items = [make_evidence({"id": f"e{i}-" + "0" * 32}) for i in range(5)]
with patch("compliance.api.evidence_routes.EvidenceRepository") as MockRepo:
MockRepo.return_value.get_all.return_value = items
response = client.get("/evidence", params={"page": 2, "limit": 2})
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["evidence"]) == 2
@@ -231,3 +231,161 @@ class TestGenerationRouteRegistration:
paths = [r.path for r in router.routes]
assert any("preview" in p for p in paths)
assert any("apply" in p for p in paths)
# =============================================================================
# _generate_for_type dispatcher
# =============================================================================
class TestGenerateForType:
"""Tests for the _generate_for_type dispatcher function."""
def test_dsfa_returns_single_item_list(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx()
result = _generate_for_type("dsfa", ctx)
assert isinstance(result, list)
assert len(result) == 1
assert "DSFA" in result[0]["title"]
def test_vvt_dispatches_correctly(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx(processing_systems=[
{"name": "HR System", "vendor": "SAP", "hosting": "cloud", "personal_data_categories": ["Mitarbeiter"]},
])
result = _generate_for_type("vvt", ctx)
assert isinstance(result, list)
assert len(result) == 1
assert "HR System" in result[0]["name"]
def test_tom_dispatches_correctly(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx()
result = _generate_for_type("tom", ctx)
assert isinstance(result, list)
assert len(result) == 8 # Base TOMs
def test_loeschfristen_dispatches_correctly(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx(processing_systems=[
{"name": "Payroll", "personal_data_categories": ["Bankdaten"]},
])
result = _generate_for_type("loeschfristen", ctx)
assert isinstance(result, list)
assert len(result) == 1
def test_obligation_dispatches_correctly(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx()
result = _generate_for_type("obligation", ctx)
assert isinstance(result, list)
assert len(result) == 8 # Base DSGVO obligations
def test_invalid_doc_type_raises_value_error(self):
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx()
with pytest.raises(ValueError, match="Unknown doc_type"):
_generate_for_type("nonexistent", ctx)
# =============================================================================
# VALID_DOC_TYPES validation
# =============================================================================
class TestValidDocTypes:
"""Tests for doc_type validation constants."""
def test_valid_doc_types_contains_all_expected(self):
from compliance.api.generation_routes import VALID_DOC_TYPES
expected = {"dsfa", "vvt", "tom", "loeschfristen", "obligation"}
assert VALID_DOC_TYPES == expected
def test_invalid_types_not_accepted(self):
from compliance.api.generation_routes import VALID_DOC_TYPES
invalid_types = ["dsgvo", "audit", "risk", "consent", "privacy", ""]
for t in invalid_types:
assert t not in VALID_DOC_TYPES, f"{t} should not be in VALID_DOC_TYPES"
# =============================================================================
# Template Context edge cases
# =============================================================================
class TestTemplateContextEdgeCases:
"""Tests for template context building and edge cases."""
def test_empty_company_name_still_generates(self):
"""Templates should work even with empty company name."""
ctx = _make_ctx(company_name="")
draft = generate_dsfa_draft(ctx)
assert draft["status"] == "draft"
assert "DSFA" in draft["title"]
def test_minimal_context_generates_all_types(self):
"""All generators should handle a minimal context without crashing."""
from compliance.api.generation_routes import _generate_for_type
ctx = _make_ctx()
for doc_type in ["dsfa", "vvt", "tom", "loeschfristen", "obligation"]:
result = _generate_for_type(doc_type, ctx)
assert isinstance(result, list), f"{doc_type} should return a list"
def test_context_with_many_processing_systems(self):
"""Verify generators handle multiple processing systems correctly."""
systems = [
{"name": f"System-{i}", "vendor": f"Vendor-{i}", "hosting": "cloud",
"personal_data_categories": [f"Kategorie-{i}"]}
for i in range(5)
]
ctx = _make_ctx(processing_systems=systems)
vvt_drafts = generate_vvt_drafts(ctx)
assert len(vvt_drafts) == 5
# Verify sequential VVT IDs
for i, draft in enumerate(vvt_drafts):
assert draft["vvt_id"] == f"VVT-AUTO-{i+1:03d}"
def test_context_with_multiple_ai_systems(self):
"""DSFA should list all AI systems in summary."""
ctx = _make_ctx(
has_ai_systems=True,
subject_to_ai_act=True,
ai_systems=[
{"name": "Chatbot", "purpose": "Support", "risk_category": "limited", "has_human_oversight": True},
{"name": "Scoring", "purpose": "Credit", "risk_category": "high", "has_human_oversight": False},
{"name": "OCR", "purpose": "Documents", "risk_category": "minimal", "has_human_oversight": True},
],
)
draft = generate_dsfa_draft(ctx)
assert len(draft["ai_systems_summary"]) == 3
assert draft["risk_level"] == "high"
def test_context_without_dpo_uses_empty_string(self):
"""When dpo_name is empty, templates should still work."""
ctx = _make_ctx(dpo_name="", dpo_email="")
draft = generate_dsfa_draft(ctx)
assert draft["dpo_name"] == ""
# Should still generate valid sections
assert "section_1" in draft["sections"]
def test_all_regulatory_flags_affect_all_generators(self):
"""When all regulatory flags are set, all generators should produce more output."""
from compliance.api.generation_routes import _generate_for_type
ctx_minimal = _make_ctx()
ctx_full = _make_ctx(
subject_to_nis2=True,
subject_to_ai_act=True,
subject_to_iso27001=True,
)
tom_minimal = _generate_for_type("tom", ctx_minimal)
tom_full = _generate_for_type("tom", ctx_full)
assert len(tom_full) > len(tom_minimal)
obligation_minimal = _generate_for_type("obligation", ctx_minimal)
obligation_full = _generate_for_type("obligation", ctx_full)
assert len(obligation_full) > len(obligation_minimal)
def test_dsfa_without_ai_has_empty_ai_summary(self):
"""DSFA without AI systems should have empty ai_systems_summary."""
ctx = _make_ctx(has_ai_systems=False, ai_systems=[])
draft = generate_dsfa_draft(ctx)
assert draft["ai_systems_summary"] == []
assert draft["involves_ai"] is False
@@ -0,0 +1,886 @@
"""Integration tests for ISMS routes (isms_routes.py).
Tests the ISO 27001 ISMS API endpoints using TestClient + SQLite + ORM:
- Scope CRUD + Approval
- Policy CRUD + Approval + Duplicate check
- Overview / Dashboard endpoint
- Readiness check
- Edge cases (not found, invalid data, etc.)
Run with: cd backend-compliance && python3 -m pytest tests/test_isms_routes.py -v
"""
import os
import sys
import pytest
from datetime import date, datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from classroom_engine.database import Base, get_db
from compliance.api.isms_routes import router as isms_router
# =============================================================================
# Test App + SQLite Setup
# =============================================================================
SQLALCHEMY_DATABASE_URL = "sqlite:///./test_isms.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_conn, connection_record):
"""Enable foreign keys and register NOW() for SQLite."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
dbapi_conn.create_function("NOW", 0, lambda: datetime.utcnow().isoformat())
app = FastAPI()
app.include_router(isms_router)
def override_get_db():
db = TestSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture(autouse=True)
def setup_db():
"""Create all tables before each test module, drop after."""
# Import all models so Base.metadata knows about them
import compliance.db.models # noqa: F401
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
# =============================================================================
# Helper data builders
# =============================================================================
def _scope_payload(**overrides):
data = {
"scope_statement": "ISMS covers all BreakPilot digital learning operations",
"included_locations": ["Frankfurt Office", "AWS eu-central-1"],
"included_processes": ["Software Development", "Data Processing"],
"included_services": ["BreakPilot PWA", "AI Assistant"],
"excluded_items": ["Marketing Website"],
"exclusion_justification": "Static site, no user data",
}
data.update(overrides)
return data
def _policy_payload(policy_id="POL-ISMS-001", **overrides):
data = {
"policy_id": policy_id,
"title": "Information Security Policy",
"policy_type": "master",
"description": "Master ISMS policy",
"policy_text": "This policy establishes the framework for information security...",
"applies_to": ["All Employees"],
"review_frequency_months": 12,
"related_controls": ["GOV-001"],
"authored_by": "iso@breakpilot.de",
}
data.update(overrides)
return data
def _objective_payload(objective_id="OBJ-2026-001", **overrides):
data = {
"objective_id": objective_id,
"title": "Reduce Security Incidents",
"description": "Reduce incidents by 30%",
"category": "operational",
"specific": "Reduce from 10 to 7 per year",
"measurable": "Incident count in ticketing system",
"achievable": "Based on trend analysis",
"relevant": "Supports info sec goals",
"time_bound": "By Q4 2026",
"kpi_name": "Security Incident Count",
"kpi_target": "7",
"kpi_unit": "incidents/year",
"measurement_frequency": "monthly",
"owner": "security@breakpilot.de",
"target_date": "2026-12-31",
"related_controls": ["OPS-003"],
}
data.update(overrides)
return data
def _soa_payload(annex_a_control="A.5.1", **overrides):
data = {
"annex_a_control": annex_a_control,
"annex_a_title": "Policies for information security",
"annex_a_category": "organizational",
"is_applicable": True,
"applicability_justification": "Required for ISMS governance",
"implementation_status": "implemented",
"implementation_notes": "Covered by GOV-001",
"breakpilot_control_ids": ["GOV-001"],
"coverage_level": "full",
"evidence_description": "ISMS Policy v2.0",
}
data.update(overrides)
return data
def _finding_payload(**overrides):
data = {
"finding_type": "minor",
"iso_chapter": "9.2",
"annex_a_control": "A.5.35",
"title": "Audit schedule not documented",
"description": "No formal internal audit schedule found",
"objective_evidence": "No document in DMS",
"impact_description": "Cannot demonstrate planned approach",
"owner": "iso@breakpilot.de",
"auditor": "external.auditor@cert.de",
"due_date": "2026-03-31",
}
data.update(overrides)
return data
def _mgmt_review_payload(**overrides):
data = {
"title": "Q1 2026 Management Review",
"review_date": "2026-01-15",
"review_period_start": "2025-10-01",
"review_period_end": "2025-12-31",
"chairperson": "ceo@breakpilot.de",
"attendees": [
{"name": "CEO", "role": "Chairperson"},
{"name": "CTO", "role": "Technical Lead"},
],
}
data.update(overrides)
return data
def _internal_audit_payload(**overrides):
data = {
"title": "ISMS Internal Audit 2026",
"audit_type": "scheduled",
"scope_description": "Complete ISMS audit covering all chapters",
"iso_chapters_covered": ["4", "5", "6", "7", "8", "9", "10"],
"annex_a_controls_covered": ["A.5", "A.6"],
"criteria": "ISO 27001:2022",
"planned_date": "2026-03-01",
"lead_auditor": "internal.auditor@breakpilot.de",
"audit_team": ["internal.auditor@breakpilot.de", "qa@breakpilot.de"],
}
data.update(overrides)
return data
# =============================================================================
# Test: ISMS Scope CRUD
# =============================================================================
class TestISMSScopeCRUD:
"""Tests for ISMS Scope CRUD endpoints."""
def test_create_scope(self):
"""POST /isms/scope should create a new scope."""
r = client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
assert r.status_code == 200
body = r.json()
assert body["scope_statement"] == "ISMS covers all BreakPilot digital learning operations"
assert body["status"] == "draft"
assert body["version"] == "1.0"
assert "id" in body
def test_get_scope(self):
"""GET /isms/scope should return the current scope."""
client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
r = client.get("/isms/scope")
assert r.status_code == 200
assert r.json()["scope_statement"] is not None
def test_get_scope_not_found(self):
"""GET /isms/scope should return 404 when no scope exists."""
r = client.get("/isms/scope")
assert r.status_code == 404
def test_update_scope(self):
"""PUT /isms/scope/{id} should update draft scope."""
create = client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
scope_id = create.json()["id"]
r = client.put(
f"/isms/scope/{scope_id}",
json={"scope_statement": "Updated scope statement"},
params={"updated_by": "admin@bp.de"},
)
assert r.status_code == 200
assert r.json()["scope_statement"] == "Updated scope statement"
assert r.json()["version"] == "1.1"
def test_update_scope_not_found(self):
"""PUT /isms/scope/{id} should return 404 for unknown id."""
r = client.put(
"/isms/scope/nonexistent-id",
json={"scope_statement": "x"},
params={"updated_by": "admin@bp.de"},
)
assert r.status_code == 404
def test_create_scope_supersedes_existing(self):
"""Creating a new scope should supersede the old one."""
client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
client.post(
"/isms/scope",
json=_scope_payload(scope_statement="New scope v2"),
params={"created_by": "admin@bp.de"},
)
r = client.get("/isms/scope")
assert r.status_code == 200
assert r.json()["scope_statement"] == "New scope v2"
def test_approve_scope(self):
"""POST /isms/scope/{id}/approve should approve scope."""
create = client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
scope_id = create.json()["id"]
r = client.post(
f"/isms/scope/{scope_id}/approve",
json={
"approved_by": "ceo@breakpilot.de",
"effective_date": "2026-03-01",
"review_date": "2027-03-01",
},
)
assert r.status_code == 200
assert r.json()["status"] == "approved"
assert r.json()["approved_by"] == "ceo@breakpilot.de"
def test_approve_scope_not_found(self):
"""POST /isms/scope/{id}/approve should return 404 for unknown scope."""
r = client.post(
"/isms/scope/fake-id/approve",
json={
"approved_by": "ceo@breakpilot.de",
"effective_date": "2026-03-01",
"review_date": "2027-03-01",
},
)
assert r.status_code == 404
def test_update_approved_scope_rejected(self):
"""PUT on approved scope should return 400."""
create = client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
scope_id = create.json()["id"]
client.post(
f"/isms/scope/{scope_id}/approve",
json={
"approved_by": "ceo@breakpilot.de",
"effective_date": "2026-03-01",
"review_date": "2027-03-01",
},
)
r = client.put(
f"/isms/scope/{scope_id}",
json={"scope_statement": "changed"},
params={"updated_by": "admin@bp.de"},
)
assert r.status_code == 400
assert "approved" in r.json()["detail"].lower()
# =============================================================================
# Test: ISMS Policy CRUD
# =============================================================================
class TestISMSPolicyCRUD:
"""Tests for ISMS Policy CRUD endpoints."""
def test_create_policy(self):
"""POST /isms/policies should create a new policy."""
r = client.post("/isms/policies", json=_policy_payload())
assert r.status_code == 200
body = r.json()
assert body["policy_id"] == "POL-ISMS-001"
assert body["status"] == "draft"
assert body["version"] == "1.0"
def test_list_policies(self):
"""GET /isms/policies should list all policies."""
client.post("/isms/policies", json=_policy_payload("POL-ISMS-001"))
client.post("/isms/policies", json=_policy_payload("POL-ISMS-002", title="Access Control Policy"))
r = client.get("/isms/policies")
assert r.status_code == 200
assert r.json()["total"] == 2
assert len(r.json()["policies"]) == 2
def test_list_policies_filter_by_type(self):
"""GET /isms/policies?policy_type=master should filter."""
client.post("/isms/policies", json=_policy_payload("POL-001"))
client.post("/isms/policies", json=_policy_payload("POL-002", policy_type="operational"))
r = client.get("/isms/policies", params={"policy_type": "master"})
assert r.status_code == 200
assert r.json()["total"] == 1
def test_get_policy_by_id(self):
"""GET /isms/policies/{id} should return a policy by its UUID."""
create = client.post("/isms/policies", json=_policy_payload())
policy_uuid = create.json()["id"]
r = client.get(f"/isms/policies/{policy_uuid}")
assert r.status_code == 200
assert r.json()["policy_id"] == "POL-ISMS-001"
def test_get_policy_by_policy_id(self):
"""GET /isms/policies/{policy_id} should also match the human-readable id."""
client.post("/isms/policies", json=_policy_payload())
r = client.get("/isms/policies/POL-ISMS-001")
assert r.status_code == 200
assert r.json()["title"] == "Information Security Policy"
def test_get_policy_not_found(self):
"""GET /isms/policies/{id} should return 404 for unknown policy."""
r = client.get("/isms/policies/nonexistent")
assert r.status_code == 404
def test_update_policy(self):
"""PUT /isms/policies/{id} should update a draft policy."""
create = client.post("/isms/policies", json=_policy_payload())
pid = create.json()["id"]
r = client.put(
f"/isms/policies/{pid}",
json={"title": "Updated Title"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["title"] == "Updated Title"
def test_update_policy_not_found(self):
"""PUT /isms/policies/{id} should return 404 for unknown policy."""
r = client.put(
"/isms/policies/fake-id",
json={"title": "x"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 404
def test_duplicate_policy_id_rejected(self):
"""POST /isms/policies with duplicate policy_id should return 400."""
client.post("/isms/policies", json=_policy_payload("POL-DUP"))
r = client.post("/isms/policies", json=_policy_payload("POL-DUP"))
assert r.status_code == 400
assert "already exists" in r.json()["detail"]
def test_approve_policy(self):
"""POST /isms/policies/{id}/approve should approve a policy."""
create = client.post("/isms/policies", json=_policy_payload())
pid = create.json()["id"]
r = client.post(
f"/isms/policies/{pid}/approve",
json={
"reviewed_by": "cto@breakpilot.de",
"approved_by": "ceo@breakpilot.de",
"effective_date": "2026-03-01",
},
)
assert r.status_code == 200
assert r.json()["status"] == "approved"
assert r.json()["approved_by"] == "ceo@breakpilot.de"
assert r.json()["next_review_date"] is not None
def test_approve_policy_not_found(self):
"""POST /isms/policies/{id}/approve should 404 for unknown policy."""
r = client.post(
"/isms/policies/fake/approve",
json={
"reviewed_by": "x",
"approved_by": "y",
"effective_date": "2026-03-01",
},
)
assert r.status_code == 404
def test_update_approved_policy_bumps_version(self):
"""Updating an approved policy should increment major version and reset to draft."""
create = client.post("/isms/policies", json=_policy_payload())
pid = create.json()["id"]
client.post(
f"/isms/policies/{pid}/approve",
json={
"reviewed_by": "cto@bp.de",
"approved_by": "ceo@bp.de",
"effective_date": "2026-03-01",
},
)
r = client.put(
f"/isms/policies/{pid}",
json={"title": "Updated after approval"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["version"] == "2.0"
assert r.json()["status"] == "draft"
# =============================================================================
# Test: Security Objectives
# =============================================================================
class TestSecurityObjectivesCRUD:
"""Tests for Security Objectives endpoints."""
def test_create_objective(self):
"""POST /isms/objectives should create a new objective."""
r = client.post("/isms/objectives", json=_objective_payload(), params={"created_by": "iso@bp.de"})
assert r.status_code == 200
body = r.json()
assert body["objective_id"] == "OBJ-2026-001"
assert body["status"] == "active"
def test_list_objectives(self):
"""GET /isms/objectives should list all objectives."""
client.post("/isms/objectives", json=_objective_payload("OBJ-001"), params={"created_by": "a"})
client.post("/isms/objectives", json=_objective_payload("OBJ-002", title="Uptime"), params={"created_by": "a"})
r = client.get("/isms/objectives")
assert r.status_code == 200
assert r.json()["total"] == 2
def test_update_objective_progress(self):
"""PUT /isms/objectives/{id} should update progress."""
create = client.post("/isms/objectives", json=_objective_payload(), params={"created_by": "a"})
oid = create.json()["id"]
r = client.put(
f"/isms/objectives/{oid}",
json={"progress_percentage": 50},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["progress_percentage"] == 50
def test_update_objective_auto_achieved(self):
"""Setting progress to 100% should auto-set status to 'achieved'."""
create = client.post("/isms/objectives", json=_objective_payload(), params={"created_by": "a"})
oid = create.json()["id"]
r = client.put(
f"/isms/objectives/{oid}",
json={"progress_percentage": 100},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["status"] == "achieved"
def test_update_objective_not_found(self):
"""PUT /isms/objectives/{id} should 404 for unknown objective."""
r = client.put(
"/isms/objectives/fake",
json={"progress_percentage": 10},
params={"updated_by": "a"},
)
assert r.status_code == 404
# =============================================================================
# Test: Statement of Applicability (SoA)
# =============================================================================
class TestSoACRUD:
"""Tests for SoA endpoints."""
def test_create_soa_entry(self):
"""POST /isms/soa should create an SoA entry."""
r = client.post("/isms/soa", json=_soa_payload(), params={"created_by": "iso@bp.de"})
assert r.status_code == 200
body = r.json()
assert body["annex_a_control"] == "A.5.1"
assert body["is_applicable"] is True
def test_list_soa_entries(self):
"""GET /isms/soa should list all SoA entries."""
client.post("/isms/soa", json=_soa_payload("A.5.1"), params={"created_by": "a"})
client.post("/isms/soa", json=_soa_payload("A.6.1", is_applicable=False, applicability_justification="N/A"), params={"created_by": "a"})
r = client.get("/isms/soa")
assert r.status_code == 200
assert r.json()["total"] == 2
assert r.json()["applicable_count"] == 1
assert r.json()["not_applicable_count"] == 1
def test_duplicate_soa_control_rejected(self):
"""POST /isms/soa with duplicate annex_a_control should return 400."""
client.post("/isms/soa", json=_soa_payload("A.5.1"), params={"created_by": "a"})
r = client.post("/isms/soa", json=_soa_payload("A.5.1"), params={"created_by": "a"})
assert r.status_code == 400
assert "already exists" in r.json()["detail"]
def test_update_soa_entry(self):
"""PUT /isms/soa/{id} should update an SoA entry."""
create = client.post("/isms/soa", json=_soa_payload(), params={"created_by": "a"})
eid = create.json()["id"]
r = client.put(
f"/isms/soa/{eid}",
json={"implementation_status": "in_progress"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["implementation_status"] == "in_progress"
assert r.json()["version"] == "1.1"
def test_update_soa_not_found(self):
"""PUT /isms/soa/{id} should 404 for unknown entry."""
r = client.put(
"/isms/soa/fake",
json={"implementation_status": "implemented"},
params={"updated_by": "a"},
)
assert r.status_code == 404
def test_approve_soa_entry(self):
"""POST /isms/soa/{id}/approve should approve an SoA entry."""
create = client.post("/isms/soa", json=_soa_payload(), params={"created_by": "a"})
eid = create.json()["id"]
r = client.post(
f"/isms/soa/{eid}/approve",
json={"reviewed_by": "cto@bp.de", "approved_by": "ceo@bp.de"},
)
assert r.status_code == 200
assert r.json()["approved_by"] == "ceo@bp.de"
# =============================================================================
# Test: Audit Findings
# =============================================================================
class TestAuditFindingsCRUD:
"""Tests for Audit Finding endpoints."""
def test_create_finding(self):
"""POST /isms/findings should create a finding with auto-generated ID."""
r = client.post("/isms/findings", json=_finding_payload())
assert r.status_code == 200
body = r.json()
assert body["finding_id"].startswith("FIND-")
assert body["status"] == "open"
def test_list_findings(self):
"""GET /isms/findings should list all findings."""
client.post("/isms/findings", json=_finding_payload())
client.post("/isms/findings", json=_finding_payload(finding_type="major", title="Major finding"))
r = client.get("/isms/findings")
assert r.status_code == 200
assert r.json()["total"] == 2
assert r.json()["major_count"] == 1
assert r.json()["minor_count"] == 1
def test_update_finding(self):
"""PUT /isms/findings/{id} should update a finding."""
create = client.post("/isms/findings", json=_finding_payload())
fid = create.json()["id"]
r = client.put(
f"/isms/findings/{fid}",
json={"root_cause": "Missing documentation process"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["root_cause"] == "Missing documentation process"
def test_update_finding_not_found(self):
"""PUT /isms/findings/{id} should 404 for unknown finding."""
r = client.put(
"/isms/findings/fake",
json={"root_cause": "x"},
params={"updated_by": "a"},
)
assert r.status_code == 404
def test_close_finding_no_capas(self):
"""POST /isms/findings/{id}/close should succeed if no CAPAs exist."""
create = client.post("/isms/findings", json=_finding_payload())
fid = create.json()["id"]
r = client.post(
f"/isms/findings/{fid}/close",
json={
"closure_notes": "Verified corrected",
"closed_by": "auditor@cert.de",
"verification_method": "Document review",
"verification_evidence": "Updated schedule approved",
},
)
assert r.status_code == 200
assert r.json()["status"] == "closed"
def test_close_finding_not_found(self):
"""POST /isms/findings/{id}/close should 404 for unknown finding."""
r = client.post(
"/isms/findings/fake/close",
json={
"closure_notes": "x",
"closed_by": "a",
"verification_method": "x",
"verification_evidence": "x",
},
)
assert r.status_code == 404
# =============================================================================
# Test: Management Reviews
# =============================================================================
class TestManagementReviewCRUD:
"""Tests for Management Review endpoints."""
def test_create_management_review(self):
"""POST /isms/management-reviews should create a review."""
r = client.post(
"/isms/management-reviews",
json=_mgmt_review_payload(),
params={"created_by": "iso@bp.de"},
)
assert r.status_code == 200
body = r.json()
assert body["review_id"].startswith("MR-")
assert body["status"] == "draft"
def test_list_management_reviews(self):
"""GET /isms/management-reviews should list reviews."""
client.post("/isms/management-reviews", json=_mgmt_review_payload(), params={"created_by": "a"})
r = client.get("/isms/management-reviews")
assert r.status_code == 200
assert r.json()["total"] == 1
def test_get_management_review(self):
"""GET /isms/management-reviews/{id} should return a review."""
create = client.post("/isms/management-reviews", json=_mgmt_review_payload(), params={"created_by": "a"})
rid = create.json()["id"]
r = client.get(f"/isms/management-reviews/{rid}")
assert r.status_code == 200
assert r.json()["chairperson"] == "ceo@breakpilot.de"
def test_get_management_review_not_found(self):
"""GET /isms/management-reviews/{id} should 404 for unknown review."""
r = client.get("/isms/management-reviews/fake")
assert r.status_code == 404
def test_update_management_review(self):
"""PUT /isms/management-reviews/{id} should update a review."""
create = client.post("/isms/management-reviews", json=_mgmt_review_payload(), params={"created_by": "a"})
rid = create.json()["id"]
r = client.put(
f"/isms/management-reviews/{rid}",
json={"input_previous_actions": "All actions completed", "status": "conducted"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["input_previous_actions"] == "All actions completed"
def test_approve_management_review(self):
"""POST /isms/management-reviews/{id}/approve should approve."""
create = client.post("/isms/management-reviews", json=_mgmt_review_payload(), params={"created_by": "a"})
rid = create.json()["id"]
r = client.post(
f"/isms/management-reviews/{rid}/approve",
json={
"approved_by": "ceo@bp.de",
"next_review_date": "2026-07-01",
},
)
assert r.status_code == 200
assert r.json()["status"] == "approved"
assert r.json()["approved_by"] == "ceo@bp.de"
# =============================================================================
# Test: Internal Audits
# =============================================================================
class TestInternalAuditCRUD:
"""Tests for Internal Audit endpoints."""
def test_create_internal_audit(self):
"""POST /isms/internal-audits should create an audit."""
r = client.post(
"/isms/internal-audits",
json=_internal_audit_payload(),
params={"created_by": "iso@bp.de"},
)
assert r.status_code == 200
body = r.json()
assert body["audit_id"].startswith("IA-")
assert body["status"] == "planned"
def test_list_internal_audits(self):
"""GET /isms/internal-audits should list audits."""
client.post("/isms/internal-audits", json=_internal_audit_payload(), params={"created_by": "a"})
r = client.get("/isms/internal-audits")
assert r.status_code == 200
assert r.json()["total"] == 1
def test_update_internal_audit(self):
"""PUT /isms/internal-audits/{id} should update an audit."""
create = client.post("/isms/internal-audits", json=_internal_audit_payload(), params={"created_by": "a"})
aid = create.json()["id"]
r = client.put(
f"/isms/internal-audits/{aid}",
json={"status": "in_progress", "actual_start_date": "2026-03-01"},
params={"updated_by": "iso@bp.de"},
)
assert r.status_code == 200
assert r.json()["status"] == "in_progress"
def test_update_internal_audit_not_found(self):
"""PUT /isms/internal-audits/{id} should 404 for unknown audit."""
r = client.put(
"/isms/internal-audits/fake",
json={"status": "in_progress"},
params={"updated_by": "a"},
)
assert r.status_code == 404
def test_complete_internal_audit(self):
"""POST /isms/internal-audits/{id}/complete should complete an audit."""
create = client.post("/isms/internal-audits", json=_internal_audit_payload(), params={"created_by": "a"})
aid = create.json()["id"]
r = client.post(
f"/isms/internal-audits/{aid}/complete",
json={
"audit_conclusion": "Overall conforming with minor observations",
"overall_assessment": "conforming",
"follow_up_audit_required": False,
},
params={"completed_by": "auditor@bp.de"},
)
assert r.status_code == 200
assert r.json()["status"] == "completed"
assert r.json()["follow_up_audit_required"] is False
def test_complete_internal_audit_not_found(self):
"""POST /isms/internal-audits/{id}/complete should 404 for unknown audit."""
r = client.post(
"/isms/internal-audits/fake/complete",
json={
"audit_conclusion": "x",
"overall_assessment": "conforming",
"follow_up_audit_required": False,
},
params={"completed_by": "a"},
)
assert r.status_code == 404
# =============================================================================
# Test: Readiness Check
# =============================================================================
class TestReadinessCheck:
"""Tests for the ISMS Readiness Check endpoint."""
def test_readiness_check_empty_isms(self):
"""POST /isms/readiness-check on empty DB should show not_ready."""
r = client.post("/isms/readiness-check", json={"triggered_by": "test"})
assert r.status_code == 200
body = r.json()
assert body["certification_possible"] is False
assert body["overall_status"] == "not_ready"
assert len(body["potential_majors"]) > 0
def test_readiness_check_latest_not_found(self):
"""GET /isms/readiness-check/latest should 404 when no check has run."""
r = client.get("/isms/readiness-check/latest")
assert r.status_code == 404
def test_readiness_check_latest_returns_most_recent(self):
"""GET /isms/readiness-check/latest should return last check."""
client.post("/isms/readiness-check", json={"triggered_by": "first"})
client.post("/isms/readiness-check", json={"triggered_by": "second"})
r = client.get("/isms/readiness-check/latest")
assert r.status_code == 200
assert r.json()["triggered_by"] == "second"
# =============================================================================
# Test: Overview / Dashboard
# =============================================================================
class TestOverviewDashboard:
"""Tests for the ISO 27001 overview endpoint."""
def test_overview_empty_isms(self):
"""GET /isms/overview on empty DB should return not_ready."""
r = client.get("/isms/overview")
assert r.status_code == 200
body = r.json()
assert body["overall_status"] in ("not_ready", "at_risk")
assert body["scope_approved"] is False
assert body["open_major_findings"] == 0
assert body["policies_count"] == 0
def test_overview_with_data(self):
"""GET /isms/overview should reflect created data."""
# Create and approve a scope
scope = client.post("/isms/scope", json=_scope_payload(), params={"created_by": "a"})
client.post(
f"/isms/scope/{scope.json()['id']}/approve",
json={"approved_by": "ceo@bp.de", "effective_date": "2026-01-01", "review_date": "2027-01-01"},
)
# Create a policy
client.post("/isms/policies", json=_policy_payload())
# Create an objective
client.post("/isms/objectives", json=_objective_payload(), params={"created_by": "a"})
r = client.get("/isms/overview")
assert r.status_code == 200
body = r.json()
assert body["scope_approved"] is True
assert body["policies_count"] == 1
assert body["objectives_count"] == 1
# =============================================================================
# Test: Audit Trail
# =============================================================================
class TestAuditTrail:
"""Tests for the Audit Trail endpoint."""
def test_audit_trail_records_actions(self):
"""Creating entities should generate audit trail entries."""
client.post("/isms/scope", json=_scope_payload(), params={"created_by": "admin@bp.de"})
client.post("/isms/policies", json=_policy_payload())
r = client.get("/isms/audit-trail")
assert r.status_code == 200
assert r.json()["total"] >= 2
def test_audit_trail_filter_by_entity_type(self):
"""GET /isms/audit-trail?entity_type=isms_policy should filter."""
client.post("/isms/scope", json=_scope_payload(), params={"created_by": "a"})
client.post("/isms/policies", json=_policy_payload())
r = client.get("/isms/audit-trail", params={"entity_type": "isms_policy"})
assert r.status_code == 200
for entry in r.json()["entries"]:
assert entry["entity_type"] == "isms_policy"
def test_audit_trail_pagination(self):
"""GET /isms/audit-trail should support pagination."""
# Create several entries
for i in range(5):
client.post("/isms/policies", json=_policy_payload(f"POL-PAGI-{i:03d}"))
r = client.get("/isms/audit-trail", params={"page": 1, "page_size": 2})
assert r.status_code == 200
assert len(r.json()["entries"]) == 2
assert r.json()["pagination"]["has_next"] is True
+334
View File
@@ -432,3 +432,337 @@ class TestVVTCsvExport:
text = self._collect_csv_body(response)
lines = text.strip().split('\n')
assert len(lines) == 1
# =============================================================================
# API Endpoint Tests (TestClient + mock DB)
# =============================================================================
from fastapi.testclient import TestClient
from fastapi import FastAPI
from compliance.api.vvt_routes import router
_app = FastAPI()
_app.include_router(router)
_client = TestClient(_app)
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
def _make_db_activity(**kwargs):
"""Create a mock VVTActivityDB object for query results."""
act = VVTActivityDB()
act.id = kwargs.get("id", uuid.uuid4())
act.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
act.vvt_id = kwargs.get("vvt_id", "VVT-001")
act.name = kwargs.get("name", "Test Verarbeitung")
act.description = kwargs.get("description", None)
act.purposes = kwargs.get("purposes", ["Vertragserfuellung"])
act.legal_bases = kwargs.get("legal_bases", ["Art. 6 Abs. 1b"])
act.data_subject_categories = kwargs.get("data_subject_categories", ["Kunden"])
act.personal_data_categories = kwargs.get("personal_data_categories", ["Email"])
act.recipient_categories = kwargs.get("recipient_categories", [])
act.third_country_transfers = kwargs.get("third_country_transfers", [])
act.retention_period = kwargs.get("retention_period", {"duration": "3 Jahre"})
act.tom_description = kwargs.get("tom_description", None)
act.business_function = kwargs.get("business_function", "IT")
act.systems = kwargs.get("systems", [])
act.deployment_model = kwargs.get("deployment_model", None)
act.data_sources = kwargs.get("data_sources", [])
act.data_flows = kwargs.get("data_flows", [])
act.protection_level = kwargs.get("protection_level", "MEDIUM")
act.dpia_required = kwargs.get("dpia_required", False)
act.structured_toms = kwargs.get("structured_toms", {})
act.status = kwargs.get("status", "DRAFT")
act.responsible = kwargs.get("responsible", None)
act.owner = kwargs.get("owner", None)
act.last_reviewed_at = kwargs.get("last_reviewed_at", None)
act.next_review_at = kwargs.get("next_review_at", None)
act.created_by = kwargs.get("created_by", "system")
act.dsfa_id = kwargs.get("dsfa_id", None)
act.created_at = kwargs.get("created_at", datetime(2026, 1, 15, 10, 0))
act.updated_at = kwargs.get("updated_at", None)
return act
def _make_db_org(**kwargs):
"""Create a mock VVTOrganizationDB object."""
org = VVTOrganizationDB()
org.id = kwargs.get("id", uuid.uuid4())
org.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
org.organization_name = kwargs.get("organization_name", "BreakPilot GmbH")
org.industry = kwargs.get("industry", "IT")
org.locations = kwargs.get("locations", ["Berlin"])
org.employee_count = kwargs.get("employee_count", 50)
org.dpo_name = kwargs.get("dpo_name", "Max DSB")
org.dpo_contact = kwargs.get("dpo_contact", "dsb@example.com")
org.vvt_version = kwargs.get("vvt_version", "1.0")
org.last_review_date = kwargs.get("last_review_date", None)
org.next_review_date = kwargs.get("next_review_date", None)
org.review_interval = kwargs.get("review_interval", "annual")
org.created_at = kwargs.get("created_at", datetime(2026, 1, 1))
org.updated_at = kwargs.get("updated_at", None)
return org
def _make_audit_entry(**kwargs):
"""Create a mock VVTAuditLogDB object."""
entry = VVTAuditLogDB()
entry.id = kwargs.get("id", uuid.uuid4())
entry.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
entry.action = kwargs.get("action", "CREATE")
entry.entity_type = kwargs.get("entity_type", "activity")
entry.entity_id = kwargs.get("entity_id", uuid.uuid4())
entry.changed_by = kwargs.get("changed_by", "system")
entry.old_values = kwargs.get("old_values", None)
entry.new_values = kwargs.get("new_values", {"name": "Test"})
entry.created_at = kwargs.get("created_at", datetime(2026, 1, 15, 10, 0))
return entry
@pytest.fixture
def mock_db():
from classroom_engine.database import get_db
from compliance.api.tenant_utils import get_tenant_id
db = MagicMock()
_app.dependency_overrides[get_db] = lambda: db
_app.dependency_overrides[get_tenant_id] = lambda: DEFAULT_TENANT
yield db
_app.dependency_overrides.clear()
class TestExportEndpoint:
"""Tests for GET /vvt/export (JSON and CSV)."""
def test_export_json_with_activities(self, mock_db):
act = _make_db_activity(vvt_id="VVT-EXP-001", name="Export Test")
org = _make_db_org()
# mock chained query for org
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = org
# mock chained query for activities
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [act]
resp = _client.get("/vvt/export?format=json")
assert resp.status_code == 200
data = resp.json()
assert "exported_at" in data
assert "organization" in data
assert data["organization"]["name"] == "BreakPilot GmbH"
assert len(data["activities"]) == 1
assert data["activities"][0]["vvt_id"] == "VVT-EXP-001"
def test_export_json_empty_dataset(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
resp = _client.get("/vvt/export?format=json")
assert resp.status_code == 200
data = resp.json()
assert data["organization"] is None
assert data["activities"] == []
def test_export_csv_returns_streaming_response(self, mock_db):
act = _make_db_activity(vvt_id="VVT-CSV-E01", name="CSV Endpoint Test")
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [act]
resp = _client.get("/vvt/export?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.headers.get("content-type", "")
assert "attachment" in resp.headers.get("content-disposition", "")
body = resp.text
assert "VVT-CSV-E01" in body
assert "CSV Endpoint Test" in body
def test_export_invalid_format_rejected(self, mock_db):
resp = _client.get("/vvt/export?format=xml")
assert resp.status_code == 422 # validation error
class TestStatsEndpoint:
"""Tests for GET /vvt/stats."""
def test_stats_empty_tenant(self, mock_db):
mock_db.query.return_value.filter.return_value.all.return_value = []
resp = _client.get("/vvt/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["by_status"] == {}
assert data["dpia_required_count"] == 0
assert data["overdue_review_count"] == 0
def test_stats_with_activities(self, mock_db):
past = datetime(2025, 1, 1, tzinfo=timezone.utc)
acts = [
_make_db_activity(status="DRAFT", business_function="HR", dpia_required=True, next_review_at=past),
_make_db_activity(status="APPROVED", business_function="IT", dpia_required=False),
_make_db_activity(status="DRAFT", business_function="HR", dpia_required=False, third_country_transfers=["USA"]),
]
mock_db.query.return_value.filter.return_value.all.return_value = acts
resp = _client.get("/vvt/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert data["by_status"]["DRAFT"] == 2
assert data["by_status"]["APPROVED"] == 1
assert data["by_business_function"]["HR"] == 2
assert data["by_business_function"]["IT"] == 1
assert data["dpia_required_count"] == 1
assert data["third_country_count"] == 1
assert data["draft_count"] == 2
assert data["approved_count"] == 1
assert data["overdue_review_count"] == 1
class TestAuditLogEndpoint:
"""Tests for GET /vvt/audit-log."""
def test_audit_log_returns_entries(self, mock_db):
entry = _make_audit_entry(action="CREATE", entity_type="activity")
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [entry]
resp = _client.get("/vvt/audit-log")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["action"] == "CREATE"
assert data[0]["entity_type"] == "activity"
def test_audit_log_empty(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
resp = _client.get("/vvt/audit-log")
assert resp.status_code == 200
assert resp.json() == []
def test_audit_log_pagination_params(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
resp = _client.get("/vvt/audit-log?limit=10&offset=20")
assert resp.status_code == 200
class TestVersioningEndpoints:
"""Tests for GET /vvt/activities/{id}/versions and /versions/{v}."""
@patch("compliance.api.versioning_utils.list_versions")
def test_list_versions_returns_list(self, mock_list_versions, mock_db):
act_id = str(uuid.uuid4())
mock_list_versions.return_value = [
{"id": str(uuid.uuid4()), "version_number": 2, "status": "draft",
"change_summary": "Updated name", "changed_sections": [],
"created_by": "admin", "approved_by": None, "approved_at": None,
"created_at": "2026-01-15T10:00:00"},
{"id": str(uuid.uuid4()), "version_number": 1, "status": "draft",
"change_summary": "Initial", "changed_sections": [],
"created_by": "system", "approved_by": None, "approved_at": None,
"created_at": "2026-01-14T09:00:00"},
]
resp = _client.get(f"/vvt/activities/{act_id}/versions")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["version_number"] == 2
assert data[1]["version_number"] == 1
@patch("compliance.api.versioning_utils.list_versions")
def test_list_versions_empty(self, mock_list_versions, mock_db):
act_id = str(uuid.uuid4())
mock_list_versions.return_value = []
resp = _client.get(f"/vvt/activities/{act_id}/versions")
assert resp.status_code == 200
assert resp.json() == []
@patch("compliance.api.versioning_utils.get_version")
def test_get_specific_version(self, mock_get_version, mock_db):
act_id = str(uuid.uuid4())
mock_get_version.return_value = {
"id": str(uuid.uuid4()),
"version_number": 1,
"status": "approved",
"snapshot": {"name": "Test", "status": "APPROVED"},
"change_summary": "Initial version",
"changed_sections": ["name", "status"],
"created_by": "admin",
"approved_by": "dpo",
"approved_at": "2026-01-16T12:00:00",
"created_at": "2026-01-15T10:00:00",
}
resp = _client.get(f"/vvt/activities/{act_id}/versions/1")
assert resp.status_code == 200
data = resp.json()
assert data["version_number"] == 1
assert data["snapshot"]["name"] == "Test"
assert data["approved_by"] == "dpo"
@patch("compliance.api.versioning_utils.get_version")
def test_get_version_not_found(self, mock_get_version, mock_db):
act_id = str(uuid.uuid4())
mock_get_version.return_value = None
resp = _client.get(f"/vvt/activities/{act_id}/versions/999")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower()
class TestExportCsvEdgeCases:
"""Additional edge cases for CSV export helper."""
def _collect_csv_body(self, response) -> str:
import asyncio
async def _read():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return ''.join(chunks)
return asyncio.get_event_loop().run_until_complete(_read())
def test_export_csv_with_third_country_transfers(self):
from compliance.api.vvt_routes import _export_csv
act = _make_db_activity(
third_country_transfers=["USA", "China"],
vvt_id="VVT-TC-001",
name="Third Country Test",
)
response = _export_csv([act])
text = self._collect_csv_body(response)
assert "Ja" in text # third_country_transfers truthy -> "Ja"
def test_export_csv_no_third_country_transfers(self):
from compliance.api.vvt_routes import _export_csv
act = _make_db_activity(
third_country_transfers=[],
vvt_id="VVT-NTC-001",
name="No Third Country",
)
response = _export_csv([act])
text = self._collect_csv_body(response)
assert "Nein" in text # empty list -> "Nein"
def test_export_csv_multiple_activities(self):
from compliance.api.vvt_routes import _export_csv
acts = [
_make_db_activity(vvt_id="VVT-M-001", name="First"),
_make_db_activity(vvt_id="VVT-M-002", name="Second"),
_make_db_activity(vvt_id="VVT-M-003", name="Third"),
]
response = _export_csv(acts)
text = self._collect_csv_body(response)
lines = text.strip().split('\n')
# 1 header + 3 data rows
assert len(lines) == 4
assert "VVT-M-001" in lines[1]
assert "VVT-M-002" in lines[2]
assert "VVT-M-003" in lines[3]
def test_export_csv_content_disposition_filename(self):
from compliance.api.vvt_routes import _export_csv
response = _export_csv([])
assert "vvt_export_" in response.headers.get("content-disposition", "")
assert ".csv" in response.headers.get("content-disposition", "")