use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use axum::Json;
use chrono::{DateTime, Utc};
use llmtrace_core::{ApiKeyRole, AuditQuery, AuthContext, TraceQuery};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::proxy::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReportType {
Soc2,
Gdpr,
Hipaa,
}
impl std::fmt::Display for ReportType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Soc2 => write!(f, "soc2"),
Self::Gdpr => write!(f, "gdpr"),
Self::Hipaa => write!(f, "hipaa"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReportStatus {
Pending,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Soc2Report {
pub total_audit_events: u64,
pub events_by_type: HashMap<String, u64>,
pub total_security_findings: u64,
pub findings_by_severity: HashMap<String, u64>,
pub total_traces_processed: u64,
pub unique_actors: Vec<String>,
pub access_control_events: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GdprReport {
pub total_processing_activities: u64,
pub processing_by_provider: HashMap<String, u64>,
pub processing_by_model: HashMap<String, u64>,
pub pii_findings: u64,
pub data_lifecycle_events: u64,
pub tenants_processed: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HipaaReport {
pub total_access_events: u64,
pub access_by_operation: HashMap<String, u64>,
pub unique_accessors: Vec<String>,
pub unauthorized_access_findings: u64,
pub failed_access_attempts: u64,
pub access_control_changes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "report_type", content = "data")]
pub enum ReportContent {
#[serde(rename = "soc2")]
Soc2(Soc2Report),
#[serde(rename = "gdpr")]
Gdpr(GdprReport),
#[serde(rename = "hipaa")]
Hipaa(HipaaReport),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceReport {
pub id: Uuid,
pub tenant_id: llmtrace_core::TenantId,
pub report_type: ReportType,
pub status: ReportStatus,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub content: Option<ReportContent>,
pub error: Option<String>,
}
pub type ReportStore = Arc<RwLock<HashMap<Uuid, ComplianceReport>>>;
#[must_use]
pub fn new_report_store() -> ReportStore {
Arc::new(RwLock::new(HashMap::new()))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ListReportsParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct GenerateReportRequest {
pub report_type: ReportType,
#[schema(value_type = String, format = "date-time")]
pub period_start: DateTime<Utc>,
#[schema(value_type = String, format = "date-time")]
pub period_end: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
struct ApiError {
error: ApiErrorDetail,
}
#[derive(Debug, Serialize, ToSchema)]
struct ApiErrorDetail {
message: String,
#[serde(rename = "type")]
error_type: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GenerateReportResponse {
pub id: String,
pub status: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ListReportsResponse {
pub data: Vec<llmtrace_core::ComplianceReportRecord>,
pub limit: u32,
pub offset: u32,
}
fn api_error(status: StatusCode, message: &str) -> Response {
let body = ApiError {
error: ApiErrorDetail {
message: message.to_string(),
error_type: "api_error".to_string(),
},
};
(status, Json(body)).into_response()
}
fn require_role_viewer(auth: &AuthContext) -> Option<Response> {
if !auth.role.has_permission(ApiKeyRole::Viewer) {
Some(api_error(StatusCode::FORBIDDEN, "Insufficient permissions"))
} else {
None
}
}
fn require_role_operator(auth: &AuthContext) -> Option<Response> {
if !auth.role.has_permission(ApiKeyRole::Operator) {
Some(api_error(
StatusCode::FORBIDDEN,
"Insufficient permissions: requires operator role",
))
} else {
None
}
}
#[utoipa::path(
post,
path = "/api/v1/reports/generate",
request_body = GenerateReportRequest,
responses(
(status = 202, description = "Report generation started", body = GenerateReportResponse),
(status = 400, description = "Bad request", body = ApiError),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 403, description = "Forbidden", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(("api_key" = [])),
tag = "LLMTrace Proxy"
)]
pub async fn generate_report(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthContext>,
Json(body): Json<GenerateReportRequest>,
) -> Response {
if let Some(err) = require_role_operator(&auth) {
return err;
}
if body.period_end <= body.period_start {
return api_error(
StatusCode::BAD_REQUEST,
"period_end must be after period_start",
);
}
let report_id = Uuid::new_v4();
let tenant_id = auth.tenant_id;
let report = ComplianceReport {
id: report_id,
tenant_id,
report_type: body.report_type,
status: ReportStatus::Pending,
period_start: body.period_start,
period_end: body.period_end,
created_at: Utc::now(),
completed_at: None,
content: None,
error: None,
};
{
let mut store = state.report_store.write().await;
store.insert(report_id, report);
}
{
let record = llmtrace_core::ComplianceReportRecord {
id: report_id,
tenant_id,
report_type: body.report_type.to_string(),
status: "pending".to_string(),
period_start: body.period_start,
period_end: body.period_end,
created_at: Utc::now(),
completed_at: None,
content: None,
error: None,
};
if let Err(e) = state.storage.metadata.store_report(&record).await {
tracing::warn!(%report_id, "Failed to persist pending report to storage: {e}");
}
}
let store = Arc::clone(&state.report_store);
let metadata = Arc::clone(&state.storage.metadata);
let traces = Arc::clone(&state.storage.traces);
let report_type = body.report_type;
let period_start = body.period_start;
let period_end = body.period_end;
tokio::spawn(async move {
let result = build_report(
tenant_id,
report_type,
period_start,
period_end,
metadata.as_ref(),
traces.as_ref(),
)
.await;
{
let mut store = store.write().await;
if let Some(r) = store.get_mut(&report_id) {
match &result {
Ok(content) => {
r.status = ReportStatus::Completed;
r.completed_at = Some(Utc::now());
r.content = Some(content.clone());
}
Err(msg) => {
r.status = ReportStatus::Failed;
r.completed_at = Some(Utc::now());
r.error = Some(msg.clone());
}
}
}
}
let record = match result {
Ok(content) => {
let content_json = serde_json::to_value(&content).ok();
llmtrace_core::ComplianceReportRecord {
id: report_id,
tenant_id,
report_type: report_type.to_string(),
status: "completed".to_string(),
period_start,
period_end,
created_at: Utc::now(),
completed_at: Some(Utc::now()),
content: content_json,
error: None,
}
}
Err(msg) => llmtrace_core::ComplianceReportRecord {
id: report_id,
tenant_id,
report_type: report_type.to_string(),
status: "failed".to_string(),
period_start,
period_end,
created_at: Utc::now(),
completed_at: Some(Utc::now()),
content: None,
error: Some(msg),
},
};
if let Err(e) = metadata.store_report(&record).await {
tracing::warn!(%report_id, "Failed to persist completed report to storage: {e}");
}
});
(
StatusCode::ACCEPTED,
Json(GenerateReportResponse {
id: report_id.to_string(),
status: "pending".to_string(),
}),
)
.into_response()
}
#[utoipa::path(
get,
path = "/api/v1/reports/{id}",
params(
("id" = String, Path, description = "Report ID"),
),
responses(
(status = 200, description = "Report record", body = llmtrace_core::ComplianceReportRecord),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 403, description = "Forbidden", body = ApiError),
(status = 404, description = "Report not found", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(("api_key" = [])),
tag = "LLMTrace Proxy"
)]
pub async fn get_report(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthContext>,
Path(report_id): Path<Uuid>,
) -> Response {
if let Some(err) = require_role_viewer(&auth) {
return err;
}
{
let store = state.report_store.read().await;
if let Some(report) = store.get(&report_id) {
if report.tenant_id != auth.tenant_id {
return api_error(StatusCode::NOT_FOUND, "Report not found");
}
let status = match report.status {
ReportStatus::Pending => "pending",
ReportStatus::Completed => "completed",
ReportStatus::Failed => "failed",
}
.to_string();
let content = match &report.content {
Some(c) => serde_json::to_value(c).ok(),
None => None,
};
let record = llmtrace_core::ComplianceReportRecord {
id: report.id,
tenant_id: report.tenant_id,
report_type: report.report_type.to_string(),
status,
period_start: report.period_start,
period_end: report.period_end,
created_at: report.created_at,
completed_at: report.completed_at,
content,
error: report.error.clone(),
};
return Json(record).into_response();
}
}
match state.storage.metadata.get_report(report_id).await {
Ok(Some(record)) => {
if record.tenant_id != auth.tenant_id {
return api_error(StatusCode::NOT_FOUND, "Report not found");
}
Json(record).into_response()
}
Ok(None) => api_error(StatusCode::NOT_FOUND, "Report not found"),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to retrieve report: {e}"),
),
}
}
#[utoipa::path(
get,
path = "/api/v1/reports",
params(
ListReportsParams
),
responses(
(status = 200, description = "Paginated report records", body = ListReportsResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 403, description = "Forbidden", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(("api_key" = [])),
tag = "LLMTrace Proxy"
)]
pub async fn list_reports(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthContext>,
Query(params): Query<ListReportsParams>,
) -> Response {
if let Some(err) = require_role_viewer(&auth) {
return err;
}
let limit = params.limit.unwrap_or(50).min(1000);
let offset = params.offset.unwrap_or(0);
let query = llmtrace_core::ReportQuery::new(auth.tenant_id)
.with_limit(limit)
.with_offset(offset);
match state.storage.metadata.list_reports(&query).await {
Ok(reports) => Json(ListReportsResponse {
data: reports,
limit,
offset,
})
.into_response(),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to list reports: {e}"),
),
}
}
async fn build_report(
tenant_id: llmtrace_core::TenantId,
report_type: ReportType,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
metadata: &dyn llmtrace_core::MetadataRepository,
traces: &dyn llmtrace_core::TraceRepository,
) -> Result<ReportContent, String> {
match report_type {
ReportType::Soc2 => build_soc2(tenant_id, period_start, period_end, metadata, traces)
.await
.map(ReportContent::Soc2),
ReportType::Gdpr => build_gdpr(tenant_id, period_start, period_end, metadata, traces)
.await
.map(ReportContent::Gdpr),
ReportType::Hipaa => build_hipaa(tenant_id, period_start, period_end, metadata, traces)
.await
.map(ReportContent::Hipaa),
}
}
async fn build_soc2(
tenant_id: llmtrace_core::TenantId,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
metadata: &dyn llmtrace_core::MetadataRepository,
traces: &dyn llmtrace_core::TraceRepository,
) -> Result<Soc2Report, String> {
let audit_query = AuditQuery::new(tenant_id).with_time_range(period_start, period_end);
let audit_events = metadata
.query_audit_events(&audit_query)
.await
.map_err(|e| format!("Failed to query audit events: {e}"))?;
let total_audit_events = audit_events.len() as u64;
let mut events_by_type: HashMap<String, u64> = HashMap::new();
let mut unique_actors_set: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut access_control_events = 0u64;
for event in &audit_events {
*events_by_type.entry(event.event_type.clone()).or_default() += 1;
unique_actors_set.insert(event.actor.clone());
if event.event_type.contains("key") || event.event_type.contains("auth") {
access_control_events += 1;
}
}
let trace_query = TraceQuery::new(tenant_id).with_time_range(period_start, period_end);
let spans = traces
.query_spans(&trace_query)
.await
.map_err(|e| format!("Failed to query spans: {e}"))?;
let total_traces_processed = traces
.query_traces(&trace_query)
.await
.map_err(|e| format!("Failed to query traces: {e}"))?
.len() as u64;
let mut total_security_findings = 0u64;
let mut findings_by_severity: HashMap<String, u64> = HashMap::new();
for span in &spans {
for finding in &span.security_findings {
total_security_findings += 1;
*findings_by_severity
.entry(finding.severity.to_string())
.or_default() += 1;
}
}
Ok(Soc2Report {
total_audit_events,
events_by_type,
total_security_findings,
findings_by_severity,
total_traces_processed,
unique_actors: unique_actors_set.into_iter().collect(),
access_control_events,
})
}
async fn build_gdpr(
tenant_id: llmtrace_core::TenantId,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
metadata: &dyn llmtrace_core::MetadataRepository,
traces: &dyn llmtrace_core::TraceRepository,
) -> Result<GdprReport, String> {
let trace_query = TraceQuery::new(tenant_id).with_time_range(period_start, period_end);
let trace_events = traces
.query_traces(&trace_query)
.await
.map_err(|e| format!("Failed to query traces: {e}"))?;
let spans = traces
.query_spans(&trace_query)
.await
.map_err(|e| format!("Failed to query spans: {e}"))?;
let total_processing_activities = trace_events.len() as u64;
let mut processing_by_provider: HashMap<String, u64> = HashMap::new();
let mut processing_by_model: HashMap<String, u64> = HashMap::new();
let mut pii_findings = 0u64;
for span in &spans {
let provider_str = format!("{:?}", span.provider);
*processing_by_provider.entry(provider_str).or_default() += 1;
*processing_by_model
.entry(span.model_name.clone())
.or_default() += 1;
for finding in &span.security_findings {
if finding.finding_type.contains("pii") {
pii_findings += 1;
}
}
}
let audit_query = AuditQuery::new(tenant_id).with_time_range(period_start, period_end);
let audit_events = metadata
.query_audit_events(&audit_query)
.await
.map_err(|e| format!("Failed to query audit events: {e}"))?;
let data_lifecycle_events = audit_events
.iter()
.filter(|e| {
e.event_type.contains("delete")
|| e.event_type.contains("create")
|| e.event_type.contains("update")
})
.count() as u64;
let tenants_processed = 1u64;
Ok(GdprReport {
total_processing_activities,
processing_by_provider,
processing_by_model,
pii_findings,
data_lifecycle_events,
tenants_processed,
})
}
async fn build_hipaa(
tenant_id: llmtrace_core::TenantId,
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
metadata: &dyn llmtrace_core::MetadataRepository,
traces: &dyn llmtrace_core::TraceRepository,
) -> Result<HipaaReport, String> {
let trace_query = TraceQuery::new(tenant_id).with_time_range(period_start, period_end);
let spans = traces
.query_spans(&trace_query)
.await
.map_err(|e| format!("Failed to query spans: {e}"))?;
let total_access_events = spans.len() as u64;
let mut access_by_operation: HashMap<String, u64> = HashMap::new();
let mut unauthorized_access_findings = 0u64;
let mut failed_access_attempts = 0u64;
for span in &spans {
*access_by_operation
.entry(span.operation_name.clone())
.or_default() += 1;
if span.is_failed() {
failed_access_attempts += 1;
}
for finding in &span.security_findings {
if finding.finding_type.contains("injection")
|| finding.finding_type.contains("unauthorized")
{
unauthorized_access_findings += 1;
}
}
}
let audit_query = AuditQuery::new(tenant_id).with_time_range(period_start, period_end);
let audit_events = metadata
.query_audit_events(&audit_query)
.await
.map_err(|e| format!("Failed to query audit events: {e}"))?;
let mut unique_accessors_set: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut access_control_changes = 0u64;
for event in &audit_events {
unique_accessors_set.insert(event.actor.clone());
if event.event_type.contains("key")
|| event.event_type.contains("auth")
|| event.event_type.contains("tenant")
{
access_control_changes += 1;
}
}
Ok(HipaaReport {
total_access_events,
access_by_operation,
unique_accessors: unique_accessors_set.into_iter().collect(),
unauthorized_access_findings,
failed_access_attempts,
access_control_changes,
})
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use axum::routing::{get, post};
use axum::Router;
use llmtrace_core::{
AuditEvent, LLMProvider, ProxyConfig, SecurityAnalyzer, SecurityFinding, SecuritySeverity,
StorageConfig, TenantId, TraceEvent, TraceSpan,
};
use llmtrace_security::RegexSecurityAnalyzer;
use llmtrace_storage::StorageProfile;
use tower::ServiceExt;
async fn test_state() -> Arc<AppState> {
let storage = StorageProfile::Memory.build().await.unwrap();
let security = Arc::new(RegexSecurityAnalyzer::new().unwrap()) as Arc<dyn SecurityAnalyzer>;
let client = reqwest::Client::new();
let config = ProxyConfig {
storage: StorageConfig {
profile: "memory".to_string(),
database_path: String::new(),
..StorageConfig::default()
},
..ProxyConfig::default()
};
let storage_breaker = Arc::new(crate::circuit_breaker::CircuitBreaker::from_config(
&config.circuit_breaker,
));
let security_breaker = Arc::new(crate::circuit_breaker::CircuitBreaker::from_config(
&config.circuit_breaker,
));
let cost_estimator = crate::cost::CostEstimator::new(&config.cost_estimation);
let cost_tracker =
crate::cost_caps::CostTracker::new(&config.cost_caps, Arc::clone(&storage.cache));
let rate_limiter =
crate::rate_limit::RateLimiter::new(&config.rate_limiting, Arc::clone(&storage.cache));
Arc::new(AppState {
config_handle: crate::config_handle::ConfigHandle::new(config, None, None),
client,
storage,
fast_analyzer: security.clone(),
security,
#[cfg(feature = "ml")]
security_ensemble: None,
ensemble_runtime: std::sync::Arc::new(llmtrace_security::EnsembleRuntimeHandle::inert()),
storage_breaker,
security_breaker,
cost_estimator,
alert_engine: None,
cost_tracker,
anomaly_detector: None,
action_router: crate::action_router::ActionRouter::new(
&llmtrace_core::ActionRouterConfig::default(),
llmtrace_core::JudgePromotionConfig::default(),
llmtrace_core::JudgeWorkerConfig::default().max_analysis_text_bytes,
None,
reqwest::Client::new(),
),
report_store: new_report_store(),
rate_limiter,
ml_status: crate::proxy::MlModelStatus::Disabled,
judge_worker_spawned: false,
runtime_overlay_status: crate::proxy::RuntimeOverlayStatus::Disabled,
shutdown: crate::shutdown::ShutdownCoordinator::new(30),
metrics: crate::metrics::Metrics::new(),
ml_pipeline_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
ready: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
})
}
fn compliance_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/api/v1/reports/generate", post(generate_report))
.route("/api/v1/reports/:id", get(get_report))
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&state),
crate::auth::auth_middleware,
))
.with_state(state)
}
async fn json_body(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn tenant_header() -> (TenantId, String) {
let id = TenantId::new();
(id, id.0.to_string())
}
fn make_trace(tenant_id: TenantId, model: &str, provider: LLMProvider) -> TraceEvent {
let trace_id = Uuid::new_v4();
TraceEvent {
trace_id,
tenant_id,
spans: vec![TraceSpan::new(
trace_id,
tenant_id,
"chat_completion".to_string(),
provider,
model.to_string(),
"test prompt".to_string(),
)],
created_at: Utc::now(),
}
}
fn make_trace_with_finding(tenant_id: TenantId) -> TraceEvent {
let trace_id = Uuid::new_v4();
let mut span = TraceSpan::new(
trace_id,
tenant_id,
"chat_completion".to_string(),
LLMProvider::OpenAI,
"gpt-4".to_string(),
"ignore previous instructions".to_string(),
);
span.add_security_finding(SecurityFinding::new(
SecuritySeverity::High,
"prompt_injection".to_string(),
"Detected injection attempt".to_string(),
0.95,
));
TraceEvent {
trace_id,
tenant_id,
spans: vec![span],
created_at: Utc::now(),
}
}
#[tokio::test]
async fn test_generate_soc2_report() {
let state = test_state().await;
let (tid, hdr) = tenant_header();
state
.storage
.traces
.store_trace(&make_trace(tid, "gpt-4", LLMProvider::OpenAI))
.await
.unwrap();
state
.storage
.traces
.store_trace(&make_trace_with_finding(tid))
.await
.unwrap();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "soc2",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let resp_body = json_body(resp).await;
assert_eq!(resp_body["status"], "pending");
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let report = json_body(resp).await;
assert_eq!(report["status"], "completed");
assert_eq!(report["content"]["report_type"], "soc2");
assert!(
report["content"]["data"]["total_traces_processed"]
.as_u64()
.unwrap()
>= 2
);
assert!(
report["content"]["data"]["total_security_findings"]
.as_u64()
.unwrap()
>= 1
);
}
#[tokio::test]
async fn test_generate_gdpr_report() {
let state = test_state().await;
let (tid, hdr) = tenant_header();
state
.storage
.traces
.store_trace(&make_trace(tid, "gpt-4", LLMProvider::OpenAI))
.await
.unwrap();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "gdpr",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let resp_body = json_body(resp).await;
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let report = json_body(resp).await;
assert_eq!(report["status"], "completed");
assert_eq!(report["content"]["report_type"], "gdpr");
assert_eq!(report["content"]["data"]["total_processing_activities"], 1);
}
#[tokio::test]
async fn test_generate_hipaa_report() {
let state = test_state().await;
let (tid, hdr) = tenant_header();
state
.storage
.traces
.store_trace(&make_trace(tid, "gpt-4", LLMProvider::OpenAI))
.await
.unwrap();
state
.storage
.traces
.store_trace(&make_trace_with_finding(tid))
.await
.unwrap();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "hipaa",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let resp_body = json_body(resp).await;
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let report = json_body(resp).await;
assert_eq!(report["status"], "completed");
assert_eq!(report["content"]["report_type"], "hipaa");
assert!(
report["content"]["data"]["total_access_events"]
.as_u64()
.unwrap()
>= 2
);
assert!(
report["content"]["data"]["unauthorized_access_findings"]
.as_u64()
.unwrap()
>= 1
);
}
#[tokio::test]
async fn test_generate_report_invalid_period() {
let state = test_state().await;
let (_, hdr) = tenant_header();
let app = compliance_router(state);
let body = serde_json::json!({
"report_type": "soc2",
"period_start": "2025-06-01T00:00:00Z",
"period_end": "2025-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_report_not_found() {
let state = test_state().await;
let (_, hdr) = tenant_header();
let app = compliance_router(state);
let req = Request::get(format!("/api/v1/reports/{}", Uuid::new_v4()))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_report_tenant_isolation() {
let state = test_state().await;
let (_tid1, hdr1) = tenant_header();
let (_tid2, hdr2) = tenant_header();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "soc2",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr1)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let resp_body = json_body(resp).await;
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr2)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr1)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_generate_report_empty_data() {
let state = test_state().await;
let (_, hdr) = tenant_header();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "soc2",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let resp_body = json_body(resp).await;
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let report = json_body(resp).await;
assert_eq!(report["status"], "completed");
assert_eq!(report["content"]["data"]["total_audit_events"], 0);
assert_eq!(report["content"]["data"]["total_traces_processed"], 0);
}
#[tokio::test]
async fn test_soc2_report_with_audit_events() {
let state = test_state().await;
let (tid, hdr) = tenant_header();
let event = AuditEvent {
id: Uuid::new_v4(),
tenant_id: tid,
event_type: "key_created".to_string(),
actor: "admin@example.com".to_string(),
resource: "api_key".to_string(),
data: serde_json::json!({}),
timestamp: Utc::now(),
};
state
.storage
.metadata
.record_audit_event(&event)
.await
.unwrap();
let app = compliance_router(Arc::clone(&state));
let body = serde_json::json!({
"report_type": "soc2",
"period_start": "2020-01-01T00:00:00Z",
"period_end": "2030-01-01T00:00:00Z",
});
let req = Request::post("/api/v1/reports/generate")
.header("x-llmtrace-tenant-id", &hdr)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let resp_body = json_body(resp).await;
let report_id = resp_body["id"].as_str().unwrap().to_string();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let app = compliance_router(Arc::clone(&state));
let req = Request::get(format!("/api/v1/reports/{report_id}"))
.header("x-llmtrace-tenant-id", &hdr)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let report = json_body(resp).await;
assert_eq!(report["status"], "completed");
assert_eq!(report["content"]["data"]["total_audit_events"], 1);
assert_eq!(report["content"]["data"]["access_control_events"], 1);
let actors = report["content"]["data"]["unique_actors"]
.as_array()
.unwrap();
assert!(actors.iter().any(|a| a == "admin@example.com"));
}
#[test]
fn test_report_type_display() {
assert_eq!(ReportType::Soc2.to_string(), "soc2");
assert_eq!(ReportType::Gdpr.to_string(), "gdpr");
assert_eq!(ReportType::Hipaa.to_string(), "hipaa");
}
#[test]
fn test_report_type_serde_roundtrip() {
let json = serde_json::to_string(&ReportType::Soc2).unwrap();
assert_eq!(json, "\"soc2\"");
let parsed: ReportType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ReportType::Soc2);
}
}