cdfbb62f9d
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Builds on PR M7.2-A. Every HTTP handler in compliance-agent/src/api/
now takes a TenantCtx extractor and pulls a tenant-scoped Database
from agent.db_pool.for_tenant(&ctx). The query bodies are unchanged —
`db.findings().find(doc! {...})` reads from the tenant's own physical
database, so the filter doc cannot leak data across tenants because
the wrong tenant's data is literally on a different db handle.
Changes
- New `dto::tenant_db(&agent, &tenant) -> Result<Database, StatusCode>`
helper. Every migrated handler calls it at the top of the body
instead of `let db = &agent.db;`. 500 on the rare pool failure;
4xx auth failures are already handled by the M7.1 status gate.
- New `api::server::inject_dev_tenant` middleware mounted only when
Keycloak is NOT configured. Synthesizes a TenantContext with
tenant_id = $DEV_TENANT_ID (default `dev`) so `cargo run` against
a bare Mongo + no KC still serves the API. Logged loudly as
"DO NOT use in any environment with real customer data".
- Test harness: TestServer mounts inject_dev_tenant so existing E2E
tests reach handlers; cleanup() now drops every <db_name>_*
per-tenant database, not just the legacy <db_name>.
Files migrated (handler count, all pass `cargo build`):
- chat.rs (3) — also rewires RagPipeline + EmbeddingStore to the
tenant DB's inner() so vector search is per-tenant
- dast.rs (5)
- findings.rs (5)
- graph.rs (7) — also rewires GraphStore inside trigger_build's
spawn to the tenant DB
- health.rs (1) — stats_overview migrated; public /health stays
un-scoped
- issues.rs (1)
- notifications.rs (5)
- pentest_handlers/session.rs (12) — both wizard + legacy paths,
plus pause/resume/stop/get_attack_chain/get_messages/
get_session_findings/lookup_repo. PentestOrchestrator now gets
the tenant DB clone in its spawn.
- pentest_handlers/export.rs (1) — fans out across sessions,
attack_chain_nodes, dast_findings, findings, sbom_entries,
graph_nodes from a single tenant_db acquisition
- pentest_handlers/stats.rs (1)
- pentest_handlers/stream.rs (1) — SSE handler verifies session
via the tenant DB before subscribing
- repos.rs (6)
- sbom.rs (5)
- scans.rs (1)
help_chat.rs has no DB queries and was skipped.
Test plan
- cargo fmt --all clean
- cargo clippy --workspace --exclude compliance-dashboard
-- -D warnings clean
- cargo test -p compliance-core --lib — 7 pass
- cargo test -p compliance-agent --lib — 228 pass
- cargo test -p compliance-agent --test tenant_isolation — 5 pass
(driver-level isolation still holds post-handler migration)
- cargo test -p compliance-agent --test tenant_status_middleware
— 6 pass
What's not yet migrated (PR-C / PR-D)
- scheduler.rs (6 sites), pipeline/orchestrator.rs (14),
pentest/orchestrator.rs (13), webhooks (gitea/github/gitlab),
trackers/jira.rs, pipeline/dedup.rs etc. — background paths
without a JWT-derived tenant context.
- agent.db is still in the ComplianceAgent struct as a transitional
handle for those paths. PR-D removes it once PR-C migrates the
background paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
184 lines
6.4 KiB
Rust
184 lines
6.4 KiB
Rust
// Shared test harness for E2E / integration tests.
|
|
//
|
|
// Spins up the agent API server on a random port with an isolated test
|
|
// database. Each test gets a fresh database that is dropped on cleanup.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use compliance_agent::agent::ComplianceAgent;
|
|
use compliance_agent::api;
|
|
use compliance_agent::database::{Database, DatabasePool};
|
|
use compliance_core::AgentConfig;
|
|
use secrecy::SecretString;
|
|
|
|
/// A running test server with a unique database.
|
|
pub struct TestServer {
|
|
pub base_url: String,
|
|
pub client: reqwest::Client,
|
|
db_name: String,
|
|
mongodb_uri: String,
|
|
}
|
|
|
|
impl TestServer {
|
|
/// Start an agent API server on a random port with an isolated database.
|
|
pub async fn start() -> Self {
|
|
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
|
|
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
|
|
|
// Unique database name per test run to avoid collisions
|
|
let db_name = format!("test_{}", uuid::Uuid::new_v4().simple());
|
|
|
|
let db = Database::connect(&mongodb_uri, &db_name)
|
|
.await
|
|
.expect("Failed to connect to MongoDB — is it running?");
|
|
db.ensure_indexes().await.expect("Failed to create indexes");
|
|
|
|
let db_pool = DatabasePool::connect(&mongodb_uri, &db_name)
|
|
.await
|
|
.expect("Failed to build DatabasePool");
|
|
|
|
let config = AgentConfig {
|
|
mongodb_uri: mongodb_uri.clone(),
|
|
mongodb_database: db_name.clone(),
|
|
litellm_url: std::env::var("TEST_LITELLM_URL")
|
|
.unwrap_or_else(|_| "http://localhost:4000".into()),
|
|
litellm_api_key: SecretString::from(String::new()),
|
|
litellm_model: "gpt-4o".into(),
|
|
litellm_embed_model: "text-embedding-3-small".into(),
|
|
agent_port: 0, // not used — we bind ourselves
|
|
scan_schedule: String::new(),
|
|
cve_monitor_schedule: String::new(),
|
|
git_clone_base_path: "/tmp/compliance-scanner-tests/repos".into(),
|
|
ssh_key_path: "/tmp/compliance-scanner-tests/ssh/id_ed25519".into(),
|
|
github_token: None,
|
|
github_webhook_secret: None,
|
|
gitlab_url: None,
|
|
gitlab_token: None,
|
|
gitlab_webhook_secret: None,
|
|
jira_url: None,
|
|
jira_email: None,
|
|
jira_api_token: None,
|
|
jira_project_key: None,
|
|
searxng_url: None,
|
|
nvd_api_key: None,
|
|
keycloak_url: None,
|
|
keycloak_realm: None,
|
|
keycloak_admin_username: None,
|
|
keycloak_admin_password: None,
|
|
pentest_verification_email: None,
|
|
pentest_imap_host: None,
|
|
pentest_imap_port: None,
|
|
pentest_imap_tls: false,
|
|
pentest_imap_username: None,
|
|
pentest_imap_password: None,
|
|
};
|
|
|
|
let agent = ComplianceAgent::new(config, db, db_pool);
|
|
|
|
// Build the router with the agent extension. After M7.2-B every
|
|
// handler takes a TenantCtx extractor; without KC in the test
|
|
// harness, the dev-tenant injector mounts a synthetic context so
|
|
// tests run end-to-end against `<db_name>_dev`.
|
|
let app = api::routes::build_router()
|
|
.layer(axum::extract::Extension(Arc::new(agent)))
|
|
.layer(axum::middleware::from_fn(api::server::inject_dev_tenant))
|
|
.layer(tower_http::cors::CorsLayer::permissive());
|
|
|
|
// Bind to port 0 to get a random available port
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("Failed to bind test server");
|
|
let port = listener.local_addr().expect("no local addr").port();
|
|
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.ok();
|
|
});
|
|
|
|
let base_url = format!("http://127.0.0.1:{port}");
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.build()
|
|
.expect("Failed to build HTTP client");
|
|
|
|
// Wait for server to be ready
|
|
for _ in 0..50 {
|
|
if client
|
|
.get(format!("{base_url}/api/v1/health"))
|
|
.send()
|
|
.await
|
|
.is_ok()
|
|
{
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
|
|
Self {
|
|
base_url,
|
|
client,
|
|
db_name,
|
|
mongodb_uri,
|
|
}
|
|
}
|
|
|
|
/// GET helper
|
|
pub async fn get(&self, path: &str) -> reqwest::Response {
|
|
self.client
|
|
.get(format!("{}{path}", self.base_url))
|
|
.send()
|
|
.await
|
|
.expect("GET request failed")
|
|
}
|
|
|
|
/// POST helper with JSON body
|
|
pub async fn post(&self, path: &str, body: &serde_json::Value) -> reqwest::Response {
|
|
self.client
|
|
.post(format!("{}{path}", self.base_url))
|
|
.json(body)
|
|
.send()
|
|
.await
|
|
.expect("POST request failed")
|
|
}
|
|
|
|
/// PATCH helper with JSON body
|
|
pub async fn patch(&self, path: &str, body: &serde_json::Value) -> reqwest::Response {
|
|
self.client
|
|
.patch(format!("{}{path}", self.base_url))
|
|
.json(body)
|
|
.send()
|
|
.await
|
|
.expect("PATCH request failed")
|
|
}
|
|
|
|
/// DELETE helper
|
|
pub async fn delete(&self, path: &str) -> reqwest::Response {
|
|
self.client
|
|
.delete(format!("{}{path}", self.base_url))
|
|
.send()
|
|
.await
|
|
.expect("DELETE request failed")
|
|
}
|
|
|
|
/// Get the unique database name for direct MongoDB access in tests.
|
|
pub fn db_name(&self) -> &str {
|
|
&self.db_name
|
|
}
|
|
|
|
/// Drop the test database on cleanup. Post-M7.2-B the actual data
|
|
/// lives in `<db_name>_<tenant>` per-tenant databases; list those
|
|
/// off the cluster and drop them too.
|
|
pub async fn cleanup(&self) {
|
|
if let Ok(client) = mongodb::Client::with_uri_str(&self.mongodb_uri).await {
|
|
client.database(&self.db_name).drop().await.ok();
|
|
if let Ok(names) = client.list_database_names().await {
|
|
let prefix = format!("{}_", self.db_name);
|
|
for name in names {
|
|
if name.starts_with(&prefix) {
|
|
client.database(&name).drop().await.ok();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|