diff --git a/.gitignore b/.gitignore index 7b92202..12c7009 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ *.swo *~ .DS_Store +.playwright-mcp/ +report-preview-full.png +compliance-dashboard/attack-chain-final.html diff --git a/compliance-agent/src/pentest/cleanup.rs b/compliance-agent/src/pentest/cleanup.rs index 406f717..d155978 100644 --- a/compliance-agent/src/pentest/cleanup.rs +++ b/compliance-agent/src/pentest/cleanup.rs @@ -298,3 +298,186 @@ async fn cleanup_okta( Err(format!("Okta delete failed ({status}): {body}")) } } + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::models::pentest::{IdentityProvider, TestUserRecord}; + use secrecy::SecretString; + + fn make_config_no_keycloak() -> AgentConfig { + AgentConfig { + mongodb_uri: String::new(), + mongodb_database: String::new(), + litellm_url: String::new(), + litellm_api_key: SecretString::from(String::new()), + litellm_model: String::new(), + litellm_embed_model: String::new(), + 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, + agent_port: 3001, + scan_schedule: String::new(), + cve_monitor_schedule: String::new(), + git_clone_base_path: String::new(), + ssh_key_path: String::new(), + 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_username: None, + pentest_imap_password: None, + } + } + + #[tokio::test] + async fn already_cleaned_up_returns_false() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: Some(IdentityProvider::Keycloak), + cleaned_up: true, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert_eq!(result, Ok(false)); + } + + #[tokio::test] + async fn firebase_returns_false_not_implemented() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: Some(IdentityProvider::Firebase), + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert_eq!(result, Ok(false)); + } + + #[tokio::test] + async fn no_provider_no_keycloak_skips() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: None, + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert_eq!(result, Ok(false)); + } + + #[tokio::test] + async fn custom_provider_no_keycloak_skips() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: Some(IdentityProvider::Custom), + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert_eq!(result, Ok(false)); + } + + #[tokio::test] + async fn keycloak_missing_config_returns_error() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: Some(IdentityProvider::Keycloak), + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert!(result.is_err()); + assert!(result + .as_ref() + .err() + .is_some_and(|e| e.contains("KEYCLOAK_URL"))); + } + + #[tokio::test] + async fn keycloak_missing_username_returns_error() { + let user = TestUserRecord { + username: None, + email: Some("test@example.com".into()), + provider_user_id: None, + provider: Some(IdentityProvider::Keycloak), + cleaned_up: false, + }; + let mut config = make_config_no_keycloak(); + config.keycloak_url = Some("http://localhost:8080".into()); + config.keycloak_realm = Some("test".into()); + config.keycloak_admin_username = Some("admin".into()); + config.keycloak_admin_password = Some(SecretString::from("pass".to_string())); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert!(result.is_err()); + assert!(result + .as_ref() + .err() + .is_some_and(|e| e.contains("username"))); + } + + #[tokio::test] + async fn auth0_missing_env_returns_error() { + let user = TestUserRecord { + username: None, + email: Some("test@example.com".into()), + provider_user_id: None, + provider: Some(IdentityProvider::Auth0), + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert!(result.is_err()); + assert!(result + .as_ref() + .err() + .is_some_and(|e| e.contains("AUTH0_DOMAIN"))); + } + + #[tokio::test] + async fn okta_missing_env_returns_error() { + let user = TestUserRecord { + username: Some("test".into()), + email: None, + provider_user_id: None, + provider: Some(IdentityProvider::Okta), + cleaned_up: false, + }; + let config = make_config_no_keycloak(); + let http = reqwest::Client::new(); + let result = cleanup_test_user(&user, &config, &http).await; + assert!(result.is_err()); + assert!(result + .as_ref() + .err() + .is_some_and(|e| e.contains("OKTA_DOMAIN"))); + } +} diff --git a/compliance-agent/src/pentest/orchestrator.rs b/compliance-agent/src/pentest/orchestrator.rs index 9cd843b..ebbca9a 100644 --- a/compliance-agent/src/pentest/orchestrator.rs +++ b/compliance-agent/src/pentest/orchestrator.rs @@ -612,3 +612,95 @@ fn summarize_tool_output(data: &serde_json::Value) -> serde_json::Value { } serde_json::Value::Object(summarized) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_summarize_strips_screenshot() { + let input = json!({ + "screenshot_base64": "iVBOR...", + "url": "https://example.com" + }); + let result = summarize_tool_output(&input); + assert_eq!( + result["screenshot_base64"], + "[screenshot captured and saved to report]" + ); + assert_eq!(result["url"], "https://example.com"); + } + + #[test] + fn test_summarize_truncates_html() { + let long_html = "x".repeat(3000); + let input = json!({ "html": long_html }); + let result = summarize_tool_output(&input); + let s = result["html"].as_str().unwrap_or_default(); + assert!(s.contains("[truncated, 3000 chars total]")); + assert!(s.starts_with(&"x".repeat(2000))); + assert!(s.len() < 3000); + } + + #[test] + fn test_summarize_truncates_text() { + let long_text = "a".repeat(2000); + let input = json!({ "text": long_text }); + let result = summarize_tool_output(&input); + let s = result["text"].as_str().unwrap_or_default(); + assert!(s.contains("[truncated]")); + assert!(s.starts_with(&"a".repeat(1500))); + assert!(s.len() < 2000); + } + + #[test] + fn test_summarize_trims_large_arrays() { + let elements: Vec = (0..20).map(|i| json!(format!("el-{i}"))).collect(); + let input = json!({ "elements": elements }); + let result = summarize_tool_output(&input); + let arr = result["elements"].as_array(); + assert!(arr.is_some()); + if let Some(arr) = arr { + // 15 kept + 1 summary entry + assert_eq!(arr.len(), 16); + assert_eq!(arr[15], json!("... and 5 more")); + } + } + + #[test] + fn test_summarize_preserves_small_data() { + let input = json!({ + "url": "https://example.com", + "status": 200, + "title": "Example" + }); + let result = summarize_tool_output(&input); + assert_eq!(result, input); + } + + #[test] + fn test_summarize_recursive() { + let input = json!({ + "page": { + "screenshot_base64": "iVBORw0KGgoAAAA...", + "url": "https://example.com" + } + }); + let result = summarize_tool_output(&input); + assert_eq!( + result["page"]["screenshot_base64"], + "[screenshot captured and saved to report]" + ); + assert_eq!(result["page"]["url"], "https://example.com"); + } + + #[test] + fn test_summarize_non_object() { + let string_val = json!("just a string"); + assert_eq!(summarize_tool_output(&string_val), string_val); + + let num_val = json!(42); + assert_eq!(summarize_tool_output(&num_val), num_val); + } +} diff --git a/compliance-agent/src/pentest/report/html/findings.rs b/compliance-agent/src/pentest/report/html/findings.rs index 17d86ae..d86058c 100644 --- a/compliance-agent/src/pentest/report/html/findings.rs +++ b/compliance-agent/src/pentest/report/html/findings.rs @@ -367,3 +367,156 @@ fn build_code_correlation( sections.join("\n") ) } + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::models::dast::{DastEvidence, DastVulnType}; + use compliance_core::models::finding::Severity; + use compliance_core::models::scan::ScanType; + + /// Helper: create a minimal `DastFinding`. + fn make_dast(title: &str, severity: Severity, endpoint: &str) -> DastFinding { + DastFinding::new( + "run1".into(), + "target1".into(), + DastVulnType::Xss, + title.into(), + "desc".into(), + severity, + endpoint.into(), + "GET".into(), + ) + } + + /// Helper: create a minimal SAST `Finding` with an ObjectId. + fn make_sast(title: &str) -> Finding { + let mut f = Finding::new( + "repo1".into(), + "fp1".into(), + "semgrep".into(), + ScanType::Sast, + title.into(), + "sast desc".into(), + Severity::High, + ); + f.id = Some(mongodb::bson::oid::ObjectId::new()); + f + } + + #[test] + fn test_findings_empty() { + let result = findings(&[], &[], &[], &[]); + assert!( + result.contains("No vulnerabilities were identified"), + "Empty findings should contain the no-vulns message" + ); + } + + #[test] + fn test_findings_grouped_by_severity() { + let f_high = make_dast("High vuln", Severity::High, "/a"); + let f_low = make_dast("Low vuln", Severity::Low, "/b"); + let f_critical = make_dast("Crit vuln", Severity::Critical, "/c"); + + let result = findings(&[f_high, f_low, f_critical], &[], &[], &[]); + + // All severity group headers should appear + assert!( + result.contains("Critical (1)"), + "should have Critical header" + ); + assert!(result.contains("High (1)"), "should have High header"); + assert!(result.contains("Low (1)"), "should have Low header"); + + // Critical should appear before High, High before Low + let crit_pos = result.find("Critical (1)"); + let high_pos = result.find("High (1)"); + let low_pos = result.find("Low (1)"); + assert!(crit_pos < high_pos, "Critical should come before High"); + assert!(high_pos < low_pos, "High should come before Low"); + } + + #[test] + fn test_code_correlation_sast_link() { + let mut sast = make_sast("SQL Injection in query"); + sast.file_path = Some("src/db/query.rs".into()); + sast.line_number = Some(42); + sast.code_snippet = + Some("let q = format!(\"SELECT * FROM {} WHERE id={}\", table, id);".into()); + + let sast_id = sast.id.as_ref().map(|oid| oid.to_hex()).unwrap_or_default(); + + let mut dast = make_dast("SQLi on /api/users", Severity::High, "/api/users"); + dast.linked_sast_finding_id = Some(sast_id); + + let result = findings(&[dast], &[sast], &[], &[]); + + assert!( + result.contains("SAST Correlation"), + "should render SAST Correlation badge" + ); + assert!( + result.contains("src/db/query.rs"), + "should contain the file path" + ); + assert!(result.contains(":42"), "should contain the line number"); + assert!( + result.contains("Vulnerable Code"), + "should render code snippet block" + ); + } + + #[test] + fn test_code_correlation_no_match() { + let dast = make_dast("XSS in search", Severity::Medium, "/search"); + // No linked_sast_finding_id, no code context, no sbom + let result = findings(&[dast], &[], &[], &[]); + + assert!( + !result.contains("code-correlation"), + "should not contain any code-correlation div" + ); + } + + #[test] + fn test_evidence_html_empty() { + let f = make_dast("No evidence", Severity::Low, "/x"); + let result = build_evidence_html(&f); + assert!(result.is_empty(), "no evidence should yield empty string"); + } + + #[test] + fn test_evidence_html_with_entries() { + let mut f = make_dast("Has evidence", Severity::High, "/y"); + f.evidence.push(DastEvidence { + request_method: "POST".into(), + request_url: "https://example.com/login".into(), + request_headers: None, + request_body: None, + response_status: 200, + response_headers: None, + response_snippet: Some("OK".into()), + screenshot_path: None, + payload: Some("".into()), + response_time_ms: None, + }); + + let result = build_evidence_html(&f); + + assert!( + result.contains("evidence-table"), + "should render the evidence table" + ); + assert!(result.contains("POST"), "should contain request method"); + assert!( + result.contains("https://example.com/login"), + "should contain request URL" + ); + assert!(result.contains("200"), "should contain response status"); + assert!( + result.contains("<script>alert(1)</script>"), + "payload should be HTML-escaped" + ); + } +} diff --git a/compliance-core/tests/models.rs b/compliance-core/tests/models.rs index 6a4d0d9..af6648a 100644 --- a/compliance-core/tests/models.rs +++ b/compliance-core/tests/models.rs @@ -517,6 +517,69 @@ fn pentest_auth_config_default() { assert!(!auth.cleanup_test_user); } +// ─── TestUserRecord ─── + +#[test] +fn test_user_record_default() { + let r = pentest::TestUserRecord::default(); + assert!(r.username.is_none()); + assert!(r.email.is_none()); + assert!(r.provider_user_id.is_none()); + assert!(r.provider.is_none()); + assert!(!r.cleaned_up); +} + +#[test] +fn test_user_record_serde_roundtrip() { + let r = pentest::TestUserRecord { + username: Some("pentestuser".into()), + email: Some("pentest+abc@scanner.example.com".into()), + provider_user_id: Some("kc-uuid-123".into()), + provider: Some(pentest::IdentityProvider::Keycloak), + cleaned_up: false, + }; + let json = serde_json::to_string(&r).unwrap(); + let back: pentest::TestUserRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(back.username, Some("pentestuser".into())); + assert_eq!(back.provider, Some(pentest::IdentityProvider::Keycloak)); + assert!(!back.cleaned_up); +} + +#[test] +fn identity_provider_serde_all_variants() { + for (variant, expected) in [ + (pentest::IdentityProvider::Keycloak, "\"keycloak\""), + (pentest::IdentityProvider::Auth0, "\"auth0\""), + (pentest::IdentityProvider::Okta, "\"okta\""), + (pentest::IdentityProvider::Firebase, "\"firebase\""), + (pentest::IdentityProvider::Custom, "\"custom\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, expected); + let back: pentest::IdentityProvider = serde_json::from_str(&json).unwrap(); + assert_eq!(back, variant); + } +} + +#[test] +fn pentest_session_with_test_user() { + let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick); + assert!(s.test_user.is_none()); + s.test_user = Some(pentest::TestUserRecord { + username: Some("pentester".into()), + email: Some("pentest+123@example.com".into()), + provider_user_id: None, + provider: Some(pentest::IdentityProvider::Auth0), + cleaned_up: false, + }); + let bson_doc = bson::to_document(&s).unwrap(); + let back: pentest::PentestSession = bson::from_document(bson_doc).unwrap(); + assert!(back.test_user.is_some()); + let tu = back.test_user.as_ref().unwrap(); + assert_eq!(tu.username, Some("pentester".into())); + assert_eq!(tu.provider, Some(pentest::IdentityProvider::Auth0)); +} + // ─── Serde helpers (BSON datetime) ─── #[test] diff --git a/compliance-dast/src/tools/mod.rs b/compliance-dast/src/tools/mod.rs index e78e9c7..021c1cd 100644 --- a/compliance-dast/src/tools/mod.rs +++ b/compliance-dast/src/tools/mod.rs @@ -142,3 +142,105 @@ impl ToolRegistry { self.tools.keys().cloned().collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_all_expected_tools() { + let registry = ToolRegistry::new(); + let names = registry.list_names(); + + let expected = [ + "recon", + "openapi_parser", + "dns_checker", + "dmarc_checker", + "tls_analyzer", + "security_headers", + "cookie_analyzer", + "csp_analyzer", + "cors_checker", + "rate_limit_tester", + "console_log_detector", + "sql_injection_scanner", + "xss_scanner", + "ssrf_scanner", + "auth_bypass_scanner", + "api_fuzzer", + "browser", + ]; + + for name in &expected { + assert!( + names.contains(&name.to_string()), + "Missing tool: {name}. Registered: {names:?}" + ); + } + } + + #[test] + fn registry_get_returns_tool() { + let registry = ToolRegistry::new(); + assert!(registry.get("recon").is_some()); + assert!(registry.get("browser").is_some()); + assert!(registry.get("nonexistent").is_none()); + } + + #[test] + fn all_definitions_have_valid_schemas() { + let registry = ToolRegistry::new(); + let defs = registry.all_definitions(); + + assert!(!defs.is_empty()); + for def in &defs { + assert!(!def.name.is_empty(), "Tool has empty name"); + assert!( + !def.description.is_empty(), + "Tool {} has empty description", + def.name + ); + assert!( + def.input_schema.is_object(), + "Tool {} schema is not an object", + def.name + ); + // Every schema should have "type": "object" + assert_eq!( + def.input_schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "Tool {} schema type is not 'object'", + def.name + ); + } + } + + #[test] + fn browser_tool_schema_has_action_enum() { + let registry = ToolRegistry::new(); + let browser = registry.get("browser"); + assert!(browser.is_some()); + let schema = browser.map(|t| t.input_schema()).unwrap_or_default(); + let action_prop = schema.get("properties").and_then(|p| p.get("action")); + assert!( + action_prop.is_some(), + "Browser tool missing 'action' property" + ); + let action_enum = action_prop + .and_then(|a| a.get("enum")) + .and_then(|e| e.as_array()); + assert!(action_enum.is_some(), "Browser action missing enum"); + let actions: Vec<&str> = action_enum + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .collect(); + assert!(actions.contains(&"navigate")); + assert!(actions.contains(&"screenshot")); + assert!(actions.contains(&"click")); + assert!(actions.contains(&"fill")); + assert!(actions.contains(&"get_content")); + assert!(actions.contains(&"close")); + } +}