feat(compliance): add GDPR, HIPAA, SOC2 compliance templates with one-click apply and dashboard

- Add ComplianceTemplate model in app/models.py
- Create database migration 027_add_compliance_templates
- Add compliance_enabled feature flag to config and settings metadata
- Create compliance_service.py with pre-built template definitions and evaluation
- Create compliance API endpoints (list, get, apply, status, summary)
- Create compliance admin view and dashboard template
- Add compliance link to admin navigation (desktop and mobile)
- Seed compliance templates at application startup
- Add comprehensive tests (29 passing)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-09 23:55:29 +00:00
parent 341dad643f
commit 666f739f4e
14 changed files with 1573 additions and 0 deletions
+1
View File
@@ -61,6 +61,7 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
ComplianceTemplate,
DocumentMetadata,
FileRecord,
Pipeline,
+501
View File
@@ -0,0 +1,501 @@
"""
Tests for the compliance templates feature.
Covers:
- app/models.py ComplianceTemplate model
- app/utils/compliance_service.py service functions (seed, evaluate, apply)
- app/api/compliance.py REST API endpoints
- app/views/compliance.py admin view route
"""
from unittest.mock import Mock, patch
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ComplianceTemplate
from app.utils.compliance_service import (
COMPLIANCE_TEMPLATES,
apply_template,
evaluate_template_status,
get_all_templates,
get_compliance_summary,
get_template_by_name,
seed_compliance_templates,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def ct_engine():
"""In-memory SQLite engine for compliance template tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def ct_session(ct_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=ct_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def ct_client(ct_engine):
"""TestClient with in-memory DB and admin override."""
from app.api.compliance import _require_admin
from app.main import app
def override_db():
Session = sessionmaker(bind=ct_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def seeded_session(ct_session):
"""Session with compliance templates already seeded."""
seed_compliance_templates(ct_session)
return ct_session
@pytest.fixture()
def seeded_client(ct_engine):
"""TestClient with seeded compliance templates."""
from app.api.compliance import _require_admin
from app.main import app
Session = sessionmaker(bind=ct_engine)
session = Session()
seed_compliance_templates(session)
session.close()
def override_db():
session = Session()
try:
yield session
finally:
session.close()
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceTemplateModel:
"""Tests for the ComplianceTemplate database model."""
def test_create_template(self, ct_session):
"""Test creating a compliance template."""
template = ComplianceTemplate(
name="test_template",
display_name="Test Template",
description="A test compliance template",
settings_json='{"auth_enabled": "True"}',
enabled=False,
status="not_applied",
)
ct_session.add(template)
ct_session.commit()
assert template.id is not None
assert template.name == "test_template"
assert template.display_name == "Test Template"
assert template.enabled is False
assert template.status == "not_applied"
def test_unique_name_constraint(self, ct_session):
"""Test that template names must be unique."""
t1 = ComplianceTemplate(
name="unique_test",
display_name="First",
settings_json="{}",
)
ct_session.add(t1)
ct_session.commit()
t2 = ComplianceTemplate(
name="unique_test",
display_name="Second",
settings_json="{}",
)
ct_session.add(t2)
with pytest.raises(Exception):
ct_session.commit()
ct_session.rollback()
def test_default_values(self, ct_session):
"""Test default column values."""
template = ComplianceTemplate(
name="defaults_test",
display_name="Defaults",
settings_json="{}",
)
ct_session.add(template)
ct_session.commit()
assert template.enabled is False
assert template.status == "not_applied"
assert template.applied_at is None
assert template.applied_by is None
# ---------------------------------------------------------------------------
# Service tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceService:
"""Tests for compliance_service utility functions."""
def test_seed_creates_templates(self, ct_session):
"""Test that seeding creates all three compliance templates."""
seed_compliance_templates(ct_session)
templates = ct_session.query(ComplianceTemplate).all()
names = {t.name for t in templates}
assert "gdpr" in names
assert "hipaa" in names
assert "soc2" in names
assert len(templates) == 3
def test_seed_is_idempotent(self, ct_session):
"""Test that seeding twice does not create duplicates."""
seed_compliance_templates(ct_session)
seed_compliance_templates(ct_session)
templates = ct_session.query(ComplianceTemplate).all()
assert len(templates) == 3
def test_seed_updates_display_name(self, ct_session):
"""Test that re-seeding updates display_name but preserves state."""
seed_compliance_templates(ct_session)
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
gdpr.enabled = True
ct_session.commit()
seed_compliance_templates(ct_session)
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
assert gdpr.enabled is True # User state preserved
def test_get_all_templates(self, seeded_session):
"""Test getting all templates."""
result = get_all_templates(seeded_session)
assert len(result) == 3
for t in result:
assert "id" in t
assert "name" in t
assert "display_name" in t
assert "checks" in t
assert "check_count" in t
def test_get_template_by_name_exists(self, seeded_session):
"""Test retrieving an existing template by name."""
result = get_template_by_name(seeded_session, "gdpr")
assert result is not None
assert result.name == "gdpr"
def test_get_template_by_name_missing(self, seeded_session):
"""Test retrieving a non-existent template."""
result = get_template_by_name(seeded_session, "nonexistent")
assert result is None
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_evaluate_template_compliant(self, mock_settings, seeded_session):
"""Test evaluation when all checks pass."""
mock_settings.return_value = {
"auth_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
}
result = evaluate_template_status(seeded_session, "gdpr")
assert result["status"] == "compliant"
assert result["passed"] == result["total"]
assert result["failed"] == 0
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_evaluate_template_non_compliant(self, mock_settings, seeded_session):
"""Test evaluation when no checks pass."""
mock_settings.return_value = {}
with patch("app.config.settings") as mock_app:
mock_app.auth_enabled = False
mock_app.sentry_send_default_pii = True
mock_app.security_headers_enabled = False
mock_app.security_header_hsts_enabled = False
mock_app.security_header_csp_enabled = False
mock_app.security_header_x_frame_options_enabled = False
mock_app.enable_deduplication = False
result = evaluate_template_status(seeded_session, "gdpr")
assert result["status"] in ("non_compliant", "partial")
assert result["failed"] > 0
def test_evaluate_unknown_template(self, seeded_session):
"""Test evaluation of a non-existent template name."""
result = evaluate_template_status(seeded_session, "unknown")
assert result["status"] == "unknown"
assert result["total"] == 0
@patch("app.utils.settings_service.save_setting_to_db")
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_apply_template_success(self, mock_get_settings, mock_save, seeded_session):
"""Test successfully applying a template."""
mock_save.return_value = True
mock_get_settings.return_value = {
"auth_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
}
result = apply_template(seeded_session, "gdpr", applied_by="test@admin.com")
assert result["success"] is True
assert result["template"] == "gdpr"
assert "applied_settings" in result
# Verify template record updated
gdpr = seeded_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
assert gdpr.enabled is True
assert gdpr.applied_by == "test@admin.com"
assert gdpr.applied_at is not None
def test_apply_unknown_template(self, seeded_session):
"""Test applying a non-existent template."""
result = apply_template(seeded_session, "nonexistent")
assert result["success"] is False
assert "error" in result
@patch("app.utils.settings_service.get_all_settings_from_db")
def test_get_compliance_summary(self, mock_settings, seeded_session):
"""Test compliance summary across all templates."""
mock_settings.return_value = {}
result = get_compliance_summary(seeded_session)
assert "overall_status" in result
assert "total_checks" in result
assert "total_passed" in result
assert "total_failed" in result
assert "templates" in result
assert len(result["templates"]) == 3
def test_compliance_templates_have_checks(self):
"""Test that all built-in templates have compliance checks."""
for name, defn in COMPLIANCE_TEMPLATES.items():
assert "checks" in defn, f"Template {name} missing checks"
assert len(defn["checks"]) > 0, f"Template {name} has no checks"
for check in defn["checks"]:
assert "key" in check
assert "expected" in check
assert "label" in check
assert "description" in check
def test_compliance_templates_have_settings(self):
"""Test that all built-in templates have settings to apply."""
for name, defn in COMPLIANCE_TEMPLATES.items():
assert "settings" in defn, f"Template {name} missing settings"
assert len(defn["settings"]) > 0, f"Template {name} has no settings"
# ---------------------------------------------------------------------------
# API tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestComplianceAPI:
"""Tests for compliance API endpoints."""
def test_list_templates(self, seeded_client):
"""Test GET /api/compliance/templates."""
resp = seeded_client.get("/api/compliance/templates")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert isinstance(data, list)
assert len(data) == 3
names = {t["name"] for t in data}
assert names == {"gdpr", "hipaa", "soc2"}
def test_get_single_template(self, seeded_client):
"""Test GET /api/compliance/templates/gdpr."""
resp = seeded_client.get("/api/compliance/templates/gdpr")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert data["name"] == "gdpr"
assert "display_name" in data
assert "checks" in data
def test_get_nonexistent_template(self, seeded_client):
"""Test GET /api/compliance/templates/unknown returns 404."""
resp = seeded_client.get("/api/compliance/templates/unknown")
assert resp.status_code == status.HTTP_404_NOT_FOUND
def test_get_template_status(self, seeded_client):
"""Test GET /api/compliance/templates/gdpr/status."""
resp = seeded_client.get("/api/compliance/templates/gdpr/status")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert "status" in data
assert "total" in data
assert "passed" in data
assert "failed" in data
assert "check_results" in data
def test_apply_template(self, seeded_client):
"""Test POST /api/compliance/templates/gdpr/apply."""
resp = seeded_client.post("/api/compliance/templates/gdpr/apply")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert data["success"] is True
assert data["template"] == "gdpr"
assert "applied_settings" in data
def test_apply_nonexistent_template(self, seeded_client):
"""Test POST /api/compliance/templates/unknown/apply returns 404."""
resp = seeded_client.post("/api/compliance/templates/unknown/apply")
assert resp.status_code == status.HTTP_404_NOT_FOUND
def test_compliance_summary(self, seeded_client):
"""Test GET /api/compliance/summary."""
resp = seeded_client.get("/api/compliance/summary")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert "overall_status" in data
assert "total_checks" in data
assert "templates" in data
assert len(data["templates"]) == 3
def test_templates_require_admin(self, ct_engine):
"""Test that endpoints require admin access."""
from app.main import app
def override_db():
Session = sessionmaker(bind=ct_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
# Do NOT override _require_admin so it checks session
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
resp = client.get("/api/compliance/templates")
assert resp.status_code == status.HTTP_403_FORBIDDEN
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# View tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceView:
"""Tests for the compliance view route."""
@patch("app.views.compliance.templates")
@patch("app.views.compliance.settings")
@pytest.mark.asyncio
async def test_compliance_page_admin(self, mock_settings, mock_templates):
"""Test compliance page renders for admin users."""
from app.views.compliance import compliance_page
mock_settings.version = "1.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
await compliance_page(mock_request)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "compliance.html"
@pytest.mark.asyncio
async def test_compliance_page_non_admin_redirects(self):
"""Test compliance page redirects non-admin users."""
from app.views.compliance import compliance_page
mock_request = Mock()
mock_request.session = {"user": {"id": "user1", "is_admin": False}}
result = await compliance_page(mock_request)
assert result.status_code == 302
# ---------------------------------------------------------------------------
# Config / settings metadata tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComplianceConfig:
"""Tests for compliance configuration settings."""
def test_compliance_enabled_default(self):
"""Test that compliance_enabled defaults to True."""
from app.config import settings
assert hasattr(settings, "compliance_enabled")
assert settings.compliance_enabled is True
def test_compliance_enabled_in_setting_metadata(self):
"""Test that compliance_enabled has SETTING_METADATA entry."""
from app.utils.settings_service import SETTING_METADATA
assert "compliance_enabled" in SETTING_METADATA
meta = SETTING_METADATA["compliance_enabled"]
assert meta["category"] == "Feature Flags"
assert meta["type"] == "boolean"