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
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:
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user