From 7b7b8a4698e1ff8dd02e9662c2099c43bde39735 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 31 Jul 2026 11:26:32 +0200 Subject: [PATCH 1/5] test: cover cloud command paths end to end --- tests/cloud_commands_e2e.rs | 1263 +++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 16 +- 2 files changed, 1278 insertions(+), 1 deletion(-) create mode 100644 tests/cloud_commands_e2e.rs diff --git a/tests/cloud_commands_e2e.rs b/tests/cloud_commands_e2e.rs new file mode 100644 index 0000000..74b9020 --- /dev/null +++ b/tests/cloud_commands_e2e.rs @@ -0,0 +1,1263 @@ +mod common; + +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; + +const TOKEN: &str = "opaque-test-token"; +const SOURCE_BODY: &str = "print(\"cloud contract\")\n"; +const REPORT_BODY: &str = r#"{"version":"semgrep.dev/v1","results":[{"path":"src/main.py"}]}"#; + +#[derive(Debug, Clone)] +struct CapturedRequest { + method: Method, + target: String, + headers: Vec<(String, String)>, + body: Vec, +} + +type RequestCheck = dyn Fn(&CapturedRequest) -> Result<(), String> + Send; + +struct ExpectedRequest { + label: &'static str, + check: Box, + status: StatusCode, + body: String, +} + +struct ApiState { + expected: VecDeque, + captured: Vec, + failures: Vec, +} + +struct ApiStub { + base_url: String, + state: Arc>, + shutdown: Option>, + server: Option>, +} + +impl ApiStub { + 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), + } + } + + fn base_url(&self) -> &str { + &self.base_url + } + + fn transcript(&self) -> String { + let state = self.state.lock().expect("lock API transcript"); + format_transcript(&state.captured) + } + + 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 + } + + 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(); + } + } +} + +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")) +} + +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, + } +} + +fn json_response(body: Value) -> (StatusCode, String) { + (StatusCode::OK, body.to_string()) +} + +fn json_response_with_status(status: StatusCode, body: Value) -> (StatusCode, String) { + (status, body.to_string()) +} + +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) +} + +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(()) +} + +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()), + } +} + +fn assert_authenticated_request( + request: &CapturedRequest, + method: Method, + path: &str, +) -> Result<(), String> { + assert_method_and_path(request, method, path)?; + assert_opaque_auth(request) +} + +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}")), + } +} + +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) +} + +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}")) +} + +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) + )) + } +} + +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()) +} + +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()), + } +} + +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")), + } +} + +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:?}")) + } +} + +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") +} + +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)); + } +} + +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 + ) +} + +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) + ) + }) +} + +fn cloud_command(api: &ApiStub, path: &Path) -> (Command, TempDir) { + let (mut command, home) = common::corgea_isolated(); + command + .current_dir(path) + .env("CORGEA_TOKEN", TOKEN) + .env("CORGEA_URL", api.base_url()); + (command, home) +} + +struct ReportProject { + root: TempDir, + report_path: PathBuf, +} + +struct GitProject { + root: TempDir, + sha: String, +} + +impl GitProject { + fn path(&self) -> &Path { + self.root.path() + } +} + +impl ReportProject { + fn path(&self) -> &Path { + self.root.path() + } + + fn report_path(&self) -> &Path { + &self.report_path + } +} + +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 } +} + +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 } +} + +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 +} + +fn temp_project_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .expect("UTF-8 temp project name") + .to_string() +} + +fn verify_request() -> ExpectedRequest { + expected_request( + "verify token", + |request| assert_authenticated_request(request, Method::GET, "/api/v1/verify"), + json_response(json!({"status": "ok"})), + ) +} + +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 + }) +} + +fn scans_response(scans: Vec) -> Value { + json!({ + "status": "ok", + "page": 1, + "total_pages": 1, + "scans": scans + }) +} + +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 + }) +} + +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 + }) +} + +fn empty_issue_page() -> Value { + json!({ + "status": "ok", + "issues": [], + "page": 1, + "total_pages": 1, + "total_issues": 0 + }) +} + +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 + }) +} + +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 + })), + ), + ] +} + +fn append_wait_plan( + expected: &mut Vec, + project: &str, + scan_id: &str, + statuses: &[&str], +) { + let list_project = project.to_string(); + expected.push(expected_request( + "query scan list before waiting", + move |request| assert_scan_list_request(request, &list_project), + json_response(scans_response(Vec::new())), + )); + + 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)), + )); +} + +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}"); +} + +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()), + ), + ] +} + +#[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.args(["wait", "list-error-scan"]); + + 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 local_project = temp_project_name(project.path()); + let scan_id = "detail-error-scan"; + let detail_path = format!("/api/v1/scan/{scan_id}"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "query scan list before failed detail", + move |request| assert_scan_list_request(request, &local_project), + json_response(scans_response(Vec::new())), + ), + 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}"); +} + +#[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( + "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" + }, + { + "id": "unrelated-scan", + "project": "other-project", + "repo": null, + "branch": null, + "status": "processing", + "engine": "semgrep", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": null + } + ] + })), + ), + ]); + 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 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 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}"); +} + +#[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/common/mod.rs b/tests/common/mod.rs index f02b6c1..817427f 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) } From 7ca26130b482ef7bfa418e9dd590377eb821bc4f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 31 Jul 2026 12:23:57 +0200 Subject: [PATCH 2/5] ci: run CLI fixtures on trusted pull requests --- .github/workflows/test.yml | 108 +++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f692e9..1a7a007 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,9 @@ on: - master pull_request: +permissions: + contents: read + jobs: rust-tests: runs-on: ubuntu-latest @@ -15,6 +18,24 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Select private fixture coverage + id: fixtures + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "pull_request" ]] && + { [[ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then + echo "run=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping private test-cli fixtures for a fork or Dependabot pull request." + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: @@ -31,3 +52,90 @@ jobs: - name: CI gate run: ./harness ci + + - name: Build CLI for fixture smoke + if: steps.fixtures.outputs.run == 'true' + run: cargo build --locked --bin corgea + + - name: Resolve test-cli revision + id: test_cli + if: steps.fixtures.outputs.run == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.TEST_CLI_TOKEN }} + HEAD_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + + if [[ -z "$GH_TOKEN" ]]; then + echo "::error::TEST_CLI_TOKEN is required for trusted CLI builds." + exit 1 + fi + + resolve_branch() { + local branch="$1" + local encoded response status sha + encoded="$(jq -rn --arg branch "$branch" '$branch | @uri')" + response="$(mktemp)" + status="$(curl --silent --show-error --location \ + --connect-timeout 10 --max-time 30 \ + --output "$response" --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/Corgea/test-cli/branches/$encoded")" + + case "$status" in + 200) + if ! sha="$(jq -er '.commit.sha' "$response")"; then + rm -f "$response" + echo "::error::Corgea/test-cli branch '$branch' returned no commit SHA." >&2 + return 1 + fi + rm -f "$response" + printf '%s' "$sha" + ;; + 404) + rm -f "$response" + return 4 + ;; + *) + rm -f "$response" + echo "::error::Failed to resolve Corgea/test-cli branch '$branch' (HTTP $status)." >&2 + return 1 + ;; + esac + } + + branch="$HEAD_BRANCH" + if sha="$(resolve_branch "$branch")"; then + : + else + status=$? + if [[ "$status" -ne 4 || "$branch" == "main" ]]; then + exit "$status" + fi + echo "No matching test-cli branch '$branch'; falling back to main." + branch=main + sha="$(resolve_branch "$branch")" + fi + + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "Using Corgea/test-cli@$sha ($branch)." + + - name: Checkout test-cli (${{ steps.test_cli.outputs.branch }}) + if: steps.fixtures.outputs.run == 'true' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: Corgea/test-cli + ref: ${{ steps.test_cli.outputs.sha }} + token: ${{ secrets.TEST_CLI_TOKEN }} + path: test-cli + persist-credentials: false + + - name: Validate CLI repository fixtures + if: steps.fixtures.outputs.run == 'true' + env: + CORGEA_BIN: ${{ github.workspace }}/target/debug/corgea + run: ./test-cli/scripts/validate-fixtures.sh From 3176fdbefcce3649b93af3c77bb6b8d705b61804 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 3 Aug 2026 17:32:50 +0200 Subject: [PATCH 3/5] test: run CLI E2E fixtures in-repo --- .github/workflows/test.yml | 106 ------------------ tests/cli_deps.rs | 45 ++++++++ tests/fixtures/README.md | 1 + .../services/api/requirements.txt | 2 + .../services/web/package-lock.json | 29 +++++ .../mixed-monorepo/services/web/package.json | 10 ++ 6 files changed, 87 insertions(+), 106 deletions(-) create mode 100644 tests/fixtures/mixed-monorepo/services/api/requirements.txt create mode 100644 tests/fixtures/mixed-monorepo/services/web/package-lock.json create mode 100644 tests/fixtures/mixed-monorepo/services/web/package.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a7a007..5a67a33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - master pull_request: permissions: @@ -18,24 +17,6 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Select private fixture coverage - id: fixtures - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - ACTOR: ${{ github.actor }} - run: | - set -euo pipefail - - if [[ "$EVENT_NAME" == "pull_request" ]] && - { [[ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice::Skipping private test-cli fixtures for a fork or Dependabot pull request." - else - echo "run=true" >> "$GITHUB_OUTPUT" - fi - - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: @@ -52,90 +33,3 @@ jobs: - name: CI gate run: ./harness ci - - - name: Build CLI for fixture smoke - if: steps.fixtures.outputs.run == 'true' - run: cargo build --locked --bin corgea - - - name: Resolve test-cli revision - id: test_cli - if: steps.fixtures.outputs.run == 'true' - shell: bash - env: - GH_TOKEN: ${{ secrets.TEST_CLI_TOKEN }} - HEAD_BRANCH: ${{ github.head_ref || github.ref_name }} - run: | - set -euo pipefail - - if [[ -z "$GH_TOKEN" ]]; then - echo "::error::TEST_CLI_TOKEN is required for trusted CLI builds." - exit 1 - fi - - resolve_branch() { - local branch="$1" - local encoded response status sha - encoded="$(jq -rn --arg branch "$branch" '$branch | @uri')" - response="$(mktemp)" - status="$(curl --silent --show-error --location \ - --connect-timeout 10 --max-time 30 \ - --output "$response" --write-out '%{http_code}' \ - --header "Authorization: Bearer $GH_TOKEN" \ - --header "Accept: application/vnd.github+json" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/Corgea/test-cli/branches/$encoded")" - - case "$status" in - 200) - if ! sha="$(jq -er '.commit.sha' "$response")"; then - rm -f "$response" - echo "::error::Corgea/test-cli branch '$branch' returned no commit SHA." >&2 - return 1 - fi - rm -f "$response" - printf '%s' "$sha" - ;; - 404) - rm -f "$response" - return 4 - ;; - *) - rm -f "$response" - echo "::error::Failed to resolve Corgea/test-cli branch '$branch' (HTTP $status)." >&2 - return 1 - ;; - esac - } - - branch="$HEAD_BRANCH" - if sha="$(resolve_branch "$branch")"; then - : - else - status=$? - if [[ "$status" -ne 4 || "$branch" == "main" ]]; then - exit "$status" - fi - echo "No matching test-cli branch '$branch'; falling back to main." - branch=main - sha="$(resolve_branch "$branch")" - fi - - echo "branch=$branch" >> "$GITHUB_OUTPUT" - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Using Corgea/test-cli@$sha ($branch)." - - - name: Checkout test-cli (${{ steps.test_cli.outputs.branch }}) - if: steps.fixtures.outputs.run == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: Corgea/test-cli - ref: ${{ steps.test_cli.outputs.sha }} - token: ${{ secrets.TEST_CLI_TOKEN }} - path: test-cli - persist-credentials: false - - - name: Validate CLI repository fixtures - if: steps.fixtures.outputs.run == 'true' - env: - CORGEA_BIN: ${{ github.workspace }}/target/debug/corgea - run: ./test-cli/scripts/validate-fixtures.sh diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index 5c19c97..67cf84b 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/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": "*" + } +} From d323289524945b56d4996775c3b9ae6e049fddba Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 4 Aug 2026 10:37:59 +0200 Subject: [PATCH 4/5] test: align cloud E2E with project resolution --- tests/cloud_commands_e2e.rs | 63 ++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/tests/cloud_commands_e2e.rs b/tests/cloud_commands_e2e.rs index 74b9020..5c72ef0 100644 --- a/tests/cloud_commands_e2e.rs +++ b/tests/cloud_commands_e2e.rs @@ -718,13 +718,6 @@ fn append_wait_plan( scan_id: &str, statuses: &[&str], ) { - let list_project = project.to_string(); - expected.push(expected_request( - "query scan list before waiting", - move |request| assert_scan_list_request(request, &list_project), - json_response(scans_response(Vec::new())), - )); - for (index, status) in statuses.iter().enumerate() { let path = format!("/api/v1/scan/{scan_id}"); let body = scan_response(scan_id, project, status); @@ -940,7 +933,7 @@ fn wait_exits_one_when_scan_list_fails() { ), ]); let (mut command, _home) = cloud_command(&api, project.path()); - command.args(["wait", "list-error-scan"]); + command.arg("wait"); let output = run_with_timeout(command, &api); let transcript = api.assert_finished(); @@ -956,16 +949,10 @@ fn wait_exits_one_when_scan_list_fails() { #[test] fn wait_exits_one_when_scan_detail_fails() { let project = TempDir::new().expect("create wait project"); - let local_project = temp_project_name(project.path()); let scan_id = "detail-error-scan"; let detail_path = format!("/api/v1/scan/{scan_id}"); let api = ApiStub::start(vec![ verify_request(), - expected_request( - "query scan list before failed detail", - move |request| assert_scan_list_request(request, &local_project), - json_response(scans_response(Vec::new())), - ), expected_request( "reject scan detail", move |request| assert_authenticated_request(request, Method::GET, &detail_path), @@ -1013,6 +1000,22 @@ fn scan_fail_on_malicious_sends_sha_and_list_renders_it() { 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"), @@ -1054,28 +1057,16 @@ fn list_json_returns_filtered_scan_contract() { "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" - }, - { - "id": "unrelated-scan", - "project": "other-project", - "repo": null, - "branch": null, - "status": "processing", - "engine": "semgrep", - "created_at": "2026-07-30T12:00:00Z", - "git_sha": null - } - ] + "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" + }] })), ), ]); From 0a4dc8e8c4da08b3dc22b34cc61b88d21b8d4e0f Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 4 Aug 2026 12:12:56 +0200 Subject: [PATCH 5/5] test: split cloud E2E suite by command --- .../common/mod.rs} | 554 +++--------------- tests/cloud_commands_e2e/inspect.rs | 140 +++++ tests/cloud_commands_e2e/main.rs | 7 + tests/cloud_commands_e2e/scan_list.rs | 148 +++++ tests/cloud_commands_e2e/upload_wait.rs | 149 +++++ 5 files changed, 515 insertions(+), 483 deletions(-) rename tests/{cloud_commands_e2e.rs => cloud_commands_e2e/common/mod.rs} (56%) create mode 100644 tests/cloud_commands_e2e/inspect.rs create mode 100644 tests/cloud_commands_e2e/main.rs create mode 100644 tests/cloud_commands_e2e/scan_list.rs create mode 100644 tests/cloud_commands_e2e/upload_wait.rs diff --git a/tests/cloud_commands_e2e.rs b/tests/cloud_commands_e2e/common/mod.rs similarity index 56% rename from tests/cloud_commands_e2e.rs rename to tests/cloud_commands_e2e/common/mod.rs index 5c72ef0..3cd26a8 100644 --- a/tests/cloud_commands_e2e.rs +++ b/tests/cloud_commands_e2e/common/mod.rs @@ -1,5 +1,3 @@ -mod common; - use http_body_util::{BodyExt, Full}; use hyper::body::{Bytes, Incoming}; use hyper::server::conn::http1; @@ -14,34 +12,35 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tempfile::TempDir; -const TOKEN: &str = "opaque-test-token"; -const SOURCE_BODY: &str = "print(\"cloud contract\")\n"; -const REPORT_BODY: &str = r#"{"version":"semgrep.dev/v1","results":[{"path":"src/main.py"}]}"#; +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)] -struct CapturedRequest { +pub(crate) struct CapturedRequest { method: Method, target: String, headers: Vec<(String, String)>, body: Vec, } -type RequestCheck = dyn Fn(&CapturedRequest) -> Result<(), String> + Send; +pub(crate) type RequestCheck = dyn Fn(&CapturedRequest) -> Result<(), String> + Send; -struct ExpectedRequest { +pub(crate) struct ExpectedRequest { label: &'static str, check: Box, status: StatusCode, body: String, } -struct ApiState { +pub(crate) struct ApiState { expected: VecDeque, captured: Vec, failures: Vec, } -struct ApiStub { +pub(crate) struct ApiStub { base_url: String, state: Arc>, shutdown: Option>, @@ -49,7 +48,7 @@ struct ApiStub { } impl ApiStub { - fn start(expected: Vec) -> Self { + 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) @@ -109,16 +108,16 @@ impl ApiStub { } } - fn base_url(&self) -> &str { + pub(crate) fn base_url(&self) -> &str { &self.base_url } - fn transcript(&self) -> String { + pub(crate) fn transcript(&self) -> String { let state = self.state.lock().expect("lock API transcript"); format_transcript(&state.captured) } - fn assert_finished(mut self) -> String { + pub(crate) fn assert_finished(mut self) -> String { self.stop(); let state = self.state.lock().expect("lock finished API state"); let remaining = state @@ -137,7 +136,7 @@ impl ApiStub { transcript } - fn stop(&mut self) { + pub(crate) fn stop(&mut self) { if let Some(shutdown) = self.shutdown.take() { let _ = shutdown.send(()); } @@ -158,7 +157,7 @@ impl Drop for ApiStub { } } -async fn handle_request( +pub(crate) async fn handle_request( request: Request, state: Arc>, ) -> Result>, hyper::Error> { @@ -224,7 +223,7 @@ async fn handle_request( .expect("build API response")) } -fn expected_request( +pub(crate) fn expected_request( label: &'static str, check: F, response: (StatusCode, String), @@ -240,15 +239,15 @@ where } } -fn json_response(body: Value) -> (StatusCode, String) { +pub(crate) fn json_response(body: Value) -> (StatusCode, String) { (StatusCode::OK, body.to_string()) } -fn json_response_with_status(status: StatusCode, body: Value) -> (StatusCode, String) { +pub(crate) fn json_response_with_status(status: StatusCode, body: Value) -> (StatusCode, String) { (status, body.to_string()) } -fn target_path_and_query(target: &str) -> (&str, Vec<(String, 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() @@ -256,7 +255,7 @@ fn target_path_and_query(target: &str) -> (&str, Vec<(String, String)>) { (path, query) } -fn assert_method_and_path( +pub(crate) fn assert_method_and_path( request: &CapturedRequest, method: Method, path: &str, @@ -271,7 +270,7 @@ fn assert_method_and_path( Ok(()) } -fn assert_opaque_auth(request: &CapturedRequest) -> Result<(), String> { +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}")), @@ -279,7 +278,7 @@ fn assert_opaque_auth(request: &CapturedRequest) -> Result<(), String> { } } -fn assert_authenticated_request( +pub(crate) fn assert_authenticated_request( request: &CapturedRequest, method: Method, path: &str, @@ -288,7 +287,11 @@ fn assert_authenticated_request( assert_opaque_auth(request) } -fn assert_query(request: &CapturedRequest, key: &str, expected: &str) -> Result<(), String> { +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(()), @@ -297,14 +300,17 @@ fn assert_query(request: &CapturedRequest, key: &str, expected: &str) -> Result< } } -fn assert_scan_list_request(request: &CapturedRequest, project: &str) -> Result<(), String> { +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) } -fn query_value(request: &CapturedRequest, key: &str) -> Result { +pub(crate) fn query_value(request: &CapturedRequest, key: &str) -> Result { let (_, query) = target_path_and_query(&request.target); query .into_iter() @@ -312,7 +318,10 @@ fn query_value(request: &CapturedRequest, key: &str) -> Result { .ok_or_else(|| format!("missing query field {key}")) } -fn assert_body_contains(request: &CapturedRequest, expected: &[u8]) -> Result<(), String> { +pub(crate) fn assert_body_contains( + request: &CapturedRequest, + expected: &[u8], +) -> Result<(), String> { if request .body .windows(expected.len()) @@ -327,7 +336,7 @@ fn assert_body_contains(request: &CapturedRequest, expected: &[u8]) -> Result<() } } -fn header_value<'a>(request: &'a CapturedRequest, name: &str) -> Option<&'a str> { +pub(crate) fn header_value<'a>(request: &'a CapturedRequest, name: &str) -> Option<&'a str> { request .headers .iter() @@ -335,7 +344,7 @@ fn header_value<'a>(request: &'a CapturedRequest, name: &str) -> Option<&'a str> .map(|(_, value)| value.as_str()) } -fn assert_content_type(request: &CapturedRequest, expected: &str) -> Result<(), String> { +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}")), @@ -343,7 +352,11 @@ fn assert_content_type(request: &CapturedRequest, expected: &str) -> Result<(), } } -fn assert_header(request: &CapturedRequest, name: &str, expected: &str) -> Result<(), 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}")), @@ -351,7 +364,7 @@ fn assert_header(request: &CapturedRequest, name: &str, expected: &str) -> Resul } } -fn assert_multipart_text_field( +pub(crate) fn assert_multipart_text_field( request: &CapturedRequest, name: &str, value: &str, @@ -368,7 +381,7 @@ fn assert_multipart_text_field( } } -fn format_transcript(requests: &[CapturedRequest]) -> String { +pub(crate) fn format_transcript(requests: &[CapturedRequest]) -> String { if requests.is_empty() { return "".to_string(); } @@ -397,7 +410,7 @@ fn format_transcript(requests: &[CapturedRequest]) -> String { .join("\n") } -fn run_with_timeout(mut command: Command, api: &ApiStub) -> Output { +pub(crate) fn run_with_timeout(mut command: Command, api: &ApiStub) -> Output { let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -424,7 +437,7 @@ fn run_with_timeout(mut command: Command, api: &ApiStub) -> Output { } } -fn output_context(output: &Output, transcript: &str) -> String { +pub(crate) fn output_context(output: &Output, transcript: &str) -> String { format!( "stdout:\n{}\nstderr:\n{}\nAPI transcript:\n{}", String::from_utf8_lossy(&output.stdout), @@ -433,7 +446,7 @@ fn output_context(output: &Output, transcript: &str) -> String { ) } -fn parse_output_json(output: &Output, transcript: &str) -> Value { +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{}", @@ -442,8 +455,8 @@ fn parse_output_json(output: &Output, transcript: &str) -> Value { }) } -fn cloud_command(api: &ApiStub, path: &Path) -> (Command, TempDir) { - let (mut command, home) = common::corgea_isolated(); +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) @@ -451,33 +464,33 @@ fn cloud_command(api: &ApiStub, path: &Path) -> (Command, TempDir) { (command, home) } -struct ReportProject { +pub(crate) struct ReportProject { root: TempDir, report_path: PathBuf, } -struct GitProject { +pub(crate) struct GitProject { root: TempDir, - sha: String, + pub(crate) sha: String, } impl GitProject { - fn path(&self) -> &Path { + pub(crate) fn path(&self) -> &Path { self.root.path() } } impl ReportProject { - fn path(&self) -> &Path { + pub(crate) fn path(&self) -> &Path { self.root.path() } - fn report_path(&self) -> &Path { + pub(crate) fn report_path(&self) -> &Path { &self.report_path } } -fn report_project() -> ReportProject { +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"); @@ -487,7 +500,7 @@ fn report_project() -> ReportProject { ReportProject { root, report_path } } -fn git_project() -> GitProject { +pub(crate) fn git_project() -> GitProject { let root = TempDir::new().expect("create Git project"); for args in [ vec!["init"], @@ -515,7 +528,7 @@ fn git_project() -> GitProject { GitProject { root, sha } } -fn run_git(path: &Path, args: &[&str]) -> Output { +pub(crate) fn run_git(path: &Path, args: &[&str]) -> Output { let output = Command::new("git") .args(args) .current_dir(path) @@ -531,14 +544,14 @@ fn run_git(path: &Path, args: &[&str]) -> Output { output } -fn temp_project_name(path: &Path) -> String { +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() } -fn verify_request() -> ExpectedRequest { +pub(crate) fn verify_request() -> ExpectedRequest { expected_request( "verify token", |request| assert_authenticated_request(request, Method::GET, "/api/v1/verify"), @@ -546,7 +559,7 @@ fn verify_request() -> ExpectedRequest { ) } -fn scan_response(scan_id: &str, project: &str, status: &str) -> Value { +pub(crate) fn scan_response(scan_id: &str, project: &str, status: &str) -> Value { json!({ "id": scan_id, "project": project, @@ -559,7 +572,7 @@ fn scan_response(scan_id: &str, project: &str, status: &str) -> Value { }) } -fn scans_response(scans: Vec) -> Value { +pub(crate) fn scans_response(scans: Vec) -> Value { json!({ "status": "ok", "page": 1, @@ -568,7 +581,7 @@ fn scans_response(scans: Vec) -> Value { }) } -fn regular_issue(issue_id: &str, scan_id: &str, project: &str, urgency: &str) -> Value { +pub(crate) fn regular_issue(issue_id: &str, scan_id: &str, project: &str, urgency: &str) -> Value { json!({ "id": issue_id, "scan_id": scan_id, @@ -604,7 +617,7 @@ fn regular_issue(issue_id: &str, scan_id: &str, project: &str, urgency: &str) -> }) } -fn regular_issue_page(scan_id: &str, project: &str) -> Value { +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"), @@ -620,7 +633,7 @@ fn regular_issue_page(scan_id: &str, project: &str) -> Value { }) } -fn empty_issue_page() -> Value { +pub(crate) fn empty_issue_page() -> Value { json!({ "status": "ok", "issues": [], @@ -630,7 +643,7 @@ fn empty_issue_page() -> Value { }) } -fn malicious_sca_issue_page() -> Value { +pub(crate) fn malicious_sca_issue_page() -> Value { json!({ "status": "ok", "issues": [{ @@ -657,7 +670,7 @@ fn malicious_sca_issue_page() -> Value { }) } -fn upload_plan(scan_id: &str, project_id: i64) -> Vec { +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); @@ -712,7 +725,7 @@ fn upload_plan(scan_id: &str, project_id: i64) -> Vec { ] } -fn append_wait_plan( +pub(crate) fn append_wait_plan( expected: &mut Vec, project: &str, scan_id: &str, @@ -744,7 +757,7 @@ fn append_wait_plan( )); } -fn assert_issue_summary(stdout: &str, context: &str) { +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}"); @@ -753,7 +766,7 @@ fn assert_issue_summary(stdout: &str, context: &str) { assert!(stdout.contains(&total), "missing {total:?}\n{context}"); } -fn blast_plan(sha: &str) -> Vec { +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(); @@ -827,428 +840,3 @@ fn blast_plan(sha: &str) -> Vec { ), ] } - -#[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}"); -} - -#[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 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 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}"); -} - -#[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/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}"); +}