e59db34a6d
- K-REG: GET /api/v1/compliance/ai-registry — lists all agents with ai_use_case_metadata - K-DPIA: GET /api/v1/compliance/dpia-template — pre-filled DPIA template export - K-INC: ComplianceIncident model, Migration 0133 (RLS), CRUD routes (admin-only) - K-RET: GET/PATCH /api/v1/compliance/retention-policies — 5 policies editable - K-COMP-TEST: 12/12 integration tests pass - K-DOC: docs/compliance.md — Betriebsdoku - Frontend: ComplianceTab.tsx in SettingsAI.tsx (new tab) - 13 files created/modified
503 lines
21 KiB
Python
503 lines
21 KiB
Python
"""Phase K Compliance Tests — integration tests with real DB.
|
|
|
|
Covers:
|
|
- K-REG: AI registry lists all agents with metadata
|
|
- K-DPIA: DPIA template export
|
|
- K-INC: Incident CRUD (create, list, update)
|
|
- K-RET: Retention policies list
|
|
- Tenant isolation for compliance incidents
|
|
- Admin-only enforcement (non-admin gets 403)
|
|
|
|
Uses the real PostgreSQL test DB (conftest fixtures).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
|
|
from app.core.db import close_engine, reset_engine_for_testing
|
|
from app.core.permission_registry import init_permission_registry
|
|
from app.core.service_container import get_container
|
|
from app.main import create_app
|
|
from app.models.compliance import ComplianceIncident
|
|
from app.models.system_settings import SystemSettings
|
|
from app.models.tenant import Tenant
|
|
from app.models.user import User
|
|
from app.plugins.builtins.automation.models import AgentDefinition
|
|
from app.plugins.registry import reset_registry_for_testing
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session", autouse=True)
|
|
async def _ensure_compliance_tables(engine: AsyncEngine):
|
|
"""Create compliance_incidents table and ensure system_settings has required columns."""
|
|
from app.core.db import Base
|
|
from sqlalchemy import text
|
|
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
# Ensure system_settings has backup_enabled and retention_config columns
|
|
# (test DB may have been created from an older schema)
|
|
result = await conn.execute(text(
|
|
"SELECT column_name FROM information_schema.columns "
|
|
"WHERE table_name='system_settings' AND column_name='backup_enabled'"
|
|
))
|
|
if result.rowcount == 0:
|
|
await conn.execute(text(
|
|
"ALTER TABLE system_settings ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT false"
|
|
))
|
|
result = await conn.execute(text(
|
|
"SELECT column_name FROM information_schema.columns "
|
|
"WHERE table_name='system_settings' AND column_name='retention_config'"
|
|
))
|
|
if result.rowcount == 0:
|
|
await conn.execute(text(
|
|
"ALTER TABLE system_settings ADD COLUMN retention_config JSONB DEFAULT '{}'::jsonb"
|
|
))
|
|
yield
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _init_perms():
|
|
"""Ensure permission registry is initialized for every test."""
|
|
init_permission_registry(
|
|
active_plugin_names={
|
|
"permissions",
|
|
"automation",
|
|
}
|
|
)
|
|
yield
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# API fixtures
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def compliance_app(engine: AsyncEngine, redis_client):
|
|
"""FastAPI app with automation + permissions plugins registered and active."""
|
|
reset_engine_for_testing(engine)
|
|
app = create_app()
|
|
registry = reset_registry_for_testing()
|
|
registry.initialize(engine, app)
|
|
init_permission_registry(
|
|
active_plugin_names={
|
|
"permissions",
|
|
"automation",
|
|
}
|
|
)
|
|
container = get_container()
|
|
await container.initialize()
|
|
|
|
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
|
from app.plugins.builtins.automation.plugin import AutomationPlugin
|
|
|
|
registry.register_plugin(PermissionsPlugin())
|
|
registry.register_plugin(AutomationPlugin())
|
|
reset_plugin_service_for_testing(registry)
|
|
|
|
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
|
async with sf() as session:
|
|
await registry.install(session, "permissions")
|
|
await registry.activate(session, "permissions")
|
|
await registry.install(session, "automation")
|
|
await registry.activate(session, "automation")
|
|
await session.commit()
|
|
|
|
yield app
|
|
await close_engine()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def compliance_client(compliance_app) -> AsyncClient:
|
|
"""HTTP test client with compliance routes available."""
|
|
transport = ASGITransport(app=compliance_app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def admin_authed_client(
|
|
compliance_client: AsyncClient, db_session: AsyncSession
|
|
) -> tuple[AsyncClient, dict]:
|
|
"""Authenticated admin client with seeded data."""
|
|
seed = await seed_tenant_and_users(db_session)
|
|
# Grant is_system_admin so require_permission('system:admin') passes
|
|
await db_session.execute(
|
|
update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True)
|
|
)
|
|
await db_session.commit()
|
|
await login_client(compliance_client, "admin@tenanta.com")
|
|
return compliance_client, seed
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def viewer_authed_client(
|
|
compliance_client: AsyncClient, db_session: AsyncSession
|
|
) -> tuple[AsyncClient, dict]:
|
|
"""Authenticated non-admin (viewer) client."""
|
|
seed = await seed_tenant_and_users(db_session)
|
|
await login_client(compliance_client, "viewer@tenanta.com")
|
|
return compliance_client, seed
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _create_agent(db, tenant_id, user_id, **overrides):
|
|
"""Create a real AgentDefinition in the DB and return it."""
|
|
agent = AgentDefinition(
|
|
tenant_id=tenant_id,
|
|
name=overrides.get("name", "Test Agent"),
|
|
description=overrides.get("description", "A test agent"),
|
|
system_prompt=overrides.get("system_prompt", "You are a helpful assistant."),
|
|
tool_ids=overrides.get("tool_ids", []),
|
|
temperature=overrides.get("temperature", 0.3),
|
|
max_tokens=overrides.get("max_tokens", 1000),
|
|
max_steps=overrides.get("max_steps", 20),
|
|
created_by=user_id,
|
|
ai_use_case_metadata=overrides.get("ai_use_case_metadata", {}),
|
|
)
|
|
db.add(agent)
|
|
await db.flush()
|
|
return agent
|
|
|
|
|
|
async def _create_system_settings(db, tenant_id):
|
|
"""Create minimal system settings for a tenant."""
|
|
settings = SystemSettings(
|
|
tenant_id=tenant_id,
|
|
company_name="Test Company",
|
|
company_street="Test St",
|
|
company_city="Test City",
|
|
company_zip="12345",
|
|
company_country="DE",
|
|
)
|
|
db.add(settings)
|
|
await db.flush()
|
|
return settings
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# K-REG: AI Registry Tests
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAIRegistry:
|
|
async def test_ai_registry_lists_all_agents(self, admin_authed_client):
|
|
"""GET /api/v1/compliance/ai-registry returns all agents with metadata."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create an agent via the automation API with use-case metadata
|
|
resp = await client.post(
|
|
"/api/v1/agents/",
|
|
json={
|
|
"name": "Compliance Test Agent",
|
|
"description": "Test agent for compliance",
|
|
"system_prompt": "You are a test assistant.",
|
|
"ai_use_case_metadata": {
|
|
"intended_purpose": "Test purpose",
|
|
"owner": "admin@test.com",
|
|
"risk_class": "medium",
|
|
"oversight_policy": "on_high_risk",
|
|
"data_categories": ["contact_data"],
|
|
"human_review_required": True,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 201, f"Agent creation failed: {resp.text}"
|
|
|
|
# Now query the AI registry
|
|
resp = await client.get("/api/v1/compliance/ai-registry")
|
|
assert resp.status_code == 200, f"AI registry failed: {resp.text}"
|
|
data = resp.json()
|
|
assert "items" in data
|
|
assert "total" in data
|
|
assert data["total"] >= 1
|
|
|
|
# Check the agent appears with metadata
|
|
found = False
|
|
for item in data["items"]:
|
|
if item["name"] == "Compliance Test Agent":
|
|
found = True
|
|
meta = item["ai_use_case_metadata"]
|
|
assert meta["intended_purpose"] == "Test purpose"
|
|
assert meta["owner"] == "admin@test.com"
|
|
assert meta["risk_class"] == "medium"
|
|
assert meta["oversight_policy"] == "on_high_risk"
|
|
assert "contact_data" in meta["data_categories"]
|
|
assert meta["human_review_required"] is True
|
|
assert "validation_warnings" in item
|
|
break
|
|
assert found, "Created agent not found in AI registry"
|
|
|
|
async def test_ai_registry_admin_only(self, viewer_authed_client):
|
|
"""Non-admin user gets 403 on AI registry."""
|
|
client, seed = viewer_authed_client
|
|
resp = await client.get("/api/v1/compliance/ai-registry")
|
|
assert resp.status_code == 403, f"Expected 403, got {resp.status_code}: {resp.text}"
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# K-DPIA: DPIA Template Tests
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDPIATemplate:
|
|
async def test_dpia_template_export(self, admin_authed_client):
|
|
"""GET /api/v1/compliance/dpia-template returns pre-filled template."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create an agent with metadata
|
|
resp = await client.post(
|
|
"/api/v1/agents/",
|
|
json={
|
|
"name": "DPIA Test Agent",
|
|
"description": "Agent for DPIA test",
|
|
"system_prompt": "You are a test assistant.",
|
|
"ai_use_case_metadata": {
|
|
"intended_purpose": "Email summarization for CRM",
|
|
"owner": "dpo@test.com",
|
|
"risk_class": "high",
|
|
"oversight_policy": "always_required",
|
|
"data_categories": ["email_content", "contact_data"],
|
|
"allowed_actions": ["read", "summarize"],
|
|
"human_review_required": True,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 201, f"Agent creation failed: {resp.text}"
|
|
agent_id = resp.json()["id"]
|
|
|
|
# Get DPIA template
|
|
resp = await client.get(
|
|
"/api/v1/compliance/dpia-template",
|
|
params={"agent_id": agent_id},
|
|
)
|
|
assert resp.status_code == 200, f"DPIA template failed: {resp.text}"
|
|
data = resp.json()
|
|
|
|
assert data["use_case_id"] == agent_id
|
|
assert data["agent_name"] == "DPIA Test Agent"
|
|
assert data["intended_purpose"] == "Email summarization for CRM"
|
|
assert data["owner"] == "dpo@test.com"
|
|
assert data["risk_class"] == "high"
|
|
assert data["oversight_policy"] == "always_required"
|
|
assert "email_content" in data["data_categories"]
|
|
assert "contact_data" in data["data_categories"]
|
|
assert data["human_review_required"] is True
|
|
assert "disclaimer" in data
|
|
assert "NOT" in data["disclaimer"] or "not" in data["disclaimer"].lower()
|
|
assert "validation_warnings" in data
|
|
|
|
async def test_dpia_template_not_found(self, admin_authed_client):
|
|
"""GET /api/v1/compliance/dpia-template with invalid agent_id returns 404."""
|
|
client, seed = admin_authed_client
|
|
fake_id = str(uuid.uuid4())
|
|
resp = await client.get(
|
|
"/api/v1/compliance/dpia-template",
|
|
params={"agent_id": fake_id},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# K-INC: Incident Register Tests
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIncidentCRUD:
|
|
async def test_incident_crud(self, admin_authed_client):
|
|
"""POST/GET/PATCH /api/v1/compliance/incidents full lifecycle."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create
|
|
resp = await client.post(
|
|
"/api/v1/compliance/incidents",
|
|
json={
|
|
"incident_type": "ai",
|
|
"title": "Test AI Incident",
|
|
"description": "An AI system produced biased output",
|
|
"provider": "openai",
|
|
"measures_taken": "Disabled agent, investigating",
|
|
"status": "open",
|
|
},
|
|
)
|
|
assert resp.status_code == 201, f"Incident creation failed: {resp.text}"
|
|
incident = resp.json()
|
|
assert incident["title"] == "Test AI Incident"
|
|
assert incident["incident_type"] == "ai"
|
|
assert incident["status"] == "open"
|
|
assert incident["id"] is not None
|
|
incident_id = incident["id"]
|
|
|
|
# List
|
|
resp = await client.get("/api/v1/compliance/incidents")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] >= 1
|
|
found = any(i["id"] == incident_id for i in data["items"])
|
|
assert found, "Created incident not found in list"
|
|
|
|
# Update (resolve)
|
|
resp = await client.patch(
|
|
f"/api/v1/compliance/incidents/{incident_id}",
|
|
json={"status": "resolved", "measures_taken": "Fixed by retraining"},
|
|
)
|
|
assert resp.status_code == 200, f"Incident update failed: {resp.text}"
|
|
updated = resp.json()
|
|
assert updated["status"] == "resolved"
|
|
assert updated["measures_taken"] == "Fixed by retraining"
|
|
assert updated["resolved_at"] is not None
|
|
assert updated["resolved_by"] is not None
|
|
|
|
async def test_incident_admin_only(self, viewer_authed_client):
|
|
"""Non-admin user gets 403 on incidents."""
|
|
client, seed = viewer_authed_client
|
|
|
|
# GET
|
|
resp = await client.get("/api/v1/compliance/incidents")
|
|
assert resp.status_code == 403, f"Expected 403, got {resp.status_code}: {resp.text}"
|
|
|
|
# POST
|
|
resp = await client.post(
|
|
"/api/v1/compliance/incidents",
|
|
json={"title": "Should Fail", "incident_type": "ai"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
async def test_incident_invalid_type(self, admin_authed_client):
|
|
"""POST with invalid incident_type returns 400."""
|
|
client, seed = admin_authed_client
|
|
resp = await client.post(
|
|
"/api/v1/compliance/incidents",
|
|
json={"title": "Bad Type", "incident_type": "invalid_type"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
async def test_incident_not_found(self, admin_authed_client):
|
|
"""PATCH with non-existent incident returns 404."""
|
|
client, seed = admin_authed_client
|
|
fake_id = str(uuid.uuid4())
|
|
resp = await client.patch(
|
|
f"/api/v1/compliance/incidents/{fake_id}",
|
|
json={"status": "resolved"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestTenantIsolation:
|
|
async def test_tenant_isolation(self, admin_authed_client, db_session):
|
|
"""Compliance incidents are tenant-isolated."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create incident in tenant A
|
|
resp = await client.post(
|
|
"/api/v1/compliance/incidents",
|
|
json={"title": "Tenant A Incident", "incident_type": "ai"},
|
|
)
|
|
assert resp.status_code == 201
|
|
incident_a_id = resp.json()["id"]
|
|
|
|
# Create incident directly in tenant B via DB
|
|
incident_b = ComplianceIncident(
|
|
tenant_id=seed["tenant_b"].id,
|
|
incident_type="privacy",
|
|
title="Tenant B Incident",
|
|
)
|
|
db_session.add(incident_b)
|
|
await db_session.flush()
|
|
await db_session.commit()
|
|
|
|
# List incidents — should only see tenant A's
|
|
resp = await client.get("/api/v1/compliance/incidents")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
titles = [i["title"] for i in data["items"]]
|
|
assert "Tenant A Incident" in titles
|
|
assert "Tenant B Incident" not in titles
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# K-RET: Retention Policies Tests
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestRetentionPolicies:
|
|
async def test_retention_policies_list(self, admin_authed_client, db_session):
|
|
"""GET /api/v1/compliance/retention-policies returns all policies."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create system settings for the tenant
|
|
await _create_system_settings(db_session, seed["tenant_a"].id)
|
|
await db_session.commit()
|
|
|
|
resp = await client.get("/api/v1/compliance/retention-policies")
|
|
assert resp.status_code == 200, f"Retention policies failed: {resp.text}"
|
|
data = resp.json()
|
|
assert "items" in data
|
|
assert data["total"] == 5 # audit_log, backup, trash, knowledge, agent_memory
|
|
|
|
keys = [p["key"] for p in data["items"]]
|
|
assert "audit_log" in keys
|
|
assert "backup" in keys
|
|
assert "trash" in keys
|
|
assert "knowledge" in keys
|
|
assert "agent_memory" in keys
|
|
|
|
# Check default values
|
|
for policy in data["items"]:
|
|
assert policy["current_days"] == policy["default_days"]
|
|
assert policy["editable"] is True
|
|
|
|
async def test_retention_policy_update(self, admin_authed_client, db_session):
|
|
"""PATCH /api/v1/compliance/retention-policies/{key} updates days."""
|
|
client, seed = admin_authed_client
|
|
|
|
# Create system settings
|
|
await _create_system_settings(db_session, seed["tenant_a"].id)
|
|
await db_session.commit()
|
|
|
|
resp = await client.patch(
|
|
"/api/v1/compliance/retention-policies/audit_log",
|
|
json={"days": 180},
|
|
)
|
|
assert resp.status_code == 200, f"Retention update failed: {resp.text}"
|
|
data = resp.json()
|
|
assert data["key"] == "audit_log"
|
|
assert data["days"] == 180
|
|
|
|
# Verify the update is reflected
|
|
resp = await client.get("/api/v1/compliance/retention-policies")
|
|
assert resp.status_code == 200
|
|
policies = resp.json()["items"]
|
|
audit_policy = next(p for p in policies if p["key"] == "audit_log")
|
|
assert audit_policy["current_days"] == 180
|
|
|
|
async def test_retention_policy_invalid_key(self, admin_authed_client, db_session):
|
|
"""PATCH with invalid key returns 400."""
|
|
client, seed = admin_authed_client
|
|
|
|
await _create_system_settings(db_session, seed["tenant_a"].id)
|
|
await db_session.commit()
|
|
|
|
resp = await client.patch(
|
|
"/api/v1/compliance/retention-policies/invalid_key",
|
|
json={"days": 30},
|
|
)
|
|
assert resp.status_code == 400
|