Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).
## Phase 0 — Architecture guardrails
Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:
1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
that would exceed the 500-line hard cap. Auto-loads in every Claude
session in this repo.
2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
enforces the LOC cap locally, freezes migrations/ without
[migration-approved], and protects guardrail files without
[guardrail-change].
3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
packages (compliance/{services,repositories,domain,schemas}), and
tsc --noEmit for admin-compliance + developer-portal.
Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.
scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.
## Deprecation sweep
47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.
DeprecationWarning count dropped from 158 to 35.
## Phase 1 Step 1 — Contract test harness
tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.
## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)
compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):
regulation_models.py (134) — Regulation, Requirement
control_models.py (279) — Control, Mapping, Evidence, Risk
ai_system_models.py (141) — AISystem, AuditExport
service_module_models.py (176) — ServiceModule, ModuleRegulation, ModuleRisk
audit_session_models.py (177) — AuditSession, AuditSignOff
isms_governance_models.py (323) — ISMSScope, Context, Policy, Objective, SoA
isms_audit_models.py (468) — Finding, CAPA, MgmtReview, InternalAudit,
AuditTrail, Readiness
models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.
All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.
## Phase 1 Step 3 — infrastructure only
backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.
PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.
## Verification
backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
PYTHONPATH=. pytest compliance/tests/ tests/contracts/
-> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
725 lines
25 KiB
Python
725 lines
25 KiB
Python
"""Tests for Vendor Compliance routes (vendor_compliance_routes.py).
|
||
|
||
Includes:
|
||
- Vendors: CRUD (5) + Stats (1) + Status-Patch (1) + Filter (2)
|
||
- Contracts: CRUD (5) + Filter (1)
|
||
- Findings: CRUD (5) + Filter (2)
|
||
- Control Instances: CRUD (5) + Filter (1)
|
||
- Controls Library: List + Create + Delete (3)
|
||
- Export Stubs: 3 × 501
|
||
- Response-Format: success/data/timestamp wrapper (2)
|
||
- camelCase/snake_case round-trip (2)
|
||
"""
|
||
|
||
import pytest
|
||
import uuid
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import create_engine, text, event
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||
|
||
from classroom_engine.database import get_db
|
||
from compliance.api.vendor_compliance_routes import router as vendor_compliance_router
|
||
|
||
# =============================================================================
|
||
# Test App + SQLite Setup
|
||
# =============================================================================
|
||
|
||
SQLALCHEMY_DATABASE_URL = "sqlite:///./test_vendor_compliance.db"
|
||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||
_RawSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||
|
||
TENANT_ID = "default"
|
||
|
||
|
||
@event.listens_for(engine, "connect")
|
||
def _register_sqlite_functions(dbapi_conn, connection_record):
|
||
dbapi_conn.create_function("NOW", 0, lambda: datetime.now(timezone.utc).isoformat())
|
||
|
||
|
||
class _DictRow(dict):
|
||
pass
|
||
|
||
|
||
class _DictSession:
|
||
def __init__(self, session):
|
||
self._session = session
|
||
|
||
def execute(self, stmt, params=None):
|
||
import re
|
||
if hasattr(stmt, 'text'):
|
||
rewritten = re.sub(r'CAST\((:[\w]+)\s+AS\s+jsonb\)', r'\1', stmt.text)
|
||
# Remove FILTER (WHERE ...) for SQLite — replace with CASE/SUM
|
||
# Simple approach: rewrite COUNT(*) FILTER (WHERE cond) → SUM(CASE WHEN cond THEN 1 ELSE 0 END)
|
||
filter_re = r'COUNT\(\*\)\s+FILTER\s*\(\s*WHERE\s+([^)]+)\)'
|
||
rewritten = re.sub(filter_re, r'SUM(CASE WHEN \1 THEN 1 ELSE 0 END)', rewritten)
|
||
# ILIKE → LIKE for SQLite
|
||
rewritten = rewritten.replace(' ILIKE ', ' LIKE ')
|
||
if rewritten != stmt.text:
|
||
stmt = text(rewritten)
|
||
result = self._session.execute(stmt, params)
|
||
return _DictResult(result)
|
||
|
||
def flush(self):
|
||
self._session.flush()
|
||
|
||
def commit(self):
|
||
self._session.commit()
|
||
|
||
def rollback(self):
|
||
self._session.rollback()
|
||
|
||
def close(self):
|
||
self._session.close()
|
||
|
||
|
||
class _DictResult:
|
||
def __init__(self, result):
|
||
self._result = result
|
||
try:
|
||
self._keys = list(result.keys())
|
||
self._returns_rows = True
|
||
except Exception:
|
||
self._keys = []
|
||
self._returns_rows = False
|
||
|
||
def fetchone(self):
|
||
if not self._returns_rows:
|
||
return None
|
||
row = self._result.fetchone()
|
||
if row is None:
|
||
return None
|
||
return _DictRow(zip(self._keys, row))
|
||
|
||
def fetchall(self):
|
||
if not self._returns_rows:
|
||
return []
|
||
rows = self._result.fetchall()
|
||
return [_DictRow(zip(self._keys, r)) for r in rows]
|
||
|
||
@property
|
||
def rowcount(self):
|
||
return self._result.rowcount
|
||
|
||
|
||
app = FastAPI()
|
||
app.include_router(vendor_compliance_router, prefix="/api/compliance")
|
||
|
||
|
||
def override_get_db():
|
||
session = _RawSessionLocal()
|
||
db = _DictSession(session)
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
app.dependency_overrides[get_db] = override_get_db
|
||
client = TestClient(app)
|
||
|
||
|
||
# =============================================================================
|
||
# SQLite Table Creation
|
||
# =============================================================================
|
||
|
||
CREATE_VENDORS = """
|
||
CREATE TABLE IF NOT EXISTS vendor_vendors (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
name TEXT NOT NULL DEFAULT '',
|
||
legal_form TEXT DEFAULT '',
|
||
country TEXT DEFAULT '',
|
||
address TEXT DEFAULT '',
|
||
website TEXT DEFAULT '',
|
||
role TEXT DEFAULT 'PROCESSOR',
|
||
service_description TEXT DEFAULT '',
|
||
service_category TEXT DEFAULT 'OTHER',
|
||
data_access_level TEXT DEFAULT 'NONE',
|
||
processing_locations TEXT DEFAULT '[]',
|
||
transfer_mechanisms TEXT DEFAULT '[]',
|
||
certifications TEXT DEFAULT '[]',
|
||
primary_contact TEXT DEFAULT '{}',
|
||
dpo_contact TEXT DEFAULT '{}',
|
||
security_contact TEXT DEFAULT '{}',
|
||
contract_types TEXT DEFAULT '[]',
|
||
inherent_risk_score INTEGER DEFAULT 50,
|
||
residual_risk_score INTEGER DEFAULT 50,
|
||
manual_risk_adjustment INTEGER,
|
||
risk_justification TEXT DEFAULT '',
|
||
review_frequency TEXT DEFAULT 'ANNUAL',
|
||
last_review_date TIMESTAMP,
|
||
next_review_date TIMESTAMP,
|
||
status TEXT DEFAULT 'ACTIVE',
|
||
processing_activity_ids TEXT DEFAULT '[]',
|
||
notes TEXT DEFAULT '',
|
||
contact_name TEXT DEFAULT '',
|
||
contact_email TEXT DEFAULT '',
|
||
contact_phone TEXT DEFAULT '',
|
||
contact_department TEXT DEFAULT '',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
created_by TEXT DEFAULT 'system'
|
||
)
|
||
"""
|
||
|
||
CREATE_CONTRACTS = """
|
||
CREATE TABLE IF NOT EXISTS vendor_contracts (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
vendor_id TEXT NOT NULL DEFAULT '',
|
||
file_name TEXT DEFAULT '',
|
||
original_name TEXT DEFAULT '',
|
||
mime_type TEXT DEFAULT '',
|
||
file_size INTEGER DEFAULT 0,
|
||
storage_path TEXT DEFAULT '',
|
||
document_type TEXT DEFAULT 'AVV',
|
||
version INTEGER DEFAULT 1,
|
||
previous_version_id TEXT,
|
||
parties TEXT DEFAULT '[]',
|
||
effective_date TIMESTAMP,
|
||
expiration_date TIMESTAMP,
|
||
auto_renewal INTEGER DEFAULT 0,
|
||
renewal_notice_period TEXT DEFAULT '',
|
||
termination_notice_period TEXT DEFAULT '',
|
||
review_status TEXT DEFAULT 'PENDING',
|
||
review_completed_at TIMESTAMP,
|
||
compliance_score INTEGER,
|
||
status TEXT DEFAULT 'DRAFT',
|
||
extracted_text TEXT DEFAULT '',
|
||
page_count INTEGER DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
created_by TEXT DEFAULT 'system'
|
||
)
|
||
"""
|
||
|
||
CREATE_FINDINGS = """
|
||
CREATE TABLE IF NOT EXISTS vendor_findings (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
vendor_id TEXT NOT NULL DEFAULT '',
|
||
contract_id TEXT,
|
||
finding_type TEXT DEFAULT 'UNKNOWN',
|
||
category TEXT DEFAULT '',
|
||
severity TEXT DEFAULT 'MEDIUM',
|
||
title TEXT DEFAULT '',
|
||
description TEXT DEFAULT '',
|
||
recommendation TEXT DEFAULT '',
|
||
citations TEXT DEFAULT '[]',
|
||
status TEXT DEFAULT 'OPEN',
|
||
assignee TEXT DEFAULT '',
|
||
due_date TIMESTAMP,
|
||
resolution TEXT DEFAULT '',
|
||
resolved_at TIMESTAMP,
|
||
resolved_by TEXT DEFAULT '',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
created_by TEXT DEFAULT 'system'
|
||
)
|
||
"""
|
||
|
||
CREATE_CONTROL_INSTANCES = """
|
||
CREATE TABLE IF NOT EXISTS vendor_control_instances (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
vendor_id TEXT NOT NULL DEFAULT '',
|
||
control_id TEXT DEFAULT '',
|
||
control_domain TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'PLANNED',
|
||
evidence_ids TEXT DEFAULT '[]',
|
||
notes TEXT DEFAULT '',
|
||
last_assessed_at TIMESTAMP,
|
||
last_assessed_by TEXT DEFAULT '',
|
||
next_assessment_date TIMESTAMP,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
created_by TEXT DEFAULT 'system'
|
||
)
|
||
"""
|
||
|
||
CREATE_CONTROLS = """
|
||
CREATE TABLE IF NOT EXISTS vendor_compliance_controls (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
domain TEXT DEFAULT '',
|
||
control_code TEXT DEFAULT '',
|
||
title TEXT DEFAULT '',
|
||
description TEXT DEFAULT '',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
|
||
|
||
def _setup_tables():
|
||
with engine.connect() as conn:
|
||
for sql in [CREATE_VENDORS, CREATE_CONTRACTS, CREATE_FINDINGS,
|
||
CREATE_CONTROL_INSTANCES, CREATE_CONTROLS]:
|
||
conn.execute(text(sql))
|
||
conn.commit()
|
||
|
||
|
||
def _teardown_tables():
|
||
with engine.connect() as conn:
|
||
for t in ["vendor_vendors", "vendor_contracts", "vendor_findings",
|
||
"vendor_control_instances", "vendor_compliance_controls"]:
|
||
conn.execute(text(f"DELETE FROM {t}"))
|
||
conn.commit()
|
||
|
||
|
||
_setup_tables()
|
||
|
||
|
||
# =============================================================================
|
||
# Fixtures
|
||
# =============================================================================
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clean_tables():
|
||
_teardown_tables()
|
||
yield
|
||
_teardown_tables()
|
||
|
||
|
||
def _create_vendor(**kwargs):
|
||
payload = {
|
||
"name": kwargs.get("name", "Test Vendor GmbH"),
|
||
"country": "DE",
|
||
"role": "PROCESSOR",
|
||
"serviceCategory": "HOSTING",
|
||
"status": kwargs.get("status", "ACTIVE"),
|
||
"inherentRiskScore": kwargs.get("inherentRiskScore", 50),
|
||
}
|
||
payload.update(kwargs)
|
||
resp = client.post("/api/compliance/vendor-compliance/vendors", json=payload)
|
||
assert resp.status_code == 201
|
||
return resp.json()["data"]
|
||
|
||
|
||
def _create_contract(vendor_id, **kwargs):
|
||
payload = {
|
||
"vendorId": vendor_id,
|
||
"documentType": "AVV",
|
||
"fileName": "avv-test.pdf",
|
||
"status": "DRAFT",
|
||
}
|
||
payload.update(kwargs)
|
||
resp = client.post("/api/compliance/vendor-compliance/contracts", json=payload)
|
||
assert resp.status_code == 201
|
||
return resp.json()["data"]
|
||
|
||
|
||
def _create_finding(vendor_id, **kwargs):
|
||
payload = {
|
||
"vendorId": vendor_id,
|
||
"findingType": "GAP",
|
||
"severity": "HIGH",
|
||
"title": "Missing TOM Annex",
|
||
"status": "OPEN",
|
||
}
|
||
payload.update(kwargs)
|
||
resp = client.post("/api/compliance/vendor-compliance/findings", json=payload)
|
||
assert resp.status_code == 201
|
||
return resp.json()["data"]
|
||
|
||
|
||
def _create_control_instance(vendor_id, **kwargs):
|
||
payload = {
|
||
"vendorId": vendor_id,
|
||
"controlId": "C-001",
|
||
"controlDomain": "priv",
|
||
"status": "PASS",
|
||
}
|
||
payload.update(kwargs)
|
||
resp = client.post("/api/compliance/vendor-compliance/control-instances", json=payload)
|
||
assert resp.status_code == 201
|
||
return resp.json()["data"]
|
||
|
||
|
||
# =============================================================================
|
||
# Response Format Tests
|
||
# =============================================================================
|
||
|
||
class TestResponseFormat:
|
||
def test_list_vendors_has_success_data_timestamp(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["success"] is True
|
||
assert "data" in body
|
||
assert "timestamp" in body
|
||
|
||
def test_create_vendor_has_success_data_timestamp(self):
|
||
resp = client.post("/api/compliance/vendor-compliance/vendors", json={"name": "Test"})
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["success"] is True
|
||
assert "data" in body
|
||
assert body["data"]["name"] == "Test"
|
||
assert "timestamp" in body
|
||
|
||
|
||
# =============================================================================
|
||
# camelCase / snake_case Round-Trip Tests
|
||
# =============================================================================
|
||
|
||
class TestCamelSnakeConversion:
|
||
def test_create_with_camel_returns_camel(self):
|
||
vendor = _create_vendor(
|
||
name="CamelTest",
|
||
legalForm="GmbH",
|
||
serviceDescription="Cloud hosting",
|
||
dataAccessLevel="CONTENT",
|
||
inherentRiskScore=80,
|
||
)
|
||
assert vendor["legalForm"] == "GmbH"
|
||
assert vendor["serviceDescription"] == "Cloud hosting"
|
||
assert vendor["dataAccessLevel"] == "CONTENT"
|
||
assert vendor["inherentRiskScore"] == 80
|
||
|
||
def test_round_trip_preserves_values(self):
|
||
vendor = _create_vendor(
|
||
name="RoundTrip",
|
||
processingLocations=["DE", "US"],
|
||
primaryContact={"name": "Max", "email": "max@test.de"},
|
||
)
|
||
vid = vendor["id"]
|
||
resp = client.get(f"/api/compliance/vendor-compliance/vendors/{vid}")
|
||
assert resp.status_code == 200
|
||
fetched = resp.json()["data"]
|
||
assert fetched["processingLocations"] == ["DE", "US"]
|
||
assert fetched["primaryContact"]["name"] == "Max"
|
||
|
||
|
||
# =============================================================================
|
||
# Vendor Tests
|
||
# =============================================================================
|
||
|
||
class TestVendorsCRUD:
|
||
def test_list_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors")
|
||
assert resp.status_code == 200
|
||
data = resp.json()["data"]
|
||
assert data["items"] == []
|
||
assert data["total"] == 0
|
||
|
||
def test_create_vendor(self):
|
||
vendor = _create_vendor(name="Hetzner GmbH")
|
||
assert vendor["name"] == "Hetzner GmbH"
|
||
assert "id" in vendor
|
||
|
||
def test_get_vendor(self):
|
||
vendor = _create_vendor()
|
||
resp = client.get(f"/api/compliance/vendor-compliance/vendors/{vendor['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["id"] == vendor["id"]
|
||
|
||
def test_update_vendor(self):
|
||
vendor = _create_vendor()
|
||
resp = client.put(
|
||
f"/api/compliance/vendor-compliance/vendors/{vendor['id']}",
|
||
json={"name": "Updated Name", "country": "AT"}
|
||
)
|
||
assert resp.status_code == 200
|
||
updated = resp.json()["data"]
|
||
assert updated["name"] == "Updated Name"
|
||
assert updated["country"] == "AT"
|
||
|
||
def test_delete_vendor(self):
|
||
vendor = _create_vendor()
|
||
resp = client.delete(f"/api/compliance/vendor-compliance/vendors/{vendor['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["deleted"] is True
|
||
resp2 = client.get(f"/api/compliance/vendor-compliance/vendors/{vendor['id']}")
|
||
assert resp2.status_code == 404
|
||
|
||
def test_get_nonexistent_vendor_404(self):
|
||
resp = client.get(f"/api/compliance/vendor-compliance/vendors/{uuid.uuid4()}")
|
||
assert resp.status_code == 404
|
||
|
||
def test_delete_nonexistent_vendor_404(self):
|
||
resp = client.delete(f"/api/compliance/vendor-compliance/vendors/{uuid.uuid4()}")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
class TestVendorStats:
|
||
def test_stats_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors/stats")
|
||
assert resp.status_code == 200
|
||
stats = resp.json()["data"]
|
||
assert stats["total"] == 0
|
||
|
||
def test_stats_with_vendors(self):
|
||
_create_vendor(name="V1", status="ACTIVE", inherentRiskScore=80)
|
||
_create_vendor(name="V2", status="INACTIVE", inherentRiskScore=30)
|
||
_create_vendor(name="V3", status="PENDING_REVIEW", inherentRiskScore=90)
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors/stats")
|
||
stats = resp.json()["data"]
|
||
assert stats["total"] == 3
|
||
assert stats["active"] == 1
|
||
assert stats["inactive"] == 1
|
||
assert stats["pendingReview"] == 1
|
||
assert stats["highRiskCount"] == 2 # 80 and 90
|
||
|
||
|
||
class TestVendorStatusPatch:
|
||
def test_patch_status(self):
|
||
vendor = _create_vendor(status="ACTIVE")
|
||
resp = client.patch(
|
||
f"/api/compliance/vendor-compliance/vendors/{vendor['id']}/status",
|
||
json={"status": "TERMINATED"}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["status"] == "TERMINATED"
|
||
|
||
def test_patch_invalid_status_400(self):
|
||
vendor = _create_vendor()
|
||
resp = client.patch(
|
||
f"/api/compliance/vendor-compliance/vendors/{vendor['id']}/status",
|
||
json={"status": "INVALID"}
|
||
)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
class TestVendorFilter:
|
||
def test_filter_by_status(self):
|
||
_create_vendor(name="Active1", status="ACTIVE")
|
||
_create_vendor(name="Inactive1", status="INACTIVE")
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors?status=ACTIVE")
|
||
items = resp.json()["data"]["items"]
|
||
assert len(items) == 1
|
||
assert items[0]["name"] == "Active1"
|
||
|
||
def test_filter_by_search(self):
|
||
_create_vendor(name="Hetzner Online GmbH")
|
||
_create_vendor(name="AWS Deutschland")
|
||
resp = client.get("/api/compliance/vendor-compliance/vendors?search=Hetzner")
|
||
items = resp.json()["data"]["items"]
|
||
assert len(items) == 1
|
||
assert "Hetzner" in items[0]["name"]
|
||
|
||
|
||
# =============================================================================
|
||
# Contract Tests
|
||
# =============================================================================
|
||
|
||
class TestContractsCRUD:
|
||
def test_list_contracts_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/contracts")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"] == []
|
||
|
||
def test_create_contract(self):
|
||
vendor = _create_vendor()
|
||
contract = _create_contract(vendor["id"])
|
||
assert contract["vendorId"] == vendor["id"]
|
||
assert contract["documentType"] == "AVV"
|
||
|
||
def test_get_contract(self):
|
||
vendor = _create_vendor()
|
||
contract = _create_contract(vendor["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/contracts/{contract['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["id"] == contract["id"]
|
||
|
||
def test_update_contract(self):
|
||
vendor = _create_vendor()
|
||
contract = _create_contract(vendor["id"])
|
||
resp = client.put(
|
||
f"/api/compliance/vendor-compliance/contracts/{contract['id']}",
|
||
json={"status": "ACTIVE", "complianceScore": 85}
|
||
)
|
||
assert resp.status_code == 200
|
||
updated = resp.json()["data"]
|
||
assert updated["status"] == "ACTIVE"
|
||
assert updated["complianceScore"] == 85
|
||
|
||
def test_delete_contract(self):
|
||
vendor = _create_vendor()
|
||
contract = _create_contract(vendor["id"])
|
||
resp = client.delete(f"/api/compliance/vendor-compliance/contracts/{contract['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["deleted"] is True
|
||
|
||
|
||
class TestContractFilter:
|
||
def test_filter_by_vendor_id(self):
|
||
v1 = _create_vendor(name="V1")
|
||
v2 = _create_vendor(name="V2")
|
||
_create_contract(v1["id"])
|
||
_create_contract(v1["id"])
|
||
_create_contract(v2["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/contracts?vendor_id={v1['id']}")
|
||
assert len(resp.json()["data"]) == 2
|
||
|
||
|
||
# =============================================================================
|
||
# Finding Tests
|
||
# =============================================================================
|
||
|
||
class TestFindingsCRUD:
|
||
def test_list_findings_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/findings")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"] == []
|
||
|
||
def test_create_finding(self):
|
||
vendor = _create_vendor()
|
||
finding = _create_finding(vendor["id"])
|
||
assert finding["vendorId"] == vendor["id"]
|
||
assert finding["severity"] == "HIGH"
|
||
|
||
def test_get_finding(self):
|
||
vendor = _create_vendor()
|
||
finding = _create_finding(vendor["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/findings/{finding['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["title"] == "Missing TOM Annex"
|
||
|
||
def test_update_finding(self):
|
||
vendor = _create_vendor()
|
||
finding = _create_finding(vendor["id"])
|
||
resp = client.put(
|
||
f"/api/compliance/vendor-compliance/findings/{finding['id']}",
|
||
json={"status": "RESOLVED", "resolution": "TOM annex added"}
|
||
)
|
||
assert resp.status_code == 200
|
||
updated = resp.json()["data"]
|
||
assert updated["status"] == "RESOLVED"
|
||
assert updated["resolution"] == "TOM annex added"
|
||
|
||
def test_delete_finding(self):
|
||
vendor = _create_vendor()
|
||
finding = _create_finding(vendor["id"])
|
||
resp = client.delete(f"/api/compliance/vendor-compliance/findings/{finding['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["deleted"] is True
|
||
|
||
|
||
class TestFindingFilter:
|
||
def test_filter_by_severity(self):
|
||
vendor = _create_vendor()
|
||
_create_finding(vendor["id"], severity="HIGH")
|
||
_create_finding(vendor["id"], severity="LOW")
|
||
resp = client.get("/api/compliance/vendor-compliance/findings?severity=HIGH")
|
||
assert len(resp.json()["data"]) == 1
|
||
|
||
def test_filter_by_vendor_id(self):
|
||
v1 = _create_vendor(name="V1")
|
||
v2 = _create_vendor(name="V2")
|
||
_create_finding(v1["id"])
|
||
_create_finding(v2["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/findings?vendor_id={v1['id']}")
|
||
assert len(resp.json()["data"]) == 1
|
||
|
||
|
||
# =============================================================================
|
||
# Control Instance Tests
|
||
# =============================================================================
|
||
|
||
class TestControlInstancesCRUD:
|
||
def test_list_control_instances_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/control-instances")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"] == []
|
||
|
||
def test_create_control_instance(self):
|
||
vendor = _create_vendor()
|
||
ci = _create_control_instance(vendor["id"])
|
||
assert ci["vendorId"] == vendor["id"]
|
||
assert ci["controlId"] == "C-001"
|
||
assert ci["status"] == "PASS"
|
||
|
||
def test_get_control_instance(self):
|
||
vendor = _create_vendor()
|
||
ci = _create_control_instance(vendor["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/control-instances/{ci['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["controlDomain"] == "priv"
|
||
|
||
def test_update_control_instance(self):
|
||
vendor = _create_vendor()
|
||
ci = _create_control_instance(vendor["id"])
|
||
resp = client.put(
|
||
f"/api/compliance/vendor-compliance/control-instances/{ci['id']}",
|
||
json={"status": "FAIL", "notes": "Needs remediation"}
|
||
)
|
||
assert resp.status_code == 200
|
||
updated = resp.json()["data"]
|
||
assert updated["status"] == "FAIL"
|
||
assert updated["notes"] == "Needs remediation"
|
||
|
||
def test_delete_control_instance(self):
|
||
vendor = _create_vendor()
|
||
ci = _create_control_instance(vendor["id"])
|
||
resp = client.delete(f"/api/compliance/vendor-compliance/control-instances/{ci['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"]["deleted"] is True
|
||
|
||
|
||
class TestControlInstanceFilter:
|
||
def test_filter_by_vendor_id(self):
|
||
v1 = _create_vendor(name="V1")
|
||
v2 = _create_vendor(name="V2")
|
||
_create_control_instance(v1["id"])
|
||
_create_control_instance(v2["id"])
|
||
resp = client.get(f"/api/compliance/vendor-compliance/control-instances?vendor_id={v1['id']}")
|
||
assert len(resp.json()["data"]) == 1
|
||
|
||
|
||
# =============================================================================
|
||
# Controls Library Tests
|
||
# =============================================================================
|
||
|
||
class TestControlsLibrary:
|
||
def test_list_controls_empty(self):
|
||
resp = client.get("/api/compliance/vendor-compliance/controls")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["data"] == []
|
||
|
||
def test_create_control(self):
|
||
resp = client.post("/api/compliance/vendor-compliance/controls", json={
|
||
"domain": "priv",
|
||
"controlCode": "PRIV-001",
|
||
"title": "Datenschutz-Folgenabschaetzung",
|
||
"description": "Art. 35 DSGVO Compliance"
|
||
})
|
||
assert resp.status_code == 201
|
||
ctrl = resp.json()["data"]
|
||
assert ctrl["domain"] == "priv"
|
||
assert ctrl["controlCode"] == "PRIV-001"
|
||
|
||
def test_delete_control(self):
|
||
resp = client.post("/api/compliance/vendor-compliance/controls", json={
|
||
"domain": "iam", "controlCode": "IAM-001", "title": "Access Control"
|
||
})
|
||
ctrl_id = resp.json()["data"]["id"]
|
||
resp2 = client.delete(f"/api/compliance/vendor-compliance/controls/{ctrl_id}")
|
||
assert resp2.status_code == 200
|
||
assert resp2.json()["data"]["deleted"] is True
|
||
|
||
|
||
# =============================================================================
|
||
# Export Stub Tests
|
||
# =============================================================================
|
||
|
||
class TestExportStubs:
|
||
def test_post_export_501(self):
|
||
resp = client.post("/api/compliance/vendor-compliance/export", json={})
|
||
assert resp.status_code == 501
|
||
assert resp.json()["success"] is False
|
||
|
||
def test_get_export_501(self):
|
||
resp = client.get(f"/api/compliance/vendor-compliance/export/{uuid.uuid4()}")
|
||
assert resp.status_code == 501
|
||
|
||
def test_download_export_501(self):
|
||
resp = client.get(f"/api/compliance/vendor-compliance/export/{uuid.uuid4()}/download")
|
||
assert resp.status_code == 501
|