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
+2
View File
@@ -11,6 +11,7 @@ from app.api.api_tokens import router as api_tokens_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
from app.api.compliance import router as compliance_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
from app.api.dropbox import router as dropbox_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
router.include_router(compliance_router)
+179
View File
@@ -0,0 +1,179 @@
"""API endpoints for managing compliance templates (GDPR, HIPAA, SOC2).
All endpoints require admin privileges.
Available routes:
GET /api/compliance/templates list all compliance templates
GET /api/compliance/templates/{name} get a single template with checks
POST /api/compliance/templates/{name}/apply one-click apply a template
GET /api/compliance/templates/{name}/status evaluate compliance status
GET /api/compliance/summary overall compliance dashboard data
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.utils.compliance_service import (
apply_template,
evaluate_template_status,
get_all_templates,
get_compliance_summary,
get_template_by_name,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance", tags=["compliance"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Authorisation helper
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin; raises HTTP 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic response models
# ---------------------------------------------------------------------------
class CheckResult(BaseModel):
"""Individual compliance check result."""
key: str
label: str
description: str
expected: str
actual: str
passing: bool
class TemplateStatusResponse(BaseModel):
"""Status evaluation for a compliance template."""
status: str
total: int
passed: int
failed: int
check_results: list[CheckResult]
class TemplateResponse(BaseModel):
"""Full compliance template representation."""
id: int
name: str
display_name: str
description: str | None
enabled: bool
status: str
applied_at: str | None
applied_by: str | None
settings: dict[str, str]
checks: list[dict[str, Any]]
check_count: int
class ApplyResponse(BaseModel):
"""Result of applying a compliance template."""
success: bool
template: str | None = None
applied_settings: dict[str, str] | None = None
errors: list[str] | None = None
error: str | None = None
status: TemplateStatusResponse | None = None
class SummaryTemplateResponse(BaseModel):
"""Per-template summary for the compliance dashboard."""
name: str
display_name: str
enabled: bool
status: str
total: int
passed: int
failed: int
applied_at: str | None
applied_by: str | None
class ComplianceSummaryResponse(BaseModel):
"""Overall compliance dashboard summary."""
overall_status: str
total_checks: int
total_passed: int
total_failed: int
templates: list[SummaryTemplateResponse]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/templates", response_model=list[TemplateResponse])
async def list_templates(db: DbSession, admin: AdminUser) -> list[dict[str, Any]]:
"""List all compliance templates with their current status."""
return get_all_templates(db)
@router.get("/templates/{name}", response_model=TemplateResponse)
async def get_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Get a single compliance template by name."""
templates = get_all_templates(db)
for t in templates:
if t["name"] == name:
return t
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
@router.post("/templates/{name}/apply", response_model=ApplyResponse)
async def apply_compliance_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Apply a compliance template (one-click).
Writes all template settings to the database and evaluates the resulting
compliance status.
"""
template = get_template_by_name(db, name)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
admin_email = admin.get("email", "admin")
result = apply_template(db, name, applied_by=admin_email)
if not result.get("success") and result.get("error"):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
return result
@router.get("/templates/{name}/status", response_model=TemplateStatusResponse)
async def get_template_status(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Evaluate the live compliance status of a template."""
template = get_template_by_name(db, name)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
return evaluate_template_status(db, name)
@router.get("/summary", response_model=ComplianceSummaryResponse)
async def compliance_summary(db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Overall compliance dashboard summary across all templates."""
return get_compliance_summary(db)
+8
View File
@@ -484,6 +484,14 @@ class Settings(BaseSettings):
# Feature flags
allow_file_delete: bool = True # Default to allowing file deletion from database
compliance_enabled: bool = Field(
default=True,
description=(
"Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
"When enabled, admins can view compliance status and apply "
"pre-built regulatory configurations. Default: True."
),
)
# PDF/A archival conversion settings
enable_pdfa_conversion: bool = Field(
+14
View File
@@ -146,6 +146,20 @@ async def lifespan(app: FastAPI):
except Exception:
logging.debug("Scheduled jobs seeding skipped — DB may not be ready yet") # noqa: S110
# Seed the built-in compliance templates (GDPR, HIPAA, SOC2) so they
# are available in the admin compliance dashboard on first startup.
try:
from app.database import SessionLocal as _SessionLocal # noqa: F811
from app.utils.compliance_service import seed_compliance_templates as _seed_compliance
_db_compliance = _SessionLocal()
try:
_seed_compliance(_db_compliance)
finally:
_db_compliance.close()
except Exception:
logging.debug("Compliance template seeding skipped — DB may not be ready yet") # noqa: S110
# Application is now running
yield
+23
View File
@@ -833,3 +833,26 @@ class ScheduledJob(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ComplianceTemplate(Base):
"""Pre-built compliance configuration templates (GDPR, HIPAA, SOC2).
Each row represents an applied compliance template. The ``settings_json``
column stores the concrete setting key/value pairs that were written when
the template was applied. ``status`` tracks the current compliance posture.
"""
__tablename__ = "compliance_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False, index=True) # GDPR, HIPAA, SOC2
display_name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
settings_json = Column(Text, nullable=False, default="{}") # JSON of applied settings
enabled = Column(Boolean, nullable=False, default=False)
status = Column(String(20), nullable=False, default="not_applied") # not_applied, compliant, partial, non_compliant
applied_at = Column(DateTime(timezone=True), nullable=True)
applied_by = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+433
View File
@@ -0,0 +1,433 @@
"""Compliance service for managing GDPR, HIPAA, and SOC2 compliance templates.
Provides pre-built compliance configurations that can be applied with one click
to ensure the DocuElevate instance meets regulatory requirements.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.models import ComplianceTemplate
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pre-built compliance template definitions
# ---------------------------------------------------------------------------
COMPLIANCE_TEMPLATES: dict[str, dict[str, Any]] = {
"gdpr": {
"display_name": "GDPR (General Data Protection Regulation)",
"description": (
"European Union regulation for data protection and privacy. "
"Enforces data minimisation, encryption at rest, audit logging, "
"and limits PII exposure in telemetry."
),
"settings": {
"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",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "User authentication must be enabled to control access to personal data.",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Personally identifiable information must not be sent to external monitoring services.",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "HTTP security headers protect against common web vulnerabilities.",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "HTTP Strict Transport Security ensures encrypted connections.",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "CSP headers prevent cross-site scripting and data injection attacks.",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "X-Frame-Options header prevents clickjacking attacks.",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Data minimisation: avoid storing duplicate documents.",
},
],
},
"hipaa": {
"display_name": "HIPAA (Health Insurance Portability and Accountability Act)",
"description": (
"United States regulation for protecting health information. "
"Requires strong access controls, audit trails, encryption, "
"and strict session management."
),
"settings": {
"auth_enabled": "True",
"multi_user_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",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "Access controls are required to protect electronic Protected Health Information (ePHI).",
},
{
"key": "multi_user_enabled",
"expected": "True",
"label": "Multi-user mode enabled",
"description": "Individual user accounts required for access accountability.",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Protected Health Information must not be sent to external services.",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "Security headers protect ePHI during transmission.",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "Encrypted transport required for all ePHI transmissions.",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "CSP prevents injection attacks that could expose ePHI.",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "Prevents embedding the application in unauthorized frames.",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Minimise data footprint for ePHI.",
},
],
},
"soc2": {
"display_name": "SOC 2 (Service Organization Control 2)",
"description": (
"Trust Service Criteria framework for service organisations. "
"Focuses on security, availability, processing integrity, "
"confidentiality, and privacy."
),
"settings": {
"auth_enabled": "True",
"multi_user_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",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "Logical access controls required (CC6.1).",
},
{
"key": "multi_user_enabled",
"expected": "True",
"label": "Multi-user mode enabled",
"description": "Individual user accounts for access management (CC6.2).",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Confidential information must not leak to external services (CC6.7).",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "Protection against common web threats (CC6.6).",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "Encrypted transport in transit (CC6.7).",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "Application-level security controls (CC6.6).",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "UI redress attack prevention (CC6.6).",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Data integrity through deduplication (PI1.1).",
},
],
},
}
def seed_compliance_templates(db: Session) -> None:
"""Create or update the built-in compliance template rows.
Called once at application startup to ensure the ``compliance_templates``
table always contains the latest definitions.
"""
for name, defn in COMPLIANCE_TEMPLATES.items():
existing = db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
if existing is None:
template = ComplianceTemplate(
name=name,
display_name=defn["display_name"],
description=defn["description"],
settings_json=json.dumps(defn["settings"]),
enabled=False,
status="not_applied",
)
db.add(template)
logger.info(f"Seeded compliance template: {name}")
else:
# Update display_name and description if changed, but preserve user state
existing.display_name = defn["display_name"]
existing.description = defn["description"]
try:
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to seed compliance templates")
def get_all_templates(db: Session) -> list[dict[str, Any]]:
"""Return all compliance templates with their current status."""
templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
result = []
for t in templates:
defn = COMPLIANCE_TEMPLATES.get(t.name, {})
checks = defn.get("checks", [])
result.append(
{
"id": t.id,
"name": t.name,
"display_name": t.display_name,
"description": t.description,
"enabled": t.enabled,
"status": t.status,
"applied_at": t.applied_at.isoformat() if t.applied_at else None,
"applied_by": t.applied_by,
"settings": json.loads(t.settings_json) if t.settings_json else {},
"checks": checks,
"check_count": len(checks),
}
)
return result
def get_template_by_name(db: Session, name: str) -> ComplianceTemplate | None:
"""Retrieve a single compliance template by name."""
return db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
def evaluate_template_status(db: Session, name: str) -> dict[str, Any]:
"""Evaluate the compliance status of a template against live settings.
Returns a dict with ``status``, ``total``, ``passed``, ``failed``, and
a list of individual ``check_results``.
"""
from app.config import settings as app_settings
from app.utils.settings_service import get_all_settings_from_db
defn = COMPLIANCE_TEMPLATES.get(name)
if defn is None:
return {"status": "unknown", "total": 0, "passed": 0, "failed": 0, "check_results": []}
db_settings = get_all_settings_from_db(db)
checks = defn.get("checks", [])
results: list[dict[str, Any]] = []
passed = 0
for check in checks:
key = check["key"]
expected = check["expected"]
# Resolve effective value: DB > config object
if key in db_settings and db_settings[key] is not None:
actual = str(db_settings[key])
else:
actual = str(getattr(app_settings, key, ""))
is_passing = actual.lower() == expected.lower()
if is_passing:
passed += 1
results.append(
{
"key": key,
"label": check["label"],
"description": check["description"],
"expected": expected,
"actual": actual,
"passing": is_passing,
}
)
total = len(checks)
if passed == total:
status = "compliant"
elif passed > 0:
status = "partial"
else:
status = "non_compliant"
return {
"status": status,
"total": total,
"passed": passed,
"failed": total - passed,
"check_results": results,
}
def apply_template(db: Session, name: str, applied_by: str = "admin") -> dict[str, Any]:
"""Apply a compliance template by writing its settings to the database.
Returns a summary of what was applied.
"""
from app.utils.settings_service import save_setting_to_db
defn = COMPLIANCE_TEMPLATES.get(name)
if defn is None:
return {"success": False, "error": f"Unknown template: {name}"}
template = get_template_by_name(db, name)
if template is None:
return {"success": False, "error": f"Template not found in database: {name}"}
applied_settings: dict[str, str] = {}
errors: list[str] = []
for key, value in defn["settings"].items():
try:
save_setting_to_db(db, key, value, changed_by=f"compliance:{name}")
applied_settings[key] = value
except Exception as e:
errors.append(f"{key}: {e}")
logger.error(f"Failed to apply compliance setting {key}={value}: {e}")
# Update the template record
now = datetime.now(timezone.utc)
template.enabled = True
template.settings_json = json.dumps(applied_settings)
template.applied_at = now
template.applied_by = applied_by
# Evaluate and store status
eval_result = evaluate_template_status(db, name)
template.status = eval_result["status"]
try:
db.commit()
except Exception:
db.rollback()
logger.exception(f"Failed to update compliance template record: {name}")
return {"success": False, "error": "Database commit failed"}
logger.info(f"Applied compliance template '{name}' by {applied_by}: {len(applied_settings)} settings written")
return {
"success": len(errors) == 0,
"template": name,
"applied_settings": applied_settings,
"errors": errors,
"status": eval_result,
}
def get_compliance_summary(db: Session) -> dict[str, Any]:
"""Return a high-level compliance dashboard summary across all templates."""
templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
summary: list[dict[str, Any]] = []
total_checks = 0
total_passed = 0
for t in templates:
eval_result = evaluate_template_status(db, t.name)
total_checks += eval_result["total"]
total_passed += eval_result["passed"]
summary.append(
{
"name": t.name,
"display_name": t.display_name,
"enabled": t.enabled,
"status": eval_result["status"],
"total": eval_result["total"],
"passed": eval_result["passed"],
"failed": eval_result["failed"],
"applied_at": t.applied_at.isoformat() if t.applied_at else None,
"applied_by": t.applied_by,
}
)
overall = "compliant" if total_checks > 0 and total_passed == total_checks else "non_compliant"
if 0 < total_passed < total_checks:
overall = "partial"
return {
"overall_status": overall,
"total_checks": total_checks,
"total_passed": total_passed,
"total_failed": total_checks - total_passed,
"templates": summary,
}
+12
View File
@@ -1595,6 +1595,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"compliance_enabled": {
"category": "Feature Flags",
"description": (
"Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
"When enabled, admins can view compliance status and apply "
"pre-built regulatory configurations. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Backup / Restore
"backup_enabled": {
"category": "Backup",
+2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter
from app.views.admin_users import router as admin_users_router
from app.views.api_tokens import router as api_tokens_router
from app.views.backup import router as backup_router
from app.views.compliance import router as compliance_router
from app.views.db_wizard import router as db_wizard_router
from app.views.dropbox import router as dropbox_router
from app.views.filemanager import router as filemanager_router
@@ -61,3 +62,4 @@ router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
router.include_router(help_router) # Built-in help / How-To docs
router.include_router(compliance_router) # Compliance templates dashboard
+48
View File
@@ -0,0 +1,48 @@
"""Admin view: compliance templates dashboard page."""
import logging
from fastapi import HTTPException, Request, status
from fastapi.responses import RedirectResponse
from app.views.base import APIRouter, require_login, settings, templates
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request):
"""Return the session user if they are an admin, else None."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
logger.warning("Non-admin user attempted to access /admin/compliance")
return None
return user
@router.get("/admin/compliance")
@require_login
async def compliance_page(request: Request):
"""Admin compliance templates dashboard page.
Displays GDPR, HIPAA, and SOC2 compliance templates with their current
status and one-click apply functionality.
"""
user = _require_admin(request)
if user is None:
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
try:
return templates.TemplateResponse(
"compliance.html",
{
"request": request,
"app_version": settings.version,
},
)
except Exception as e:
logger.error(f"Error loading compliance page: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load compliance page",
)
+6
View File
@@ -168,6 +168,9 @@
<a href="/admin/scheduled-jobs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-clock w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Scheduled Jobs
</a>
<a href="/admin/compliance" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-check-double w-4 mr-2 text-green-500" aria-hidden="true"></i> Compliance
</a>
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> Backup &amp; Restore
</a>
@@ -338,6 +341,9 @@
<a href="/admin/scheduled-jobs" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-clock mr-2 text-indigo-500" aria-hidden="true"></i> Scheduled Jobs
</a>
<a href="/admin/compliance" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-check-double mr-2 text-green-500" aria-hidden="true"></i> Compliance
</a>
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> Backup &amp; Restore
</a>
+300
View File
@@ -0,0 +1,300 @@
{% extends "base.html" %}
{% block title %}Compliance Templates - DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6" x-data="complianceApp()">
<!-- Header -->
<div class="mb-6">
<div class="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
<div>
<h1 class="text-3xl font-bold mb-1">Compliance Templates</h1>
<p class="text-gray-500 text-sm">
Pre-built compliance configurations for GDPR, HIPAA, and SOC 2.
Apply templates with one click to align your instance with regulatory requirements.
</p>
</div>
<button @click="refreshAll()"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
:disabled="loading"
aria-label="Refresh compliance status">
<i class="fas fa-sync-alt mr-2" :class="loading ? 'animate-spin' : ''" aria-hidden="true"></i> Refresh
</button>
</div>
</div>
<!-- Overall Status Banner -->
<div class="mb-6 rounded-lg p-5 shadow"
:class="{
'bg-green-50 border border-green-200': summary.overall_status === 'compliant',
'bg-yellow-50 border border-yellow-200': summary.overall_status === 'partial',
'bg-red-50 border border-red-200': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
}">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-xl"
:class="{
'bg-green-100 text-green-600': summary.overall_status === 'compliant',
'bg-yellow-100 text-yellow-600': summary.overall_status === 'partial',
'bg-red-100 text-red-600': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
}">
<i :class="{
'fas fa-check-circle': summary.overall_status === 'compliant',
'fas fa-exclamation-triangle': summary.overall_status === 'partial',
'fas fa-times-circle': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
}" aria-hidden="true"></i>
</span>
<div>
<h2 class="text-lg font-semibold"
:class="{
'text-green-800': summary.overall_status === 'compliant',
'text-yellow-800': summary.overall_status === 'partial',
'text-red-800': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
}">
<span x-text="summary.overall_status === 'compliant' ? 'All Checks Passing' :
summary.overall_status === 'partial' ? 'Partially Compliant' :
'Non-Compliant'"></span>
</h2>
<p class="text-sm text-gray-600">
<span x-text="summary.total_passed"></span> of <span x-text="summary.total_checks"></span> checks passing across all templates
</p>
</div>
</div>
<div class="flex gap-4 text-center">
<div>
<div class="text-2xl font-bold text-green-700" x-text="summary.total_passed"></div>
<div class="text-xs text-gray-500">Passed</div>
</div>
<div>
<div class="text-2xl font-bold text-red-700" x-text="summary.total_failed"></div>
<div class="text-xs text-gray-500">Failed</div>
</div>
<div>
<div class="text-2xl font-bold text-gray-700" x-text="summary.total_checks"></div>
<div class="text-xs text-gray-500">Total</div>
</div>
</div>
</div>
</div>
<!-- Alert Messages -->
<div x-show="alertMessage" x-cloak
class="mb-4 rounded-lg p-4 flex items-center gap-3"
:class="alertType === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
'bg-red-50 border border-red-200 text-red-800'"
role="alert">
<i :class="alertType === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" aria-hidden="true"></i>
<span x-text="alertMessage"></span>
<button @click="alertMessage = ''" class="ml-auto text-gray-400 hover:text-gray-600" aria-label="Dismiss alert">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Template Cards -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<template x-for="tmpl in templates" :key="tmpl.name">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<!-- Card Header -->
<div class="p-5 border-b"
:class="{
'border-green-200 bg-green-50': tmpl._status === 'compliant',
'border-yellow-200 bg-yellow-50': tmpl._status === 'partial',
'border-gray-200': tmpl._status === 'not_applied' || tmpl._status === 'non_compliant'
}">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-bold text-gray-800" x-text="tmpl.display_name"></h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold"
:class="{
'bg-green-100 text-green-800': tmpl._status === 'compliant',
'bg-yellow-100 text-yellow-800': tmpl._status === 'partial',
'bg-red-100 text-red-800': tmpl._status === 'non_compliant',
'bg-gray-100 text-gray-600': tmpl._status === 'not_applied'
}">
<span x-text="tmpl._status === 'compliant' ? 'Compliant' :
tmpl._status === 'partial' ? 'Partial' :
tmpl._status === 'non_compliant' ? 'Non-Compliant' :
'Not Applied'"></span>
</span>
</div>
<p class="text-sm text-gray-600" x-text="tmpl.description"></p>
</div>
<!-- Check Results (when expanded) -->
<div class="p-5" x-show="tmpl._expanded" x-collapse>
<h4 class="text-sm font-semibold text-gray-700 mb-3">
<i class="fas fa-clipboard-check mr-1" aria-hidden="true"></i>
Compliance Checks (<span x-text="tmpl._statusData ? tmpl._statusData.passed : '?'"></span>/<span x-text="tmpl._statusData ? tmpl._statusData.total : '?'"></span>)
</h4>
<ul class="space-y-2">
<template x-for="check in (tmpl._statusData ? tmpl._statusData.check_results : [])" :key="check.key">
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 flex-shrink-0">
<i :class="check.passing ? 'fas fa-check-circle text-green-500' : 'fas fa-times-circle text-red-500'" aria-hidden="true"></i>
</span>
<div>
<span class="font-medium" x-text="check.label"></span>
<p class="text-xs text-gray-500" x-text="check.description"></p>
<p class="text-xs mt-0.5" x-show="!check.passing">
<span class="text-red-600">Current: <code x-text="check.actual"></code></span>
<span class="text-gray-400 mx-1"></span>
<span class="text-green-600">Expected: <code x-text="check.expected"></code></span>
</p>
</div>
</li>
</template>
</ul>
</div>
<!-- Card Footer -->
<div class="px-5 py-4 bg-gray-50 border-t flex items-center justify-between gap-2">
<button @click="toggleExpand(tmpl)"
class="inline-flex items-center px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
:aria-expanded="tmpl._expanded"
:aria-label="'Toggle details for ' + tmpl.display_name">
<i class="fas mr-1" :class="tmpl._expanded ? 'fa-chevron-up' : 'fa-chevron-down'" aria-hidden="true"></i>
<span x-text="tmpl._expanded ? 'Hide Details' : 'Show Details'"></span>
</button>
<button @click="applyTemplate(tmpl.name)"
:disabled="tmpl._applying"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 min-h-[44px] min-w-[44px]"
:class="tmpl._applying ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
:aria-label="'Apply ' + tmpl.display_name + ' template'">
<i class="fas mr-1.5" :class="tmpl._applying ? 'fa-spinner animate-spin' : 'fa-bolt'" aria-hidden="true"></i>
<span x-text="tmpl._applying ? 'Applying…' : 'Apply Template'"></span>
</button>
</div>
<!-- Applied info -->
<div x-show="tmpl.applied_at" class="px-5 py-2 bg-gray-50 border-t text-xs text-gray-500">
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
Last applied: <span x-text="tmpl.applied_at ? new Date(tmpl.applied_at).toLocaleString() : 'Never'"></span>
<span x-show="tmpl.applied_by"> by <span x-text="tmpl.applied_by"></span></span>
</div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="!loading && templates.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-shield-alt text-4xl mb-4" aria-hidden="true"></i>
<p class="text-lg font-medium">No compliance templates available</p>
<p class="text-sm">Templates will appear here after seeding.</p>
</div>
<!-- Loading Spinner -->
<div x-show="loading && templates.length === 0" class="text-center py-12">
<i class="fas fa-spinner animate-spin text-3xl text-blue-500" aria-hidden="true"></i>
<p class="text-gray-500 mt-2">Loading compliance templates…</p>
</div>
</div>
<script>
function complianceApp() {
return {
templates: [],
summary: { overall_status: 'unknown', total_checks: 0, total_passed: 0, total_failed: 0 },
loading: true,
alertMessage: '',
alertType: 'success',
async init() {
await this.refreshAll();
},
async refreshAll() {
this.loading = true;
try {
const [templatesRes, summaryRes] = await Promise.all([
fetch('/api/compliance/templates'),
fetch('/api/compliance/summary')
]);
if (templatesRes.ok) {
const data = await templatesRes.json();
this.templates = data.map(t => ({
...t,
_expanded: false,
_applying: false,
_status: t.status || 'not_applied',
_statusData: null
}));
// Fetch status for each template
for (const tmpl of this.templates) {
this.fetchTemplateStatus(tmpl);
}
}
if (summaryRes.ok) {
this.summary = await summaryRes.json();
}
} catch (err) {
console.error('Failed to load compliance data:', err);
this.showAlert('error', 'Failed to load compliance data. Please try again.');
} finally {
this.loading = false;
}
},
async fetchTemplateStatus(tmpl) {
try {
const res = await fetch(`/api/compliance/templates/${tmpl.name}/status`);
if (res.ok) {
tmpl._statusData = await res.json();
tmpl._status = tmpl._statusData.status;
}
} catch (err) {
console.error(`Failed to fetch status for ${tmpl.name}:`, err);
}
},
async toggleExpand(tmpl) {
tmpl._expanded = !tmpl._expanded;
if (tmpl._expanded && !tmpl._statusData) {
await this.fetchTemplateStatus(tmpl);
}
},
async applyTemplate(name) {
const tmpl = this.templates.find(t => t.name === name);
if (!tmpl) return;
if (!confirm(`Apply the ${tmpl.display_name} compliance template?\n\nThis will update your application settings to meet ${name.toUpperCase()} requirements.`)) {
return;
}
tmpl._applying = true;
this.alertMessage = '';
try {
const res = await fetch(`/api/compliance/templates/${name}/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (res.ok && data.success) {
this.showAlert('success', `${tmpl.display_name} template applied successfully.`);
await this.refreshAll();
} else {
this.showAlert('error', data.detail || data.error || 'Failed to apply template.');
}
} catch (err) {
console.error(`Failed to apply ${name}:`, err);
this.showAlert('error', 'Network error. Please try again.');
} finally {
tmpl._applying = false;
}
},
showAlert(type, message) {
this.alertType = type;
this.alertMessage = message;
if (type === 'success') {
setTimeout(() => { this.alertMessage = ''; }, 5000);
}
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,44 @@
"""Add compliance_templates table for GDPR, HIPAA, SOC2 compliance templates.
Revision ID: 027_add_compliance_templates
Revises: 026_add_scheduled_jobs
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "027_add_compliance_templates"
down_revision: Union[str, None] = "026_add_scheduled_jobs"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create compliance_templates table."""
op.create_table(
"compliance_templates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(50), nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("settings_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("status", sa.String(20), nullable=False, server_default="not_applied"),
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("applied_by", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_compliance_templates_name"),
)
op.create_index("ix_compliance_templates_id", "compliance_templates", ["id"])
op.create_index("ix_compliance_templates_name", "compliance_templates", ["name"])
def downgrade() -> None:
"""Drop compliance_templates table."""
op.drop_index("ix_compliance_templates_name", "compliance_templates")
op.drop_index("ix_compliance_templates_id", "compliance_templates")
op.drop_table("compliance_templates")
+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"