diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f692e9..5a67a33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,11 @@ on: push: branches: - main - - master pull_request: +permissions: + contents: read + jobs: rust-tests: runs-on: ubuntu-latest diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index e3fc2ee..88cd021 100644 --- a/tests/cli_deps.rs +++ b/tests/cli_deps.rs @@ -247,6 +247,51 @@ fn cli_graph_format_json_outputs_parseable_nodes() { .any(|node| node["id"] == "pkg:npm/left-pad@1.3.0")); } +#[test] +fn cli_mixed_monorepo_commands_cover_npm_and_pypi() { + let path = fixture("mixed-monorepo"); + let expected = ["pkg:npm/debug@4.3.4", "pkg:pypi/fastapi@0.110.0"]; + + for (subcommand, format) in [("scan", "json"), ("graph", "json"), ("sbom", "cyclonedx")] { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", subcommand, &path, "--format", format]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "deps {subcommand} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + let ids: Vec<_> = if subcommand == "sbom" { + assert_eq!(parsed["bomFormat"], "CycloneDX"); + parsed["components"] + .as_array() + .expect("components array") + .iter() + .filter_map(|component| component["purl"].as_str()) + .collect() + } else { + parsed["nodes"] + .as_array() + .expect("nodes array") + .iter() + .filter_map(|node| node["id"].as_str()) + .collect() + }; + + for id in expected { + assert!( + ids.contains(&id), + "deps {subcommand} omitted {id}: {parsed}" + ); + } + } +} + #[test] fn cli_deps_help_includes_copy_paste_examples() { let cases = [ diff --git a/tests/cloud_commands_e2e/common/mod.rs b/tests/cloud_commands_e2e/common/mod.rs new file mode 100644 index 0000000..3cd26a8 --- /dev/null +++ b/tests/cloud_commands_e2e/common/mod.rs @@ -0,0 +1,842 @@ +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +pub(crate) const TOKEN: &str = "opaque-test-token"; +pub(crate) const SOURCE_BODY: &str = "print(\"cloud contract\")\n"; +pub(crate) const REPORT_BODY: &str = + r#"{"version":"semgrep.dev/v1","results":[{"path":"src/main.py"}]}"#; + +#[derive(Debug, Clone)] +pub(crate) struct CapturedRequest { + method: Method, + target: String, + headers: Vec<(String, String)>, + body: Vec, +} + +pub(crate) type RequestCheck = dyn Fn(&CapturedRequest) -> Result<(), String> + Send; + +pub(crate) struct ExpectedRequest { + label: &'static str, + check: Box, + status: StatusCode, + body: String, +} + +pub(crate) struct ApiState { + expected: VecDeque, + captured: Vec, + failures: Vec, +} + +pub(crate) struct ApiStub { + base_url: String, + state: Arc>, + shutdown: Option>, + server: Option>, +} + +impl ApiStub { + pub(crate) fn start(expected: Vec) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind API stub"); + listener + .set_nonblocking(true) + .expect("make API listener nonblocking"); + let base_url = format!( + "http://127.0.0.1:{}", + listener.local_addr().expect("API listener address").port() + ); + let state = Arc::new(Mutex::new(ApiState { + expected: expected.into(), + captured: Vec::new(), + failures: Vec::new(), + })); + let server_state = Arc::clone(&state); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build API stub runtime"); + runtime.block_on(async move { + let listener = + tokio::net::TcpListener::from_std(listener).expect("adopt API listener"); + let mut shutdown_rx = shutdown_rx; + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + let (stream, _) = accepted.expect("accept API connection"); + let state = Arc::clone(&server_state); + tokio::task::spawn(async move { + let service_state = Arc::clone(&state); + let service = service_fn(move |request| { + handle_request(request, Arc::clone(&service_state)) + }); + if let Err(error) = http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await + { + let mut state = state.lock().expect("lock API state"); + state.failures.push(format!( + "failed to serve API connection: {error}" + )); + } + }); + } + } + } + }); + }); + + Self { + base_url, + state, + shutdown: Some(shutdown), + server: Some(server), + } + } + + pub(crate) fn base_url(&self) -> &str { + &self.base_url + } + + pub(crate) fn transcript(&self) -> String { + let state = self.state.lock().expect("lock API transcript"); + format_transcript(&state.captured) + } + + pub(crate) fn assert_finished(mut self) -> String { + self.stop(); + let state = self.state.lock().expect("lock finished API state"); + let remaining = state + .expected + .iter() + .map(|request| request.label) + .collect::>(); + let transcript = format_transcript(&state.captured); + assert!( + state.failures.is_empty() && remaining.is_empty(), + "API contract did not finish\nfailures:\n{}\nremaining: {:?}\ntranscript:\n{}", + state.failures.join("\n"), + remaining, + transcript + ); + transcript + } + + pub(crate) fn stop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(server) = self.server.take() { + server.join().expect("join API stub"); + } + } +} + +impl Drop for ApiStub { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(server) = self.server.take() { + let _ = server.join(); + } + } +} + +pub(crate) async fn handle_request( + request: Request, + state: Arc>, +) -> Result>, hyper::Error> { + let (parts, body) = request.into_parts(); + let body = body.collect().await?.to_bytes().to_vec(); + let captured = CapturedRequest { + method: parts.method, + target: parts + .uri + .path_and_query() + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| parts.uri.path().to_string()), + headers: parts + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value + .to_str() + .map(str::to_string) + .unwrap_or_else(|_| format!("{value:?}")), + ) + }) + .collect(), + body, + }; + + let (status, body) = { + let mut state = state.lock().expect("lock API request state"); + state.captured.push(captured.clone()); + match state.expected.pop_front() { + Some(expected) => match (expected.check)(&captured) { + Ok(()) => (expected.status, expected.body), + Err(error) => { + state + .failures + .push(format!("{}: {}", expected.label, error)); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error", "message": error}).to_string(), + ) + } + }, + None => { + let error = format!( + "unexpected extra request: {} {}", + captured.method, captured.target + ); + state.failures.push(error.clone()); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error", "message": error}).to_string(), + ) + } + } + }; + + Ok(Response::builder() + .status(status) + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body))) + .expect("build API response")) +} + +pub(crate) fn expected_request( + label: &'static str, + check: F, + response: (StatusCode, String), +) -> ExpectedRequest +where + F: Fn(&CapturedRequest) -> Result<(), String> + Send + 'static, +{ + ExpectedRequest { + label, + check: Box::new(check), + status: response.0, + body: response.1, + } +} + +pub(crate) fn json_response(body: Value) -> (StatusCode, String) { + (StatusCode::OK, body.to_string()) +} + +pub(crate) fn json_response_with_status(status: StatusCode, body: Value) -> (StatusCode, String) { + (status, body.to_string()) +} + +pub(crate) fn target_path_and_query(target: &str) -> (&str, Vec<(String, String)>) { + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let query = url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + (path, query) +} + +pub(crate) fn assert_method_and_path( + request: &CapturedRequest, + method: Method, + path: &str, +) -> Result<(), String> { + let (actual_path, _) = target_path_and_query(&request.target); + if request.method != method { + return Err(format!("expected method {method}, got {}", request.method)); + } + if actual_path != path { + return Err(format!("expected path {path}, got {actual_path}")); + } + Ok(()) +} + +pub(crate) fn assert_opaque_auth(request: &CapturedRequest) -> Result<(), String> { + match header_value(request, "corgea-token") { + Some(value) if value == TOKEN => Ok(()), + Some(value) => Err(format!("expected CORGEA-TOKEN {TOKEN}, got {value}")), + None => Err("missing CORGEA-TOKEN header".to_string()), + } +} + +pub(crate) fn assert_authenticated_request( + request: &CapturedRequest, + method: Method, + path: &str, +) -> Result<(), String> { + assert_method_and_path(request, method, path)?; + assert_opaque_auth(request) +} + +pub(crate) fn assert_query( + request: &CapturedRequest, + key: &str, + expected: &str, +) -> Result<(), String> { + let (_, query) = target_path_and_query(&request.target); + match query.iter().find(|(name, _)| name == key) { + Some((_, value)) if value == expected => Ok(()), + Some((_, value)) => Err(format!("expected query {key}={expected}, got {value}")), + None => Err(format!("missing query field {key}")), + } +} + +pub(crate) fn assert_scan_list_request( + request: &CapturedRequest, + project: &str, +) -> Result<(), String> { + assert_authenticated_request(request, Method::GET, "/api/v1/scans")?; + assert_query(request, "page", "1")?; + assert_query(request, "page_size", "30")?; + assert_query(request, "project", project) +} + +pub(crate) fn query_value(request: &CapturedRequest, key: &str) -> Result { + let (_, query) = target_path_and_query(&request.target); + query + .into_iter() + .find_map(|(name, value)| (name == key).then_some(value)) + .ok_or_else(|| format!("missing query field {key}")) +} + +pub(crate) fn assert_body_contains( + request: &CapturedRequest, + expected: &[u8], +) -> Result<(), String> { + if request + .body + .windows(expected.len()) + .any(|window| window == expected) + { + Ok(()) + } else { + Err(format!( + "request body does not contain {:?}", + String::from_utf8_lossy(expected) + )) + } +} + +pub(crate) fn header_value<'a>(request: &'a CapturedRequest, name: &str) -> Option<&'a str> { + request + .headers + .iter() + .find(|(header, _)| header.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +pub(crate) fn assert_content_type(request: &CapturedRequest, expected: &str) -> Result<(), String> { + match header_value(request, "content-type") { + Some(value) if value.starts_with(expected) => Ok(()), + Some(value) => Err(format!("expected content type {expected}, got {value}")), + None => Err("missing Content-Type header".to_string()), + } +} + +pub(crate) fn assert_header( + request: &CapturedRequest, + name: &str, + expected: &str, +) -> Result<(), String> { + match header_value(request, name) { + Some(value) if value == expected => Ok(()), + Some(value) => Err(format!("expected header {name}: {expected}, got {value}")), + None => Err(format!("missing {name} header")), + } +} + +pub(crate) fn assert_multipart_text_field( + request: &CapturedRequest, + name: &str, + value: &str, +) -> Result<(), String> { + let expected = format!("name=\"{name}\"\r\n\r\n{value}\r\n"); + if request + .body + .windows(expected.len()) + .any(|window| window == expected.as_bytes()) + { + Ok(()) + } else { + Err(format!("missing multipart field {name}={value:?}")) + } +} + +pub(crate) fn format_transcript(requests: &[CapturedRequest]) -> String { + if requests.is_empty() { + return "".to_string(); + } + requests + .iter() + .enumerate() + .map(|(index, request)| { + let preview_len = request.body.len().min(512); + let preview = String::from_utf8_lossy(&request.body[..preview_len]); + format!( + "{}. {} {}\nheaders: {:?}\nbody ({} bytes): {}{}", + index + 1, + request.method, + request.target, + request.headers, + request.body.len(), + preview, + if request.body.len() > preview_len { + "…" + } else { + "" + } + ) + }) + .collect::>() + .join("\n") +} + +pub(crate) fn run_with_timeout(mut command: Command, api: &ApiStub) -> Output { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn corgea"); + let deadline = Instant::now() + Duration::from_secs(10); + + loop { + if child.try_wait().expect("poll corgea").is_some() { + return child.wait_with_output().expect("collect corgea output"); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().expect("reap timed-out corgea"); + panic!( + "corgea timed out\nstatus: {}\nstdout:\n{}\nstderr:\n{}\nAPI transcript:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + api.transcript(), + ); + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +pub(crate) fn output_context(output: &Output, transcript: &str) -> String { + format!( + "stdout:\n{}\nstderr:\n{}\nAPI transcript:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + transcript + ) +} + +pub(crate) fn parse_output_json(output: &Output, transcript: &str) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "failed to parse CLI stdout as JSON: {error}\n{}", + output_context(output, transcript) + ) + }) +} + +pub(crate) fn cloud_command(api: &ApiStub, path: &Path) -> (Command, TempDir) { + let (mut command, home) = crate::repo_common::corgea_isolated(); + command + .current_dir(path) + .env("CORGEA_TOKEN", TOKEN) + .env("CORGEA_URL", api.base_url()); + (command, home) +} + +pub(crate) struct ReportProject { + root: TempDir, + report_path: PathBuf, +} + +pub(crate) struct GitProject { + root: TempDir, + pub(crate) sha: String, +} + +impl GitProject { + pub(crate) fn path(&self) -> &Path { + self.root.path() + } +} + +impl ReportProject { + pub(crate) fn path(&self) -> &Path { + self.root.path() + } + + pub(crate) fn report_path(&self) -> &Path { + &self.report_path + } +} + +pub(crate) fn report_project() -> ReportProject { + let root = TempDir::new().expect("create report project"); + let source_dir = root.path().join("src"); + std::fs::create_dir(&source_dir).expect("create source directory"); + std::fs::write(source_dir.join("main.py"), SOURCE_BODY).expect("write source"); + let report_path = root.path().join("semgrep.json"); + std::fs::write(&report_path, REPORT_BODY).expect("write report"); + ReportProject { root, report_path } +} + +pub(crate) fn git_project() -> GitProject { + let root = TempDir::new().expect("create Git project"); + for args in [ + vec!["init"], + vec!["config", "user.email", "cloud-e2e@example.com"], + vec!["config", "user.name", "Cloud E2E"], + vec!["checkout", "-b", "e2e-main"], + vec![ + "remote", + "add", + "origin", + "https://github.com/corgea/cloud-e2e.git", + ], + ] { + run_git(root.path(), &args); + } + std::fs::write(root.path().join("main.py"), SOURCE_BODY).expect("write Git source"); + run_git(root.path(), &["add", "main.py"]); + run_git(root.path(), &["commit", "-m", "fixture"]); + let output = run_git(root.path(), &["rev-parse", "HEAD"]); + let sha = String::from_utf8(output.stdout) + .expect("UTF-8 Git SHA") + .trim() + .to_string(); + assert_eq!(sha.len(), 40, "expected full Git SHA, got {sha}"); + GitProject { root, sha } +} + +pub(crate) fn run_git(path: &Path, args: &[&str]) -> Output { + let output = Command::new("git") + .args(args) + .current_dir(path) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +pub(crate) fn temp_project_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .expect("UTF-8 temp project name") + .to_string() +} + +pub(crate) fn verify_request() -> ExpectedRequest { + expected_request( + "verify token", + |request| assert_authenticated_request(request, Method::GET, "/api/v1/verify"), + json_response(json!({"status": "ok"})), + ) +} + +pub(crate) fn scan_response(scan_id: &str, project: &str, status: &str) -> Value { + json!({ + "id": scan_id, + "project": project, + "repo": null, + "branch": null, + "status": status, + "engine": "semgrep", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": null + }) +} + +pub(crate) fn scans_response(scans: Vec) -> Value { + json!({ + "status": "ok", + "page": 1, + "total_pages": 1, + "scans": scans + }) +} + +pub(crate) fn regular_issue(issue_id: &str, scan_id: &str, project: &str, urgency: &str) -> Value { + json!({ + "id": issue_id, + "scan_id": scan_id, + "status": "open", + "urgency": urgency, + "created_at": "2026-07-30T12:00:00Z", + "classification": { + "id": format!("class-{issue_id}"), + "name": format!("{urgency} test issue"), + "description": null + }, + "location": { + "file": { + "name": "main.py", + "language": "python", + "path": "src/main.py" + }, + "line_number": 7, + "project": { + "name": project, + "branch": null, + "git_sha": null + } + }, + "details": null, + "auto_triage": { + "false_positive_detection": { + "status": "not_started", + "reasoning": null + } + }, + "auto_fix_suggestion": null + }) +} + +pub(crate) fn regular_issue_page(scan_id: &str, project: &str) -> Value { + let issues = vec![ + regular_issue("issue-cr", scan_id, project, "CR"), + regular_issue("issue-hi-1", scan_id, project, "HI"), + regular_issue("issue-hi-2", scan_id, project, "HI"), + regular_issue("issue-me", scan_id, project, "ME"), + ]; + json!({ + "status": "ok", + "issues": issues, + "page": 1, + "total_pages": 1, + "total_issues": 4 + }) +} + +pub(crate) fn empty_issue_page() -> Value { + json!({ + "status": "ok", + "issues": [], + "page": 1, + "total_pages": 1, + "total_issues": 0 + }) +} + +pub(crate) fn malicious_sca_issue_page() -> Value { + json!({ + "status": "ok", + "issues": [{ + "id": "sca-malicious-1", + "created_at": "2026-07-30T12:00:00Z", + "description": "malicious dependency", + "details": null, + "severity": "critical", + "classification": "malicious", + "cve": null, + "package": { + "name": "bad-package", + "version": "1.0.0", + "ecosystem": "npm", + "fix_version": null + }, + "location": { + "path": "package-lock.json" + } + }], + "page": 1, + "total_pages": 1, + "total_issues": 1 + }) +} + +pub(crate) fn upload_plan(scan_id: &str, project_id: i64) -> Vec { + let run_id = Arc::new(Mutex::new(None::)); + let code_run_id = Arc::clone(&run_id); + let scan_run_id = Arc::clone(&run_id); + vec![ + verify_request(), + expected_request( + "upload referenced source", + move |request| { + assert_authenticated_request(request, Method::POST, "/api/v1/code-upload")?; + assert_query(request, "path", "src/main.py")?; + assert_content_type(request, "multipart/form-data")?; + assert_body_contains(request, SOURCE_BODY.as_bytes())?; + let value = query_value(request, "run_id")?; + *code_run_id.lock().expect("lock upload run ID") = Some(value); + Ok(()) + }, + json_response(json!({"status": "ok"})), + ), + expected_request( + "upload report", + move |request| { + assert_authenticated_request(request, Method::POST, "/api/v1/scan-upload")?; + assert_query(request, "engine", "semgrep")?; + assert_query(request, "project", "upload-contract")?; + assert_query(request, "ci", "false")?; + assert_query(request, "ci_platform", "unknown")?; + assert_content_type(request, "application/json")?; + assert_body_contains(request, REPORT_BODY.as_bytes())?; + let value = query_value(request, "run_id")?; + let expected = scan_run_id + .lock() + .expect("lock upload run ID") + .clone() + .ok_or_else(|| "source upload did not capture run_id".to_string())?; + if value != expected { + return Err(format!( + "scan upload run_id {value} does not match source upload {expected}" + )); + } + let (_, query) = target_path_and_query(&request.target); + if query.iter().any(|(key, _)| key == "repo_data") { + return Err("unexpected repo_data query field".to_string()); + } + Ok(()) + }, + json_response(json!({ + "status": "ok", + "sast_scan_id": scan_id, + "project_id": project_id + })), + ), + ] +} + +pub(crate) fn append_wait_plan( + expected: &mut Vec, + project: &str, + scan_id: &str, + statuses: &[&str], +) { + for (index, status) in statuses.iter().enumerate() { + let path = format!("/api/v1/scan/{scan_id}"); + let body = scan_response(scan_id, project, status); + expected.push(expected_request( + if index == 0 { + "read initial scan status" + } else { + "poll scan status" + }, + move |request| assert_authenticated_request(request, Method::GET, &path), + json_response(body), + )); + } + + let issue_path = format!("/api/v1/scan/{scan_id}/issues"); + expected.push(expected_request( + "read completed scan issues", + move |request| { + assert_authenticated_request(request, Method::GET, &issue_path)?; + assert_query(request, "page", "1")?; + assert_query(request, "page_size", "30") + }, + json_response(regular_issue_page(scan_id, project)), + )); +} + +pub(crate) fn assert_issue_summary(stdout: &str, context: &str) { + for (urgency, count) in [("CR", 1), ("HI", 2), ("ME", 1), ("LO", 0)] { + let row = format!("{urgency:<20} | {count}"); + assert!(stdout.contains(&row), "missing {row:?}\n{context}"); + } + let total = format!("{:<20} | 4", "Total"); + assert!(stdout.contains(&total), "missing {total:?}\n{context}"); +} + +pub(crate) fn blast_plan(sha: &str) -> Vec { + let patch_sha = sha.to_string(); + let patch_path = "/api/v1/start-scan/transfer-123/".to_string(); + let detail_path = "/api/v1/scan/blast-scan-123".to_string(); + let issue_path = "/api/v1/scan/blast-scan-123/issues".to_string(); + let sca_path = "/api/v1/scan/blast-scan-123/issues/sca".to_string(); + vec![ + verify_request(), + expected_request( + "start BLAST upload", + |request| { + assert_authenticated_request(request, Method::POST, "/api/v1/start-scan")?; + assert_query(request, "scan_type", "blast")?; + assert_content_type(request, "multipart/form-data") + }, + json_response(json!({"transfer_id": "transfer-123"})), + ), + expected_request( + "upload BLAST archive", + move |request| { + assert_authenticated_request(request, Method::PATCH, &patch_path)?; + assert_query(request, "scan_type", "blast")?; + assert_content_type(request, "multipart/form-data")?; + assert_header(request, "upload-offset", "0")?; + assert_header(request, "upload-name", "cloud-e2e.zip")?; + let upload_length = header_value(request, "upload-length") + .ok_or_else(|| "missing Upload-Length header".to_string())?; + let parsed_length = upload_length + .parse::() + .map_err(|error| format!("invalid Upload-Length {upload_length}: {error}"))?; + if parsed_length == 0 { + return Err("Upload-Length must be positive".to_string()); + } + assert_multipart_text_field(request, "file_size", upload_length)?; + assert_multipart_text_field(request, "project_name", "cloud-e2e")?; + assert_multipart_text_field(request, "branch", "e2e-main")?; + assert_multipart_text_field( + request, + "repo_url", + "https://github.com/corgea/cloud-e2e.git", + )?; + assert_multipart_text_field(request, "sha", &patch_sha)?; + assert_body_contains(request, b"name=\"chunk_data\"") + }, + json_response(json!({ + "scan_id": "blast-scan-123", + "project_id": 91 + })), + ), + expected_request( + "read completed BLAST scan", + move |request| assert_authenticated_request(request, Method::GET, &detail_path), + json_response(scan_response("blast-scan-123", "cloud-e2e", "complete")), + ), + expected_request( + "read regular BLAST issues", + move |request| { + assert_authenticated_request(request, Method::GET, &issue_path)?; + assert_query(request, "page", "1")?; + assert_query(request, "page_size", "30") + }, + json_response(empty_issue_page()), + ), + expected_request( + "read malicious SCA issues", + move |request| { + assert_authenticated_request(request, Method::GET, &sca_path)?; + assert_query(request, "page", "1")?; + assert_query(request, "page_size", "30") + }, + json_response(malicious_sca_issue_page()), + ), + ] +} diff --git a/tests/cloud_commands_e2e/inspect.rs b/tests/cloud_commands_e2e/inspect.rs new file mode 100644 index 0000000..b29b24b --- /dev/null +++ b/tests/cloud_commands_e2e/inspect.rs @@ -0,0 +1,140 @@ +use crate::common::*; +use hyper::{Method, StatusCode}; +use serde_json::json; +use tempfile::TempDir; + +#[test] +fn inspect_scan_json_returns_requested_scan() { + let project = TempDir::new().expect("create inspect project"); + let scan_id = "inspect-scan-123"; + let scan_path = format!("/api/v1/scan/{scan_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "inspect scan as JSON", + move |request| assert_authenticated_request(request, Method::GET, &scan_path), + json_response(json!({ + "id": scan_id, + "project": "inspect-project", + "repo": "https://github.com/corgea/inspect-project.git", + "branch": "main", + "status": "complete", + "engine": "blast", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": "abcdef0123456789abcdef0123456789abcdef01" + })), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["inspect", scan_id, "--json"]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let body = parse_output_json(&output, &transcript); + assert_eq!(body["id"], scan_id, "{context}"); + assert_eq!(body["project"], "inspect-project", "{context}"); + assert_eq!(body["status"], "complete", "{context}"); + assert_eq!(body["engine"], "blast", "{context}"); + assert_eq!( + body["git_sha"], "abcdef0123456789abcdef0123456789abcdef01", + "{context}" + ); +} + +#[test] +fn inspect_issue_json_returns_requested_issue() { + let project = TempDir::new().expect("create inspect project"); + let issue_id = "inspect-issue-123"; + let issue_path = format!("/api/v1/issue/{issue_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "inspect issue as JSON", + move |request| assert_authenticated_request(request, Method::GET, &issue_path), + json_response(json!({ + "status": "ok", + "issue": regular_issue( + issue_id, + "inspect-scan-123", + "inspect-project", + "HI" + ) + })), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["inspect", "--issue", "--json", issue_id]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let body = parse_output_json(&output, &transcript); + assert_eq!(body["status"], "ok", "{context}"); + assert_eq!(body["issue"]["id"], issue_id, "{context}"); + assert_eq!(body["issue"]["scan_id"], "inspect-scan-123", "{context}"); + assert_eq!(body["issue"]["urgency"], "HI", "{context}"); + assert_eq!( + body["issue"]["classification"]["name"], "HI test issue", + "{context}" + ); + assert_eq!( + body["issue"]["location"]["file"]["path"], "src/main.py", + "{context}" + ); + assert_eq!(body["issue"]["location"]["line_number"], 7, "{context}"); +} + +#[test] +fn inspect_scan_exits_one_on_server_error() { + let project = TempDir::new().expect("create inspect project"); + let scan_id = "inspect-error-scan"; + let scan_path = format!("/api/v1/scan/{scan_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "reject scan inspection", + move |request| assert_authenticated_request(request, Method::GET, &scan_path), + json_response_with_status( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error"}), + ), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["inspect", scan_id]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(scan_id), "{context}"); +} + +#[test] +fn inspect_issue_exits_one_on_invalid_contract() { + let project = TempDir::new().expect("create inspect project"); + let issue_id = "inspect-invalid-issue"; + let issue_path = format!("/api/v1/issue/{issue_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "return invalid issue contract", + move |request| assert_authenticated_request(request, Method::GET, &issue_path), + json_response(json!({"unexpected": true})), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["inspect", "--issue", "--json", issue_id]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(issue_id), "{context}"); + assert!(stderr.contains("Failed to parse response"), "{context}"); +} diff --git a/tests/cloud_commands_e2e/main.rs b/tests/cloud_commands_e2e/main.rs new file mode 100644 index 0000000..eb2352d --- /dev/null +++ b/tests/cloud_commands_e2e/main.rs @@ -0,0 +1,7 @@ +#[path = "../common/mod.rs"] +mod repo_common; + +mod common; +mod inspect; +mod scan_list; +mod upload_wait; diff --git a/tests/cloud_commands_e2e/scan_list.rs b/tests/cloud_commands_e2e/scan_list.rs new file mode 100644 index 0000000..9632472 --- /dev/null +++ b/tests/cloud_commands_e2e/scan_list.rs @@ -0,0 +1,148 @@ +use crate::common::*; +use hyper::{Method, StatusCode}; +use serde_json::json; +use tempfile::TempDir; + +#[test] +fn scan_fail_on_malicious_sends_sha_and_list_renders_it() { + let project = git_project(); + let scan_api = ApiStub::start(blast_plan(&project.sha)); + let (mut scan_command, _scan_home) = cloud_command(&scan_api, project.path()); + scan_command.args([ + "scan", + "blast", + "--fail-on", + "malicious", + "--project-name", + "cloud-e2e", + ]); + + let scan_output = run_with_timeout(scan_command, &scan_api); + let scan_transcript = scan_api.assert_finished(); + let scan_context = output_context(&scan_output, &scan_transcript); + assert_eq!(scan_output.status.code(), Some(1), "{scan_context}"); + let scan_stdout = String::from_utf8_lossy(&scan_output.stdout); + assert!( + scan_stdout.contains("matched --fail-on malicious"), + "{scan_context}" + ); + + let list_response_sha = project.sha.clone(); + let list_api = ApiStub::start(vec![ + verify_request(), + expected_request( + "resolve Git project", + |request| { + assert_authenticated_request(request, Method::GET, "/api/v1/projects")?; + assert_query(request, "repo_url", "corgea/cloud-e2e")?; + assert_query(request, "page", "1")?; + assert_query(request, "page_size", "50") + }, + json_response(json!({ + "status": "ok", + "projects": [{ + "name": "cloud-e2e", + "repo_url": "https://github.com/corgea/cloud-e2e.git" + }] + })), + ), + expected_request( + "list scans for Git project", + move |request| assert_scan_list_request(request, "cloud-e2e"), + json_response(scans_response(vec![json!({ + "id": "blast-scan-123", + "project": "cloud-e2e", + "repo": "https://github.com/corgea/cloud-e2e.git", + "branch": "e2e-main", + "status": "complete", + "engine": "blast", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": list_response_sha + })])), + ), + ]); + let (mut list_command, _list_home) = cloud_command(&list_api, project.path()); + list_command.arg("list"); + + let list_output = run_with_timeout(list_command, &list_api); + let list_transcript = list_api.assert_finished(); + let list_context = output_context(&list_output, &list_transcript); + assert_eq!(list_output.status.code(), Some(0), "{list_context}"); + let list_stdout = String::from_utf8_lossy(&list_output.stdout); + assert!(list_stdout.contains(&project.sha[..8]), "{list_context}"); +} + +#[test] +fn list_json_returns_filtered_scan_contract() { + let project = TempDir::new().expect("create list project"); + let local_project = temp_project_name(project.path()); + let response_project = local_project.clone(); + let query_project = local_project.clone(); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "list filtered scans as JSON", + move |request| assert_scan_list_request(request, &query_project), + json_response(json!({ + "status": "ok", + "page": 1, + "total_pages": 2, + "scans": [{ + "id": "list-scan-123", + "project": response_project, + "repo": "https://github.com/corgea/list-contract.git", + "branch": "main", + "status": "complete", + "engine": "blast", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": "0123456789abcdef0123456789abcdef01234567" + }] + })), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["list", "--json"]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let body = parse_output_json(&output, &transcript); + assert_eq!(body["page"], 1, "{context}"); + assert_eq!(body["total_pages"], 2, "{context}"); + let results = body["results"].as_array().expect("list JSON results"); + assert_eq!(results.len(), 1, "{context}"); + assert_eq!(results[0]["id"], "list-scan-123", "{context}"); + assert_eq!(results[0]["project"], local_project, "{context}"); + assert_eq!(results[0]["status"], "complete", "{context}"); + assert_eq!( + results[0]["git_sha"], "0123456789abcdef0123456789abcdef01234567", + "{context}" + ); +} + +#[test] +fn list_exits_one_on_server_error() { + let project = TempDir::new().expect("create list project"); + let local_project = temp_project_name(project.path()); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "reject scan list", + move |request| assert_scan_list_request(request, &local_project), + json_response_with_status( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error"}), + ), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.arg("list"); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Unable to fetch scans"), "{context}"); +} diff --git a/tests/cloud_commands_e2e/upload_wait.rs b/tests/cloud_commands_e2e/upload_wait.rs new file mode 100644 index 0000000..994ff77 --- /dev/null +++ b/tests/cloud_commands_e2e/upload_wait.rs @@ -0,0 +1,149 @@ +use crate::common::*; +use hyper::{Method, StatusCode}; +use serde_json::json; +use tempfile::TempDir; + +#[test] +fn upload_prints_tracking_url_from_returned_ids() { + let project = report_project(); + let api = ApiStub::start(upload_plan("scan-upload-123", 42)); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "upload", + project.report_path().to_str().expect("UTF-8 report path"), + "--project-name", + "upload-contract", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("scan-upload-123"), "{context}"); + assert!( + stdout.contains("/project/42/?scan_id=scan-upload-123"), + "{context}" + ); + assert!( + stdout.contains("continue securely in the Corgea cloud"), + "{context}" + ); +} + +#[test] +fn upload_wait_uses_returned_ids_and_stops_at_complete() { + let project = report_project(); + let local_project = temp_project_name(project.path()); + let mut plan = upload_plan("wait-scan-123", 73); + append_wait_plan( + &mut plan, + &local_project, + "wait-scan-123", + &["processing", "complete"], + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "upload", + project.report_path().to_str().expect("UTF-8 report path"), + "--project-name", + "upload-contract", + "--wait", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("/project/73/?scan_id=wait-scan-123"), + "{context}" + ); + assert_issue_summary(&stdout, &context); +} + +#[test] +fn wait_reports_an_immediately_complete_scan() { + let project = TempDir::new().expect("create wait project"); + let local_project = temp_project_name(project.path()); + let mut plan = vec![verify_request()]; + append_wait_plan( + &mut plan, + &local_project, + "complete-scan-123", + &["complete"], + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["wait", "complete-scan-123"]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Scan has been processed successfully!"), + "{context}" + ); + assert!(stdout.contains("scan_id=complete-scan-123"), "{context}"); + assert_issue_summary(&stdout, &context); +} + +#[test] +fn wait_exits_one_when_scan_list_fails() { + let project = TempDir::new().expect("create wait project"); + let local_project = temp_project_name(project.path()); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "reject scan list", + move |request| assert_scan_list_request(request, &local_project), + json_response_with_status( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error"}), + ), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.arg("wait"); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Unable to query the scan list"), + "{context}" + ); +} + +#[test] +fn wait_exits_one_when_scan_detail_fails() { + let project = TempDir::new().expect("create wait project"); + let scan_id = "detail-error-scan"; + let detail_path = format!("/api/v1/scan/{scan_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "reject scan detail", + move |request| assert_authenticated_request(request, Method::GET, &detail_path), + json_response_with_status( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"status": "error"}), + ), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["wait", scan_id]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Oops! Something went wrong"), "{context}"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3d99a04..83068c9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -44,7 +44,21 @@ pub fn corgea_isolated() -> (Command, TempDir) { .env_remove("CURSOR_AGENT") .env_remove("CURSOR_TRACE_ID") .env_remove("GEMINI_CLI") - .env_remove("PI_AGENT"); + .env_remove("PI_AGENT") + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env_remove("GITHUB_REPOSITORY") + .env_remove("GITHUB_PR") + .env_remove("REPO_DATA") + .env_remove("DEBUG_CORGEA_OVERRIDE_REPORT_CHUNK_SIZE") + .env_remove("RUST_LOG") + .env_remove("CORGEA_DEBUG") + .env_remove("HTTP_PROXY") + .env_remove("HTTPS_PROXY") + .env_remove("ALL_PROXY") + .env_remove("http_proxy") + .env_remove("https_proxy") + .env_remove("all_proxy"); (cmd, home) } diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index e09abb5..8a43b37 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -13,6 +13,7 @@ Offline fixture projects for `corgea deps` unit and CLI tests per `docs/PRD_DEPS | `node-transitive` | npm generic transitive edges + scoped package names | | `node-yarn` / `node-pnpm` | unsupported npm-family lockfiles | | `node-monorepo` | workspace detection | +| `mixed-monorepo` | recursive npm + requirements.txt discovery | | `python-poetry` | Poetry lock + transitive urllib3 | | `python-poetry-multi` | Poetry dependency tables for multiple parents | | `python-pip-nolock` | DEP001 + requirements.txt | diff --git a/tests/fixtures/mixed-monorepo/services/api/requirements.txt b/tests/fixtures/mixed-monorepo/services/api/requirements.txt new file mode 100644 index 0000000..737125f --- /dev/null +++ b/tests/fixtures/mixed-monorepo/services/api/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.110.0 +pydantic>=2.0 diff --git a/tests/fixtures/mixed-monorepo/services/web/package-lock.json b/tests/fixtures/mixed-monorepo/services/web/package-lock.json new file mode 100644 index 0000000..15cb527 --- /dev/null +++ b/tests/fixtures/mixed-monorepo/services/web/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "corgea-fixture-web-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "corgea-fixture-web-service", + "version": "1.0.0", + "dependencies": { + "debug": "^4.3.4", + "ms": "*" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-fixture-debug", + "dependencies": { + "ms": "2.1.2" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-fixture-ms" + } + } +} diff --git a/tests/fixtures/mixed-monorepo/services/web/package.json b/tests/fixtures/mixed-monorepo/services/web/package.json new file mode 100644 index 0000000..7cdd514 --- /dev/null +++ b/tests/fixtures/mixed-monorepo/services/web/package.json @@ -0,0 +1,10 @@ +{ + "name": "corgea-fixture-web-service", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "debug": "^4.3.4", + "ms": "*" + } +}