From 6a2efae8778221e9aca2f62e9dd5421aa5b8a343 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Mon, 29 Jun 2026 17:40:52 +0200 Subject: [PATCH 1/2] fix(cli): resolve list/wait projects by repo URL, not directory name (COR-1577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list/wait now resolve the canonical project via GET /api/v1/projects?repo_url= (with an old-backend safety guard and CWD-name fallback when there's no git remote); dropped the brittle client-side scan.project==name filter; added mutually-exclusive --project-name/--repo override flags; replaced the silent empty table and bare "Error querying scan list" with clear miss messages. CLI-only — no backend/persisted-state changes. Residual of COR-1493. --- src/list.rs | 434 +++++++++++++++++++++------------------ src/main.rs | 45 +++- src/scan.rs | 26 +-- src/utils/api.rs | 230 +++++++++++++++++++++ src/utils/generic.rs | 93 +++++++++ src/wait.rs | 34 ++- tests/common/mod.rs | 59 ++++++ tests/list_resolution.rs | 262 +++++++++++++++++++++++ tests/wait_resolution.rs | 208 +++++++++++++++++++ 9 files changed, 1164 insertions(+), 227 deletions(-) create mode 100644 tests/list_resolution.rs create mode 100644 tests/wait_resolution.rs diff --git a/src/list.rs b/src/list.rs index 00790ba..43f66c3 100644 --- a/src/list.rs +++ b/src/list.rs @@ -4,6 +4,7 @@ use crate::utils; use serde_json::json; use std::path::Path; +#[allow(clippy::too_many_arguments)] pub fn run( config: &Config, issues: &bool, @@ -12,11 +13,19 @@ pub fn run( page: &Option, page_size: &Option, scan_id: &Option, + project_name_override: Option, + repo_override: Option, ) { - let project_name = - utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); - println!(); + // Leading blank line is cosmetic spacing for the human tables. Gate it on + // non-JSON mode so `--json` (success OR miss) keeps a clean stdout. + if !*json { + println!(); + } if *sca_issues { + // SCA has no project parameter (get_sca_issues); keep the CWD basename + // for its legacy error copy and do NOT resolve here. + let project_name = + utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); let sca_issues_response = match utils::api::get_sca_issues( &config.get_url(), Some((*page).unwrap_or(1)), @@ -106,229 +115,260 @@ pub fn run( Some(sca_issues_response.page), Some(sca_issues_response.total_pages), ); - } else if *issues { - let issues_response = match utils::api::get_scan_issues( + } else { + // Resolve once for both the --issues and scan-listing paths. + let resolved = utils::api::resolve_project( &config.get_url(), - &project_name, - Some((*page).unwrap_or(1)), - *page_size, - scan_id.clone(), - ) { - Ok(response) => response, - Err(e) => { - debug(&format!("Error Sending Request: {}", e)); - if e.to_string().contains("404") { - if scan_id.is_some() { - log::error!("Scan with ID '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", scan_id.as_ref().unwrap()); + project_name_override.as_deref(), + repo_override.as_deref(), + ); + let project_name = resolved.query_name.clone(); + if *issues { + let issues_response = match utils::api::get_scan_issues( + &config.get_url(), + &project_name, + Some((*page).unwrap_or(1)), + *page_size, + scan_id.clone(), + ) { + Ok(response) => response, + Err(e) => { + debug(&format!("Error Sending Request: {}", e)); + if e.to_string().contains("404") { + if scan_id.is_some() { + log::error!("Scan with ID '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", scan_id.as_ref().unwrap()); + } else if resolved.confirmed { + log::error!("Project '{}' has no issues yet. Run 'corgea scan' to create a scan for this project.", project_name); + } else { + log::error!( + "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", + resolved.tried_label + ); + } } else { - log::error!("Project with name '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", project_name); + log::error!( + "Unable to fetch scan issues. Please check your connection and ensure that:\n\ + - The server URL is reachable.\n\ + - Your authentication token is valid.\n\n\ + Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli {}", + e + ); } - } else { - log::error!( - "Unable to fetch scan issues. Please check your connection and ensure that:\n\ - - The server URL is reachable.\n\ - - Your authentication token is valid.\n\n\ - Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli {}", - e - ); + std::process::exit(1); } - std::process::exit(1); - } - }; - let mut render_blocking_rules = false; - let mut blocking_rules: std::collections::HashMap = - std::collections::HashMap::new(); + }; + let mut render_blocking_rules = false; + let mut blocking_rules: std::collections::HashMap = + std::collections::HashMap::new(); - if scan_id.is_some() { - let mut page: u32 = 1; - loop { - match utils::api::check_blocking_rules( - &config.get_url(), - scan_id.as_ref().unwrap(), - Some(page), - ) { - Ok(rules) => { - if rules.block { - render_blocking_rules = true; - for issue in rules.blocking_issues { - blocking_rules.insert(issue.id, issue.triggered_by_rules.join(",")); - } - if rules.total_pages == page { + if scan_id.is_some() { + let mut page: u32 = 1; + loop { + match utils::api::check_blocking_rules( + &config.get_url(), + scan_id.as_ref().unwrap(), + Some(page), + ) { + Ok(rules) => { + if rules.block { + render_blocking_rules = true; + for issue in rules.blocking_issues { + blocking_rules + .insert(issue.id, issue.triggered_by_rules.join(",")); + } + if rules.total_pages == page { + break; + } + page += 1; + } else { break; } - page += 1; - } else { - break; } - } - Err(e) => { - log::error!("Failed to check blocking rules: {}", e); - std::process::exit(1); + Err(e) => { + log::error!("Failed to check blocking rules: {}", e); + std::process::exit(1); + } } } } - } - if *json { - let mut json = serde_json::json!({ - "page": issues_response.page, - "total_pages": issues_response.total_pages, - "results": &issues_response.issues - }); - if render_blocking_rules { - json["results"] = serde_json::json!(issues_response - .issues - .unwrap_or_default() - .iter() - .map(|issue| { - serde_json::json!(utils::api::IssueWithBlockingRules { - id: issue.id.clone(), - scan_id: issue.scan_id.clone(), - status: issue.status.clone(), - urgency: issue.urgency.clone(), - created_at: issue.created_at.clone(), - classification: issue.classification.clone(), - location: issue.location.clone(), - details: issue.details.clone(), - auto_triage: issue.auto_triage.clone(), - auto_fix_suggestion: issue.auto_fix_suggestion.clone(), - blocked: blocking_rules.contains_key(&issue.id), - blocking_rules: if blocking_rules.contains_key(&issue.id) { - Some(vec![blocking_rules.get(&issue.id).unwrap().clone()]) - } else { - None - } + if *json { + let mut json = serde_json::json!({ + "page": issues_response.page, + "total_pages": issues_response.total_pages, + "results": &issues_response.issues + }); + if render_blocking_rules { + json["results"] = serde_json::json!(issues_response + .issues + .unwrap_or_default() + .iter() + .map(|issue| { + serde_json::json!(utils::api::IssueWithBlockingRules { + id: issue.id.clone(), + scan_id: issue.scan_id.clone(), + status: issue.status.clone(), + urgency: issue.urgency.clone(), + created_at: issue.created_at.clone(), + classification: issue.classification.clone(), + location: issue.location.clone(), + details: issue.details.clone(), + auto_triage: issue.auto_triage.clone(), + auto_fix_suggestion: issue.auto_fix_suggestion.clone(), + blocked: blocking_rules.contains_key(&issue.id), + blocking_rules: if blocking_rules.contains_key(&issue.id) { + Some(vec![blocking_rules.get(&issue.id).unwrap().clone()]) + } else { + None + } + }) }) - }) - .collect::>()); + .collect::>()); + } + let output = json!(json); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return; } - let output = json!(json); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); - return; - } - let mut table_header = vec![ - "Issue ID".to_string(), - "Category".to_string(), - "Urgency".to_string(), - "File Path".to_string(), - "Line".to_string(), - ]; - if render_blocking_rules { - table_header.push("Blocking".to_string()); - table_header.push("Rule ID".to_string()); - } - let mut table = vec![table_header]; + let mut table_header = vec![ + "Issue ID".to_string(), + "Category".to_string(), + "Urgency".to_string(), + "File Path".to_string(), + "Line".to_string(), + ]; + if render_blocking_rules { + table_header.push("Blocking".to_string()); + table_header.push("Rule ID".to_string()); + } + let mut table = vec![table_header]; - for issue in &issues_response.issues.unwrap_or_default() { - let classification_display = issue.classification.id.clone(); - let path = Path::new(&issue.location.file.path); - let path_parts: Vec<&str> = path - .components() - .filter_map(|c| c.as_os_str().to_str()) - .collect(); + for issue in &issues_response.issues.unwrap_or_default() { + let classification_display = issue.classification.id.clone(); + let path = Path::new(&issue.location.file.path); + let path_parts: Vec<&str> = path + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); - let shortened_path = if path_parts.len() > 2 { - let base_part = if path_parts[0].len() > 1 { - path_parts[0] + let shortened_path = if path_parts.len() > 2 { + let base_part = if path_parts[0].len() > 1 { + path_parts[0] + } else { + path_parts[1] + }; + format!("{}/../{}", base_part, path_parts[path_parts.len() - 1]).to_string() } else { - path_parts[1] + issue.location.file.path.clone() }; - format!("{}/../{}", base_part, path_parts[path_parts.len() - 1]).to_string() - } else { - issue.location.file.path.clone() - }; - let mut row = vec![ - issue.id.clone(), - classification_display, - issue.urgency.clone(), - shortened_path, - issue.location.line_number.to_string(), - ]; - if render_blocking_rules { - row.push(blocking_rules.contains_key(&issue.id).to_string()); - row.push( - blocking_rules - .get(&issue.id) - .unwrap_or(&"".to_string()) - .to_string(), - ); + let mut row = vec![ + issue.id.clone(), + classification_display, + issue.urgency.clone(), + shortened_path, + issue.location.line_number.to_string(), + ]; + if render_blocking_rules { + row.push(blocking_rules.contains_key(&issue.id).to_string()); + row.push( + blocking_rules + .get(&issue.id) + .unwrap_or(&"".to_string()) + .to_string(), + ); + } + table.push(row); } - table.push(row); - } - utils::terminal::print_table(table, issues_response.page, issues_response.total_pages); - } else { - let (scans, page, total_pages) = match utils::api::query_scan_list( - &config.get_url(), - Some(&project_name), - *page, - *page_size, - ) { - Ok(scans) => { - let page = scans.page; - let total_pages = scans.total_pages; - let filtered_scans: Vec = scans - .scans - .unwrap_or_default() - .into_iter() - .filter(|scan| scan.project == project_name) - .collect(); - (filtered_scans, page, total_pages) + utils::terminal::print_table(table, issues_response.page, issues_response.total_pages); + } else { + let (scans, page, total_pages) = match utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + *page, + *page_size, + ) { + Ok(scans) => { + let page = scans.page; + let total_pages = scans.total_pages; + // The server already filtered by the resolved project; the old + // client-side `scan.project == cwd_basename` pass is redundant + // and would discard every repo-resolved scan. (COR-1577) + (scans.scans.unwrap_or_default(), page, total_pages) + } + Err(e) => { + if e.to_string().contains("404") { + log::error!( + "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", + resolved.tried_label + ); + } else { + log::error!( + "Unable to fetch scans. Please check your connection and ensure that:\n\ + - The server URL is reachable.\n\ + - Your authentication token is valid.\n\n\ + Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli" + ); + } + std::process::exit(1); + } + }; + // JSON mode stays a valid machine envelope even when empty. + if *json { + let output = json!({ + "page": page, + "total_pages": total_pages, + "results": scans + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return; } - Err(e) => { - if e.to_string().contains("404") { - log::error!("Project with name '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", project_name); + // Human mode: never render a silent empty table on a miss. + if scans.is_empty() { + if resolved.confirmed { + println!( + "Project '{}' has no scans yet. Run 'corgea scan' to create one.", + project_name + ); } else { - log::error!( - "Unable to fetch scans. Please check your connection and ensure that:\n\ - - The server URL is reachable.\n\ - - Your authentication token is valid.\n\n\ - Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli" + println!( + "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", + resolved.tried_label ); } - std::process::exit(1); + return; } - }; - if *json { - let output = json!({ - "page": page, - "total_pages": total_pages, - "results": scans - }); - println!("{}", serde_json::to_string_pretty(&output).unwrap()); - return; - } - let mut table = vec![vec![ - "Scan ID".to_string(), - "Project".to_string(), - "Status".to_string(), - "Repo".to_string(), - "Branch".to_string(), - ]]; + let mut table = vec![vec![ + "Scan ID".to_string(), + "Project".to_string(), + "Status".to_string(), + "Repo".to_string(), + "Branch".to_string(), + ]]; - for scan in &scans { - let formatted_repo = scan.repo.clone().unwrap_or("N/A".to_string()); - let formatted_repo = if formatted_repo != "N/A" { - if let Some(repo_name) = formatted_repo.split('/').next_back() { - let owner = formatted_repo.split('/').nth(3).unwrap_or("unknown"); - let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name); - format!("{}/{}", owner, repo_name) + for scan in &scans { + let formatted_repo = scan.repo.clone().unwrap_or("N/A".to_string()); + let formatted_repo = if formatted_repo != "N/A" { + if let Some(repo_name) = formatted_repo.split('/').next_back() { + let owner = formatted_repo.split('/').nth(3).unwrap_or("unknown"); + let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name); + format!("{}/{}", owner, repo_name) + } else { + formatted_repo + } } else { formatted_repo - } - } else { - formatted_repo - }; + }; - table.push(vec![ - scan.id.clone(), - scan.project.clone(), - scan.status.clone(), - formatted_repo, - scan.branch.clone().unwrap_or("N/A".to_string()), - ]); - } + table.push(vec![ + scan.id.clone(), + scan.project.clone(), + scan.status.clone(), + formatted_repo, + scan.branch.clone().unwrap_or("N/A".to_string()), + ]); + } - utils::terminal::print_table(table, page, total_pages); + utils::terminal::print_table(table, page, total_pages); + } } } diff --git a/src/main.rs b/src/main.rs index de89adb..0658bab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,7 +134,20 @@ enum Commands { project_name: Option, }, /// Wait for the latest in progress scan - Wait { scan_id: Option }, + Wait { + scan_id: Option, + #[arg( + long, + conflicts_with = "repo", + help = "Query this exact Corgea project name directly (skips repo auto-resolution)." + )] + project_name: Option, + #[arg( + long, + help = "Resolve the project from this repo (org/repo slug or remote URL) instead of the git remote." + )] + repo: Option, + }, /// List something, by default it lists the scans #[command(alias = "ls")] List { @@ -159,6 +172,19 @@ enum Commands { #[arg(long, value_parser = clap::value_parser!(u16), help = "Number of items per page")] page_size: Option, + + #[arg( + long, + conflicts_with = "repo", + help = "Query this exact Corgea project name directly (skips repo auto-resolution)." + )] + project_name: Option, + + #[arg( + long, + help = "Resolve the project from this repo (org/repo slug or remote URL) instead of the git remote." + )] + repo: Option, }, /// Inspect something, by default it will inspect a scan Inspect { @@ -600,9 +626,18 @@ fn main() { ), } } - Some(Commands::Wait { scan_id }) => { + Some(Commands::Wait { + scan_id, + project_name, + repo, + }) => { verify_token_and_exit_when_fail(&corgea_config); - wait::run(&corgea_config, scan_id.clone(), None); + wait::run( + &corgea_config, + scan_id.clone(), + project_name.clone(), + repo.clone(), + ); } Some(Commands::List { issues, @@ -611,6 +646,8 @@ fn main() { page_size, scan_id, sca_issues, + project_name, + repo, }) => { verify_token_and_exit_when_fail(&corgea_config); if *issues && *sca_issues { @@ -629,6 +666,8 @@ fn main() { page, page_size, scan_id, + project_name.clone(), + repo.clone(), ); } Some(Commands::Inspect { diff --git a/src/scan.rs b/src/scan.rs index af1fd4a..cb6ec0b 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -48,7 +48,6 @@ pub fn run_command(base_cmd: &String, mut command: Command) -> String { pub struct ScanUploadResult { pub scan_id: String, - pub project_id: Option, } pub fn run_semgrep(config: &Config, project_name: Option) { @@ -65,8 +64,10 @@ pub fn run_semgrep(config: &Config, project_name: Option) { let output = run_command(&base_command.to_string(), command); - if let Some(result) = parse_scan(config, output, true, project_name) { - crate::wait::run(config, Some(result.scan_id), result.project_id); + if let Some(result) = parse_scan(config, output, true, project_name.clone()) { + // Preserve an explicit --project-name for the post-scan wait; when None, + // wait resolves by the git remote / CWD (COR-1577). + crate::wait::run(config, Some(result.scan_id), project_name, None); } } @@ -80,8 +81,10 @@ pub fn run_snyk(config: &Config, project_name: Option) { let output = run_command(&base_command.to_string(), command); - if let Some(result) = parse_scan(config, output, true, project_name) { - crate::wait::run(config, Some(result.scan_id), result.project_id); + if let Some(result) = parse_scan(config, output, true, project_name.clone()) { + // Preserve an explicit --project-name for the post-scan wait; when None, + // wait resolves by the git remote / CWD (COR-1577). + crate::wait::run(config, Some(result.scan_id), project_name, None); } } @@ -369,7 +372,6 @@ pub fn upload_scan( }; let mut sast_scan_id: Option = None; - let mut project_id: Option = None; let mut upload_failed = false; @@ -396,13 +398,6 @@ pub fn upload_scan( sast_scan_id = Some(id_num.to_string()); } } - if let Some(pid_val) = json.get("project_id") { - if let Some(pid_str) = pid_val.as_str() { - project_id = Some(pid_str.to_string()); - } else if let Some(pid_num) = pid_val.as_i64() { - project_id = Some(pid_num.to_string()); - } - } } Err(e) => { log::warn!("Failed to parse response JSON: {}", e); @@ -514,8 +509,5 @@ pub fn upload_scan( println!("Go to {base_url} to see results."); - sast_scan_id.map(|scan_id| ScanUploadResult { - scan_id, - project_id, - }) + sast_scan_id.map(|scan_id| ScanUploadResult { scan_id }) } diff --git a/src/utils/api.rs b/src/utils/api.rs index a504760..ce34062 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -729,6 +729,171 @@ pub fn query_scan_list( } } +#[derive(Deserialize, Debug)] +pub struct ProjectSummary { + // Project.id is an integer in the envelope; model it tolerantly so an + // unexpected string id (or a missing field) never fails deserialization. + #[serde(default)] + pub id: serde_json::Value, + pub name: String, + #[serde(default)] + pub repo_url: Option, +} + +#[derive(Deserialize, Debug)] +pub struct ProjectsResponse { + // Models the `@paginated` envelope status; deserialized for completeness but + // not consumed (the slug guard + non-2xx/parse handling already gate misses). + #[allow(dead_code)] + pub status: String, + #[serde(default)] + pub projects: Option>, +} + +/// Stringify a scalar JSON id (number -> "123", string -> as-is) for the wait +/// scan URL. Returns None for null/array/object/missing so a bogus id never +/// becomes part of a URL like `/project/null/`. +fn id_to_string(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Resolve the canonical project for a repo slug via GET /api/v1/projects?repo_url=… +/// +/// Old-backend safety guard: a pre-COR-1426 backend ignores the unknown +/// `repo_url` param and returns ALL company projects. Keep only candidates +/// whose returned `repo_url` (case-insensitive) contains the slug; on an old +/// backend none match -> Ok(None) -> caller falls back to the CWD-name path. +pub fn resolve_project_by_repo( + url: &str, + slug: &str, +) -> Result, Box> { + let request_url = format!("{}{}/projects", url, API_BASE); + let client = http_client(); + debug(&format!( + "Resolving project via {} (repo_url={})", + request_url, slug + )); + let response = client + .get(&request_url) + .query(&[("repo_url", slug)]) + .send()?; + check_for_warnings(response.headers(), response.status()); + if !response.status().is_success() { + // Endpoint absent/erroring on a very old backend -> treat as unresolved. + return Ok(None); + } + let text = response.text()?; + let parsed: ProjectsResponse = match serde_json::from_str(&text) { + Ok(p) => p, + Err(e) => { + debug(&format!( + "Failed to parse /projects response: {} | body: {}", + e, text + )); + return Ok(None); + } + }; + let slug_lc = slug.to_lowercase(); + let matched = parsed.projects.unwrap_or_default().into_iter().find(|p| { + p.repo_url + .as_deref() + .map(|r| r.to_lowercase().contains(&slug_lc)) + .unwrap_or(false) + }); + Ok(matched) +} + +/// What `list`/`wait` need to drive the existing name-based queries. +#[derive(Debug)] +pub struct ResolvedProject { + /// Sent as `?project=` to the listing endpoints. + pub query_name: String, + /// True only when a backend project was confirmed via /projects. Drives + /// confirmed-but-empty vs unresolved-miss messaging. + pub confirmed: bool, + /// Numeric id (stringified) when confirmed and the id is a scalar — used + /// for the wait scan URL. None otherwise (falls back to query_name). + pub project_id: Option, + /// What we looked for ("project 'x'" / "repo 'org/repo'" / "directory + /// 'dir'"), pre-formatted for the miss message. + pub tried_label: String, +} + +/// Resolve which project `list`/`wait` should query. +/// 1) --project-name -> use verbatim, skip resolution. +/// 2) else slug from --repo or the git remote -> resolve_project_by_repo: +/// confirmed -> canonical {name,id}. +/// 3) unconfirmed: explicit --repo -> query the slug as a name (never the CWD); +/// auto-detected remote -> CWD basename (no-regression fallback). +/// 4) no remote -> CWD basename (today's behavior). +pub fn resolve_project( + url: &str, + project_name_override: Option<&str>, + repo_override: Option<&str>, +) -> ResolvedProject { + if let Some(name) = project_name_override { + return ResolvedProject { + query_name: name.to_string(), + confirmed: false, + project_id: None, + tried_label: format!("project '{}'", name), + }; + } + + // Explicit --repo vs auto-detected remote changes the unconfirmed fallback. + let repo_was_explicit = repo_override.is_some(); + // --repo may already be a bare `org/repo` slug (extract_repo_slug needs a + // host segment, so it returns None for a 2-segment value) -> use raw value. + let slug = match repo_override { + Some(r) => utils::generic::extract_repo_slug(r).or_else(|| Some(r.to_string())), + None => utils::generic::get_repo_info("./") + .ok() + .flatten() + .and_then(|info| info.repo_url) + .and_then(|u| utils::generic::extract_repo_slug(&u)), + }; + + // CWD basename, resolved lazily — only the fallback paths below read it. + let cwd = + || utils::generic::get_current_working_directory().unwrap_or_else(|| "unknown".to_string()); + + if let Some(slug) = slug { + if let Ok(Some(project)) = resolve_project_by_repo(url, &slug) { + return ResolvedProject { + query_name: project.name, + confirmed: true, + project_id: id_to_string(&project.id), + tried_label: format!("repo '{}'", slug), + }; + } + // Unconfirmed: explicit --repo queries the slug as a name (clean miss, + // never wrong CWD results); auto-detected remote keeps the CWD fallback. + let query_name = if repo_was_explicit { + slug.clone() + } else { + cwd() + }; + return ResolvedProject { + query_name, + confirmed: false, + project_id: None, + tried_label: format!("repo '{}'", slug), + }; + } + + let cwd = cwd(); + ResolvedProject { + tried_label: format!("directory '{}'", cwd), + query_name: cwd, + confirmed: false, + project_id: None, + } +} + pub fn exchange_code_for_token( base_url: &str, code: &str, @@ -1302,4 +1467,69 @@ mod tests { assert!(result.is_err()); assert_eq!(attempts.get(), RETRY_BACKOFF_SECS.len() + 1); } + + // Single-response-per-connection JSON stub on an ephemeral port; returns base URL. + fn spawn_projects_stub(body: &'static str) -> String { + use std::io::{Read, Write}; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + // Drain the request headers before responding. Closing the + // socket with an unread request still in the kernel buffer + // triggers a TCP RST that surfaces on the client as hyper + // `UnexpectedMessage` (flaky, timing-dependent). + let mut chunk = [0u8; 1024]; + let mut buf = Vec::new(); + while let Ok(n) = stream.read(&mut chunk) { + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + } + }); + base + } + + #[test] + fn resolve_project_by_repo_keeps_only_repo_url_matches() { + // New backend: filter applied, one matching project returned. + let base = spawn_projects_stub( + r#"{"status":"ok","projects":[{"id":7,"name":"bohappdev/dotnet-azure-web-tsb","repo_url":"https://github.com/bohappdev/dotnet-azure-web-tsb"}]}"#, + ); + let got = resolve_project_by_repo(&base, "bohappdev/dotnet-azure-web-tsb").unwrap(); + assert_eq!( + got.map(|p| p.name).as_deref(), + Some("bohappdev/dotnet-azure-web-tsb") + ); + } + + #[test] + fn resolve_project_by_repo_guards_against_old_backend_returning_all() { + // Old backend ignores ?repo_url and returns unrelated projects -> guard -> None. + let base = spawn_projects_stub( + r#"{"status":"ok","projects":[{"id":1,"name":"other/repo","repo_url":"https://github.com/other/repo"},{"id":2,"name":"misc/thing","repo_url":"https://github.com/misc/thing"}]}"#, + ); + let got = resolve_project_by_repo(&base, "bohappdev/dotnet-azure-web-tsb").unwrap(); + assert!(got.is_none(), "non-matching projects must be discarded"); + } + + #[test] + fn resolve_project_by_repo_empty_projects_is_none() { + let base = spawn_projects_stub(r#"{"status":"ok","projects":[]}"#); + assert!(resolve_project_by_repo(&base, "org/repo") + .unwrap() + .is_none()); + } } diff --git a/src/utils/generic.rs b/src/utils/generic.rs index f07d013..6386b62 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -260,6 +260,48 @@ fn extract_repo_name_from_url(url: &str) -> Option { None } +/// Extract the `org/repo` slug from a git remote URL. Returns the last two +/// meaningful path segments so it is a substring of essentially every stored +/// (normalized) `repo_url` form. Distinct from `extract_repo_name_from_url`, +/// which returns only the final segment (`repo`). +/// +/// Handles: +/// https://github.com/org/repo(.git) -> org/repo +/// git@github.com:org/repo(.git) -> org/repo +/// ssh://git@github.com/org/repo -> org/repo +/// https://dev.azure.com/org/project/_git/repo -> project/_git/repo +/// +/// Azure `_git` is kept (and the preceding project segment included) because +/// doghouse `normalize_repo_url` stores Azure as `.../project/_git/repo` +/// (`heeler/models.py:208-212`) — `project/_git/repo` is a substring of that, +/// `project/repo` is NOT. Azure SSH remotes (`ssh.dev.azure.com/v3/...`, which +/// carry no `_git` segment) are a known limitation; users pass --project-name. +/// +/// Returns None when fewer than two path segments follow the host (a bare host +/// or garbage input). +pub fn extract_repo_slug(url: &str) -> Option { + let url = url.trim().trim_end_matches('/'); + let url = url.strip_suffix(".git").unwrap_or(url); + // Drop scheme (`https://`, `ssh://`, …) if present. + let url = url.rsplit("://").next().unwrap_or(url); + // Split host from path: URL forms use '/', scp-like `git@host:org/repo` + // uses ':'. After filtering empties, segments[0] is the host. + let segments: Vec<&str> = url.split(['/', ':']).filter(|s| !s.is_empty()).collect(); + if segments.len() < 3 { + return None; // need host + at least org + repo + } + let last = segments[segments.len() - 1]; + let prev = segments[segments.len() - 2]; + if prev == "_git" && segments.len() >= 4 { + // Azure DevOps: keep the project so the slug stays a substring of the + // normalized stored URL (.../project/_git/repo). + let project = segments[segments.len() - 3]; + Some(format!("{}/_git/{}", project, last)) + } else { + Some(format!("{}/{}", prev, last)) + } +} + pub fn get_env_var_if_exists(var_name: &str) -> Option { match env::var(var_name) { Ok(value) if !value.trim().is_empty() => Some(value), @@ -370,4 +412,55 @@ mod tests { added ); } + + #[test] + fn extract_repo_slug_handles_common_remote_forms() { + assert_eq!( + extract_repo_slug("https://github.com/org/repo.git").as_deref(), + Some("org/repo") + ); + assert_eq!( + extract_repo_slug("https://github.com/org/repo").as_deref(), + Some("org/repo") + ); + assert_eq!( + extract_repo_slug("git@github.com:org/repo.git").as_deref(), + Some("org/repo") + ); + assert_eq!( + extract_repo_slug("git@github.com:org/repo").as_deref(), + Some("org/repo") + ); + assert_eq!( + extract_repo_slug("ssh://git@github.com/org/repo").as_deref(), + Some("org/repo") + ); + assert_eq!( + extract_repo_slug("https://github.com/org/repo/").as_deref(), + Some("org/repo") + ); + // host:port should not leak the port into the slug + assert_eq!( + extract_repo_slug("https://git.example.com:8443/org/repo").as_deref(), + Some("org/repo") + ); + // Bank of Hope case + assert_eq!( + extract_repo_slug("git@github.com:bohappdev/dotnet-azure-web-tsb.git").as_deref(), + Some("bohappdev/dotnet-azure-web-tsb") + ); + // Azure DevOps `_git` HTTPS -> keeps project + _git so it stays a substring + // of the normalized stored URL. + assert_eq!( + extract_repo_slug("https://dev.azure.com/org/project/_git/repo").as_deref(), + Some("project/_git/repo") + ); + } + + #[test] + fn extract_repo_slug_returns_none_for_unsplittable_input() { + assert_eq!(extract_repo_slug("not a url"), None); + assert_eq!(extract_repo_slug(""), None); + assert_eq!(extract_repo_slug("github.com"), None); // host only + } } diff --git a/src/wait.rs b/src/wait.rs index fdd69bb..6039548 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -2,14 +2,18 @@ use crate::config::Config; use crate::scanners::blast; use crate::utils; -pub fn run(config: &Config, scan_id: Option, project_id: Option) { - let project_name = match utils::generic::get_current_working_directory() { - Some(name) => name, - None => { - log::error!("Unable to retrieve the current working directory. Please check your permissions and try again."); - std::process::exit(1); - } - }; +pub fn run( + config: &Config, + scan_id: Option, + project_name_override: Option, + repo_override: Option, +) { + let resolved = utils::api::resolve_project( + &config.get_url(), + project_name_override.as_deref(), + repo_override.as_deref(), + ); + let project_name = resolved.query_name.clone(); let scans_result = utils::api::query_scan_list(&config.get_url(), Some(&project_name), Some(1), None); @@ -45,13 +49,23 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) None => match scans.first() { Some(scan) => (scan.id.clone(), scan.status == "Complete"), None => { - log::error!("Error querying scan list"); + if resolved.confirmed { + log::error!( + "Project '{}' has no scans yet. Run 'corgea scan' to start one.", + project_name + ); + } else { + log::error!( + "No scan to wait for: no Corgea project found for {}. Run 'corgea scan', or pass --scan-id / --project-name.", + resolved.tried_label + ); + } std::process::exit(1); } }, }; - let scan_url = match &project_id { + let scan_url = match &resolved.project_id { Some(pid) => format!("{}/project/{}/?scan_id={}", config.get_url(), pid, scan_id), None => format!( "{}/project/{}?scan_id={}", diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bc4ec03..a8fb3af 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -433,3 +433,62 @@ pub fn tree_harness( .vuln_statuses(statuses) .build() } + +// --- project-resolution e2e fixtures (shared by list_resolution.rs and +// wait_resolution.rs) ------------------------------------------------------- + +/// Canonical project name for the Bank-of-Hope resolution case: the dir +/// basename (`dotnet-azure-web-tsb`) differs from the stored project name. +#[allow(dead_code)] +pub const CANON: &str = "bohappdev/dotnet-azure-web-tsb"; +/// Git remote whose slug resolves to `CANON`. +#[allow(dead_code)] +pub const REMOTE: &str = "https://github.com/bohappdev/dotnet-azure-web-tsb.git"; + +/// `/projects` hit returning the canonical project whose `repo_url` contains +/// the slug (id 7) — the new-backend confirmed path. +#[allow(dead_code)] +pub fn projects_match() -> String { + r#"{"status":"ok","projects":[{"id":7,"name":"bohappdev/dotnet-azure-web-tsb","repo_url":"https://github.com/bohappdev/dotnet-azure-web-tsb"}]}"#.to_string() +} + +/// `/projects` miss (repo not onboarded / pre-COR-1426 backend filtered out). +#[allow(dead_code)] +pub fn projects_empty() -> String { + r#"{"status":"ok","projects":[]}"#.to_string() +} + +/// `/scans` returning one `Complete` scan under `project`. +#[allow(dead_code)] +pub fn scans_one(project: &str) -> String { + format!( + r#"{{"status":"ok","page":1,"total_pages":1,"scans":[{{"id":"scan-123","project":"{project}","repo":"https://github.com/bohappdev/dotnet-azure-web-tsb","branch":"main","status":"Complete","engine":"blast","created_at":"2026-01-01T00:00:00Z"}}]}}"# + ) +} + +/// `/scans` returning an empty page. +#[allow(dead_code)] +pub fn scans_empty() -> String { + r#"{"status":"ok","page":1,"total_pages":1,"scans":[]}"#.to_string() +} + +/// Temp git repo at `/` with `origin` set to `remote`. The dir +/// basename is the caller's to choose so it can differ from the stored name. +#[allow(dead_code)] +pub fn temp_git_repo(dirname: &str, remote: &str) -> (TempDir, std::path::PathBuf) { + let tmp = TempDir::new().expect("temp dir"); + let repo_dir = tmp.path().join(dirname); + std::fs::create_dir(&repo_dir).expect("create repo dir"); + let repo = git2::Repository::init(&repo_dir).expect("git init"); + repo.remote("origin", remote).expect("set origin"); + (tmp, repo_dir) +} + +/// Temp NON-git dir at `/` (no remote). +#[allow(dead_code)] +pub fn temp_plain_dir(dirname: &str) -> (TempDir, std::path::PathBuf) { + let tmp = TempDir::new().expect("temp dir"); + let dir = tmp.path().join(dirname); + std::fs::create_dir(&dir).expect("create dir"); + (tmp, dir) +} diff --git a/tests/list_resolution.rs b/tests/list_resolution.rs new file mode 100644 index 0000000..a33d005 --- /dev/null +++ b/tests/list_resolution.rs @@ -0,0 +1,262 @@ +//! End-to-end tests for `corgea list` repo-URL project resolution (COR-1577). +//! +//! Each test runs the real binary against a one-response-per-connection HTTP +//! stub (`common::spawn_http_stub`) and, where a git remote matters, a temp +//! git repo built with `git2` whose directory basename DIFFERS from the stored +//! canonical project name (the Bank of Hope case: dir `dotnet-azure-web-tsb` +//! vs project `bohappdev/dotnet-azure-web-tsb`). +//! +//! Routing matches on the request-target PATH PREFIX with `starts_with`: the +//! `/projects` request carries a percent-encoded query string +//! (`?repo_url=bohappdev%2Fdotnet-azure-web-tsb`), so the full target is not a +//! stable key. `verify_token_and_exit_when_fail` calls `GET /api/v1/verify` +//! first, so every stub serves it. + +mod common; + +use common::{ + projects_empty, projects_match, scans_empty, scans_one, temp_git_repo, temp_plain_dir, CANON, + REMOTE, +}; +use std::path::Path; +use std::process::Output; + +// --- stub bodies ----------------------------------------------------------- + +/// `/issues` returning one issue (status `ok`). +fn issues_one() -> String { + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":1,"issues":[{"id":"issue-abc","scan_id":"scan-123","status":"open","urgency":"high","created_at":"2026-01-01T00:00:00Z","classification":{"id":"CWE-89","name":"SQL Injection","description":null},"location":{"file":{"name":"app.py","language":"python","path":"src/app.py"},"line_number":42,"project":{"name":"bohappdev/dotnet-azure-web-tsb","branch":null,"git_sha":null}},"details":null,"auto_triage":{"false_positive_detection":{"status":"none","reasoning":null}},"auto_fix_suggestion":null}]}"#.to_string() +} + +/// `/issues` exact-name miss (HTTP 200 `no_project_found`, mapped to 404). +fn issues_miss() -> String { + r#"{"status":"no_project_found"}"#.to_string() +} + +// --- harness --------------------------------------------------------------- + +/// Stub serving verify + the three listing endpoints, keyed on path prefix. +fn spawn_stub(projects: String, scans: String, issues: String) -> String { + common::spawn_http_stub(move |path| { + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/projects?repo_url=") { + ("200 OK", projects.clone()) + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans.clone()) + } else if path.starts_with("/api/v1/issues?") { + ("200 OK", issues.clone()) + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }) +} + +/// Run `corgea list ` against `url` from `cwd`, isolated from the +/// host (temp HOME). `CORGEA_URL`/`CORGEA_TOKEN` are layered back on after +/// `corgea_isolated` strips them. +fn run_list(args: &[&str], url: &str, cwd: &Path) -> Output { + let (mut cmd, _home) = common::corgea_isolated(); + cmd.arg("list"); + cmd.args(args); + cmd.env("CORGEA_URL", url) + .env("CORGEA_TOKEN", "test-token") + .current_dir(cwd); + cmd.output().expect("spawn corgea") +} + +// --- tests ----------------------------------------------------------------- + +#[test] +fn list_uses_canonical_name_from_repo() { + let url = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&[], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Project column shows the canonical org/repo, proving resolution, not the + // dir basename, drove the listing. + assert!( + stdout.contains("bohappdev/dotnet-azure-web-tsb"), + "stdout: {stdout}" + ); + assert!(stdout.contains("scan-123"), "stdout: {stdout}"); +} + +#[test] +fn list_issues_shows_repo_resolved_issue() { + let url = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--issues"], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("issue-abc"), "stdout: {stdout}"); +} + +#[test] +fn list_repo_flag_resolves_from_flag_not_remote() { + use std::sync::{Arc, Mutex}; + let hits = Arc::new(Mutex::new(Vec::::new())); + let hits_c = hits.clone(); + // Records every request target so we can prove the slug came from --repo. + let url = common::spawn_http_stub(move |path| { + hits_c.lock().unwrap().push(path.to_string()); + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/projects?repo_url=") { + ("200 OK", projects_match()) + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans_one(CANON)) + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }); + // Non-git dir: no remote, so a resolved slug can ONLY have come from --repo. + let (_tmp, dir) = temp_plain_dir("unrelated-dir"); + let out = run_list(&["--repo", "bohappdev/dotnet-azure-web-tsb"], &url, &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("bohappdev/dotnet-azure-web-tsb"), + "stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/projects?repo_url=bohappdev%2Fdotnet-azure-web-tsb")), + "expected a /projects hit carrying the flag slug; hits: {hits:?}" + ); +} + +#[test] +fn list_miss_names_repo_no_empty_table() { + let url = spawn_stub(projects_empty(), scans_empty(), issues_miss()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&[], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("repo 'bohappdev/dotnet-azure-web-tsb'"), + "stdout: {stdout}" + ); + assert!( + !stdout.contains("Scan ID"), + "should not render a table; stdout: {stdout}" + ); +} + +#[test] +fn list_no_remote_falls_back_to_cwd_name() { + // Regression: non-git dir whose basename matches a project the scans stub + // serves under that exact name still lists scans (CWD-name fallback). + let url = spawn_stub(projects_empty(), scans_one("myproject"), issues_one()); + let (_tmp, dir) = temp_plain_dir("myproject"); + let out = run_list(&[], &url, &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("myproject"), "stdout: {stdout}"); + assert!(stdout.contains("scan-123"), "stdout: {stdout}"); +} + +#[test] +fn list_project_name_override() { + let url = spawn_stub(projects_empty(), scans_one("some/name"), issues_one()); + let (_tmp, dir) = temp_plain_dir("whatever"); + let out = run_list(&["--project-name", "some/name"], &url, &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("some/name"), "stdout: {stdout}"); +} + +#[test] +fn list_project_name_and_repo_are_mutually_exclusive() { + let (_tmp, dir) = temp_plain_dir("whatever"); + let (mut cmd, _home) = common::corgea_isolated(); + cmd.args(["list", "--project-name", "a", "--repo", "b"]) + .env("CORGEA_URL", "http://127.0.0.1:1") + .env("CORGEA_TOKEN", "test-token") + .current_dir(&dir); + let out = cmd.output().expect("spawn corgea"); + // clap rejects conflicting args at parse time with a usage error, exit 2. + assert_eq!( + out.status.code(), + Some(2), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn list_json_miss_is_valid_empty_envelope() { + let url = spawn_stub(projects_empty(), scans_empty(), issues_miss()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--json"], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout not JSON ({e}): {stdout}")); + assert_eq!( + v["results"].as_array().map(|a| a.len()), + Some(0), + "results should be empty; stdout: {stdout}" + ); + assert!( + !stdout.contains("No Corgea project"), + "no human prose on stdout; stdout: {stdout}" + ); +} + +#[test] +fn list_issues_json_miss_keeps_stdout_clean() { + let url = spawn_stub(projects_empty(), scans_empty(), issues_miss()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--issues", "--json"], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + // The --issues miss is a hard error via log::error! (stderr); stdout stays + // clean so JSON consumers never see corrupt output. + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!( + stdout.trim().is_empty(), + "stdout must be clean; stdout: {stdout}" + ); + assert!( + stderr.contains("No Corgea project found"), + "stderr should name the miss; stderr: {stderr}" + ); +} diff --git a/tests/wait_resolution.rs b/tests/wait_resolution.rs new file mode 100644 index 0000000..28c01b3 --- /dev/null +++ b/tests/wait_resolution.rs @@ -0,0 +1,208 @@ +//! End-to-end tests for `corgea wait` repo-URL project resolution (COR-1577). +//! +//! Mirrors `tests/list_resolution.rs`: each test runs the real binary against a +//! one-response-per-connection HTTP stub (`common::spawn_http_stub`) and, where +//! a git remote matters, a temp git repo built with `git2` whose directory +//! basename DIFFERS from the stored canonical project name (the Bank of Hope +//! case: dir `dotnet-azure-web-tsb` vs project `bohappdev/dotnet-azure-web-tsb`). +//! +//! Routing matches on the request-target PATH PREFIX with `starts_with`: the +//! `/projects` request carries a percent-encoded query string +//! (`?repo_url=bohappdev%2Fdotnet-azure-web-tsb`), so the full target is not a +//! stable key. `verify_token_and_exit_when_fail` calls `GET /api/v1/verify` +//! first, so every stub serves it. The `/api/v1/scan/` arm serves BOTH +//! `GET /api/v1/scan/{id}` (so `check_scan_status` succeeds) and +//! `/api/v1/scan/{id}/issues?…` (so `report_scan_status` succeeds) — branching +//! on `contains("/issues")`. + +mod common; + +use common::{ + projects_empty, projects_match, scans_empty, scans_one, temp_git_repo, temp_plain_dir, CANON, + REMOTE, +}; +use std::path::Path; +use std::process::Output; + +// --- stub bodies ----------------------------------------------------------- + +/// `GET /api/v1/scan/{id}` returning a single completed scan (consumed by +/// `check_scan_status`/`get_scan`, which check the lowercase `complete`). +fn scan_complete() -> String { + r#"{"id":"scan-123","project":"bohappdev/dotnet-azure-web-tsb","repo":"https://github.com/bohappdev/dotnet-azure-web-tsb","branch":"main","status":"complete","engine":"blast","created_at":"2026-01-01T00:00:00Z"}"#.to_string() +} + +/// `GET /api/v1/scan/{id}/issues` returning an empty page (one round-trip: +/// `total_pages` 1). `report_scan_status` groups by urgency and succeeds with +/// no issues — enough to print the result link. +fn scan_issues_empty() -> String { + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":0,"issues":[]}"#.to_string() +} + +// --- harness --------------------------------------------------------------- + +/// Stub serving verify + projects + scans + the single-scan/scan-issues +/// endpoints, keyed on path prefix. +fn spawn_stub(projects: String, scans: String) -> String { + common::spawn_http_stub(move |path| { + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/projects?repo_url=") { + ("200 OK", projects.clone()) + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans.clone()) + } else if path.starts_with("/api/v1/scan/") { + if path.contains("/issues") { + ("200 OK", scan_issues_empty()) + } else { + ("200 OK", scan_complete()) + } + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }) +} + +/// Run `corgea wait ` against `url` from `cwd`, isolated from the host +/// (temp HOME). `CORGEA_URL`/`CORGEA_TOKEN` are layered back on after +/// `corgea_isolated` strips them. +fn run_wait(args: &[&str], url: &str, cwd: &Path) -> Output { + let (mut cmd, _home) = common::corgea_isolated(); + cmd.arg("wait"); + cmd.args(args); + cmd.env("CORGEA_URL", url) + .env("CORGEA_TOKEN", "test-token") + .current_dir(cwd); + cmd.output().expect("spawn corgea") +} + +// --- tests ----------------------------------------------------------------- + +#[test] +fn wait_uses_canonical_project_and_numeric_id_scan_url() { + let url = spawn_stub(projects_match(), scans_one(CANON)); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_wait(&[], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // The scan URL uses the numeric project id (7) returned by /projects, + // proving `resolved.project_id` flows through — not the dir basename. + assert!( + stdout.contains("/project/7/?scan_id=scan-123"), + "stdout: {stdout}" + ); +} + +#[test] +fn wait_repo_flag_resolves_from_flag_not_remote() { + use std::sync::{Arc, Mutex}; + let hits = Arc::new(Mutex::new(Vec::::new())); + let hits_c = hits.clone(); + // Records every request target so we can prove the slug came from --repo. + let url = common::spawn_http_stub(move |path| { + hits_c.lock().unwrap().push(path.to_string()); + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/projects?repo_url=") { + ("200 OK", projects_match()) + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans_one(CANON)) + } else if path.starts_with("/api/v1/scan/") { + if path.contains("/issues") { + ("200 OK", scan_issues_empty()) + } else { + ("200 OK", scan_complete()) + } + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }); + // Non-git dir: no remote, so a resolved slug can ONLY have come from --repo. + let (_tmp, dir) = temp_plain_dir("unrelated-dir"); + let out = run_wait(&["--repo", "bohappdev/dotnet-azure-web-tsb"], &url, &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("/project/7/?scan_id=scan-123"), + "stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/projects?repo_url=bohappdev%2Fdotnet-azure-web-tsb")), + "expected a /projects hit carrying the flag slug; hits: {hits:?}" + ); +} + +#[test] +fn wait_miss_names_repo_no_bare_error() { + let url = spawn_stub(projects_empty(), scans_empty()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_wait(&[], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "stdout: {stdout}\nstderr: {stderr}" + ); + // Clear, actionable miss naming the repo the resolver tried. + assert!( + stderr.contains("repo 'bohappdev/dotnet-azure-web-tsb'"), + "stderr should name the repo; stderr: {stderr}" + ); + // The bare cryptic error is gone for good. + assert!( + !stdout.contains("Error querying scan list") + && !stderr.contains("Error querying scan list"), + "the bare error must be absent; stdout: {stdout}\nstderr: {stderr}" + ); +} + +#[test] +fn wait_project_name_override() { + let url = spawn_stub(projects_empty(), scans_one("some/name")); + let (_tmp, dir) = temp_plain_dir("whatever"); + let out = run_wait(&["--project-name", "some/name"], &url, &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Override skips resolution: no confirmed project id, so the scan URL falls + // back to the name form keyed on the exact `--project-name` value. + assert!( + stdout.contains("/project/some/name?scan_id=scan-123"), + "stdout: {stdout}" + ); +} + +#[test] +fn wait_project_name_and_repo_are_mutually_exclusive() { + let (_tmp, dir) = temp_plain_dir("whatever"); + let (mut cmd, _home) = common::corgea_isolated(); + cmd.args(["wait", "--project-name", "a", "--repo", "b"]) + .env("CORGEA_URL", "http://127.0.0.1:1") + .env("CORGEA_TOKEN", "test-token") + .current_dir(&dir); + let out = cmd.output().expect("spawn corgea"); + // clap rejects conflicting args at parse time with a usage error, exit 2. + assert_eq!( + out.status.code(), + Some(2), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} From 916df2079a7e507060f75c7d2fdb206d7c931212 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Tue, 30 Jun 2026 10:35:50 +0200 Subject: [PATCH 2/2] fix(cli): harden list/wait project resolution (PR #122 review) Addresses four review findings on the COR-1577 repo-URL resolver: 1. Boundary-aware repo match (was a substring `contains`): the backend's `repo_url__icontains` returns siblings like `org/repo-v2` for slug `org/repo`, and the old guard could confirm the wrong project. Now matches on a path-segment boundary (equal or ends-with `/`, after stripping `.git`/trailing slash). 2. Subdirectory invocation: resolution used `Repository::open`, which only succeeds at the repo root, so `list`/`wait` from a subdir fell back to the subdir basename. New `discover_repo_url` walks up via `Repository::discover`. 3. Surface hard resolver failures: `resolve_project` now returns a Result and propagates network/auth/5xx from `/projects` instead of silently falling back to the local-directory project; a clean no-match (or 404 on an old backend) stays a soft fallback. 4. Skip redundant resolution: `list --issues --scan-id` hits `/scan/{id}/issues`, which ignores the project, so the extra `/projects` round-trip is now skipped. Adds unit tests (boundary match, sibling rejection, 404-soft vs 5xx-hard) and integration tests (no `/projects` call for `--issues --scan-id`, resolution from a subdirectory). --- src/list.rs | 45 +++++++++++--- src/utils/api.rs | 130 ++++++++++++++++++++++++++++++++------- src/utils/generic.rs | 13 ++++ src/wait.rs | 17 ++++- tests/list_resolution.rs | 87 ++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 33 deletions(-) diff --git a/src/list.rs b/src/list.rs index 43f66c3..bc70287 100644 --- a/src/list.rs +++ b/src/list.rs @@ -116,13 +116,35 @@ pub fn run( Some(sca_issues_response.total_pages), ); } else { - // Resolve once for both the --issues and scan-listing paths. - let resolved = utils::api::resolve_project( - &config.get_url(), - project_name_override.as_deref(), - repo_override.as_deref(), - ); - let project_name = resolved.query_name.clone(); + // The --scan-id issue route hits /scan/{id}/issues and ignores the + // project, so skip the extra /projects resolution in that one mode; + // every other path here queries by project and needs it resolved. + let resolved: Option = if *issues && scan_id.is_some() { + None + } else { + match utils::api::resolve_project( + &config.get_url(), + project_name_override.as_deref(), + repo_override.as_deref(), + ) { + Ok(resolved) => Some(resolved), + Err(e) => { + log::error!( + "Unable to resolve the Corgea project. Please check your connection and ensure that:\n\ + - The server URL is reachable.\n\ + - Your authentication token is valid.\n\n\ + Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli\n\n\ + Error details: {}", + e + ); + std::process::exit(1); + } + } + }; + let project_name = resolved + .as_ref() + .map(|r| r.query_name.clone()) + .unwrap_or_default(); if *issues { let issues_response = match utils::api::get_scan_issues( &config.get_url(), @@ -137,12 +159,12 @@ pub fn run( if e.to_string().contains("404") { if scan_id.is_some() { log::error!("Scan with ID '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", scan_id.as_ref().unwrap()); - } else if resolved.confirmed { + } else if resolved.as_ref().map(|r| r.confirmed).unwrap_or(false) { log::error!("Project '{}' has no issues yet. Run 'corgea scan' to create a scan for this project.", project_name); } else { log::error!( "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", - resolved.tried_label + resolved.as_ref().map(|r| r.tried_label.as_str()).unwrap_or_default() ); } } else { @@ -281,6 +303,11 @@ pub fn run( utils::terminal::print_table(table, issues_response.page, issues_response.total_pages); } else { + // Scan-listing always resolves (the skip only applies to + // --issues --scan-id), so `resolved` is present here. + let resolved = resolved + .as_ref() + .expect("scan listing always resolves the project"); let (scans, page, total_pages) = match utils::api::query_scan_list( &config.get_url(), Some(&project_name), diff --git a/src/utils/api.rs b/src/utils/api.rs index ce34062..080a1b7 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -761,12 +761,29 @@ fn id_to_string(v: &serde_json::Value) -> Option { } } +/// True when a stored `repo_url` refers to exactly `slug` (an `org/repo`-style +/// value, already lowercased). Matches on a path-segment boundary so a sibling +/// or prefix repo is never mistaken for the target: the backend's +/// `repo_url__icontains` filter returns `org/repo-v2` for slug `org/repo`, and a +/// plain substring check would wrongly accept it. Strips `.git`/trailing slash +/// first so `…/org/repo.git` and `…/org/repo/` both match `org/repo`. +fn repo_url_matches_slug(repo_url: &str, slug_lc: &str) -> bool { + let r = repo_url.trim().trim_end_matches('/'); + let r = r.strip_suffix(".git").unwrap_or(r).to_lowercase(); + r == slug_lc || r.ends_with(&format!("/{}", slug_lc)) +} + /// Resolve the canonical project for a repo slug via GET /api/v1/projects?repo_url=… /// /// Old-backend safety guard: a pre-COR-1426 backend ignores the unknown /// `repo_url` param and returns ALL company projects. Keep only candidates -/// whose returned `repo_url` (case-insensitive) contains the slug; on an old -/// backend none match -> Ok(None) -> caller falls back to the CWD-name path. +/// whose returned `repo_url` matches the slug on a path-segment boundary; on an +/// old backend none match -> Ok(None) -> caller falls back to the CWD-name path. +/// +/// Hard failures (network, auth, 5xx) return `Err` so the caller can surface the +/// backend problem instead of silently resolving to the local-directory project; +/// only a clean "no match" (or a 404 from a backend without the endpoint) is a +/// soft `Ok(None)`. pub fn resolve_project_by_repo( url: &str, slug: &str, @@ -782,10 +799,17 @@ pub fn resolve_project_by_repo( .query(&[("repo_url", slug)]) .send()?; check_for_warnings(response.headers(), response.status()); - if !response.status().is_success() { - // Endpoint absent/erroring on a very old backend -> treat as unresolved. + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + // /projects absent on a very old backend -> soft miss; the caller falls + // back to the name-based path, so this stays a no-regression case. return Ok(None); } + if !status.is_success() { + // Auth (401/403) or server (5xx) failure: surface it rather than + // silently resolving to the local-directory project. + return Err(format!("/projects request failed: HTTP {}", status).into()); + } let text = response.text()?; let parsed: ProjectsResponse = match serde_json::from_str(&text) { Ok(p) => p, @@ -801,7 +825,7 @@ pub fn resolve_project_by_repo( let matched = parsed.projects.unwrap_or_default().into_iter().find(|p| { p.repo_url .as_deref() - .map(|r| r.to_lowercase().contains(&slug_lc)) + .map(|r| repo_url_matches_slug(r, &slug_lc)) .unwrap_or(false) }); Ok(matched) @@ -825,36 +849,38 @@ pub struct ResolvedProject { /// Resolve which project `list`/`wait` should query. /// 1) --project-name -> use verbatim, skip resolution. -/// 2) else slug from --repo or the git remote -> resolve_project_by_repo: +/// 2) else slug from --repo or the discovered git remote -> resolve_project_by_repo: /// confirmed -> canonical {name,id}. /// 3) unconfirmed: explicit --repo -> query the slug as a name (never the CWD); /// auto-detected remote -> CWD basename (no-regression fallback). /// 4) no remote -> CWD basename (today's behavior). +/// +/// Returns `Err` only for a hard resolver failure (network/auth/5xx from +/// /projects); a clean "no match" resolves to the fallback name above. pub fn resolve_project( url: &str, project_name_override: Option<&str>, repo_override: Option<&str>, -) -> ResolvedProject { +) -> Result> { if let Some(name) = project_name_override { - return ResolvedProject { + return Ok(ResolvedProject { query_name: name.to_string(), confirmed: false, project_id: None, tried_label: format!("project '{}'", name), - }; + }); } // Explicit --repo vs auto-detected remote changes the unconfirmed fallback. let repo_was_explicit = repo_override.is_some(); // --repo may already be a bare `org/repo` slug (extract_repo_slug needs a // host segment, so it returns None for a 2-segment value) -> use raw value. + // No --repo: discover the enclosing repo's remote (works from a subdir). let slug = match repo_override { Some(r) => utils::generic::extract_repo_slug(r).or_else(|| Some(r.to_string())), - None => utils::generic::get_repo_info("./") - .ok() - .flatten() - .and_then(|info| info.repo_url) - .and_then(|u| utils::generic::extract_repo_slug(&u)), + None => { + utils::generic::discover_repo_url().and_then(|u| utils::generic::extract_repo_slug(&u)) + } }; // CWD basename, resolved lazily — only the fallback paths below read it. @@ -862,13 +888,15 @@ pub fn resolve_project( || utils::generic::get_current_working_directory().unwrap_or_else(|| "unknown".to_string()); if let Some(slug) = slug { - if let Ok(Some(project)) = resolve_project_by_repo(url, &slug) { - return ResolvedProject { + // `?` propagates hard resolver failures (network/auth/5xx); only a clean + // Ok(None) "no match" falls through to the name-based fallback below. + if let Some(project) = resolve_project_by_repo(url, &slug)? { + return Ok(ResolvedProject { query_name: project.name, confirmed: true, project_id: id_to_string(&project.id), tried_label: format!("repo '{}'", slug), - }; + }); } // Unconfirmed: explicit --repo queries the slug as a name (clean miss, // never wrong CWD results); auto-detected remote keeps the CWD fallback. @@ -877,21 +905,21 @@ pub fn resolve_project( } else { cwd() }; - return ResolvedProject { + return Ok(ResolvedProject { query_name, confirmed: false, project_id: None, tried_label: format!("repo '{}'", slug), - }; + }); } let cwd = cwd(); - ResolvedProject { + Ok(ResolvedProject { tried_label: format!("directory '{}'", cwd), query_name: cwd, confirmed: false, project_id: None, - } + }) } pub fn exchange_code_for_token( @@ -1470,6 +1498,12 @@ mod tests { // Single-response-per-connection JSON stub on an ephemeral port; returns base URL. fn spawn_projects_stub(body: &'static str) -> String { + spawn_projects_stub_status("200 OK", body) + } + + // As `spawn_projects_stub` but with a caller-chosen status line, for the + // resolver error-path tests (404 soft-miss vs 5xx hard error). + fn spawn_projects_stub_status(status_line: &'static str, body: &'static str) -> String { use std::io::{Read, Write}; let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); @@ -1492,7 +1526,8 @@ mod tests { } } let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", + status_line, body.len(), body ); @@ -1532,4 +1567,55 @@ mod tests { .unwrap() .is_none()); } + + #[test] + fn repo_url_matches_slug_enforces_path_boundary() { + // Exact repo (with/without scheme, .git, trailing slash) matches; a + // sibling/prefix repo or a different org must not (COR-1577 review). + assert!(repo_url_matches_slug( + "https://github.com/acme/api", + "acme/api" + )); + assert!(repo_url_matches_slug( + "https://github.com/acme/api.git", + "acme/api" + )); + assert!(repo_url_matches_slug("acme/api", "acme/api")); + assert!(!repo_url_matches_slug( + "https://github.com/acme/api-v2", + "acme/api" + )); + assert!(!repo_url_matches_slug( + "https://github.com/notacme/api", + "acme/api" + )); + } + + #[test] + fn resolve_project_by_repo_rejects_sibling_prefix_repo() { + // Backend `repo_url__icontains` returns the sibling `acme/api-v2` for + // slug `acme/api`; the boundary guard must reject it rather than + // confirming the wrong project (COR-1577 review). + let base = spawn_projects_stub( + r#"{"status":"ok","projects":[{"id":9,"name":"acme/api-v2","repo_url":"https://github.com/acme/api-v2"}]}"#, + ); + let got = resolve_project_by_repo(&base, "acme/api").unwrap(); + assert!(got.is_none(), "a prefix sibling repo must not be confirmed"); + } + + #[test] + fn resolve_project_by_repo_404_is_soft_none() { + // /projects absent on a very old backend -> soft miss (Ok(None)). + let base = spawn_projects_stub_status("404 Not Found", r#"{"message":"not found"}"#); + assert!(resolve_project_by_repo(&base, "org/repo") + .unwrap() + .is_none()); + } + + #[test] + fn resolve_project_by_repo_server_error_is_hard_err() { + // 5xx must surface, not silently fall back to the local-dir project. + let base = spawn_projects_stub_status("500 Internal Server Error", r#"{"error":"boom"}"#); + assert!(resolve_project_by_repo(&base, "org/repo").is_err()); + } } diff --git a/src/utils/generic.rs b/src/utils/generic.rs index 6386b62..8cd4ecf 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -343,6 +343,19 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { })) } +/// Find the enclosing repository's `origin` remote URL, searching upward from +/// the current directory so `corgea list`/`wait` resolve correctly when run +/// from a subdirectory, not only the repo root. `get_repo_info` uses +/// `Repository::open`, which succeeds only at the root; this uses +/// `Repository::discover`. Returns None outside a git repo or when `origin` +/// carries no URL. +pub fn discover_repo_url() -> Option { + let repo = Repository::discover(Path::new(".")).ok()?; + repo.find_remote("origin") + .ok() + .and_then(|remote| remote.url().map(|url| url.to_string())) +} + pub fn get_status(status: &str) -> &str { match status.to_lowercase().as_str() { "fix available" | "fix_available" => "Fix Available", diff --git a/src/wait.rs b/src/wait.rs index 6039548..ea34aa8 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -8,11 +8,24 @@ pub fn run( project_name_override: Option, repo_override: Option, ) { - let resolved = utils::api::resolve_project( + let resolved = match utils::api::resolve_project( &config.get_url(), project_name_override.as_deref(), repo_override.as_deref(), - ); + ) { + Ok(resolved) => resolved, + Err(e) => { + log::error!( + "Unable to resolve the Corgea project. Please check your connection and ensure that:\n\ + - The server URL is reachable.\n\ + - Your authentication token is valid.\n\n\ + Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli\n\n\ + Error details: {}", + e + ); + std::process::exit(1); + } + }; let project_name = resolved.query_name.clone(); let scans_result = diff --git a/tests/list_resolution.rs b/tests/list_resolution.rs index a33d005..ae7edb5 100644 --- a/tests/list_resolution.rs +++ b/tests/list_resolution.rs @@ -241,6 +241,93 @@ fn list_json_miss_is_valid_empty_envelope() { ); } +#[test] +fn list_issues_with_scan_id_skips_project_resolution() { + use std::sync::{Arc, Mutex}; + let hits = Arc::new(Mutex::new(Vec::::new())); + let hits_c = hits.clone(); + // The scan-id issue route ignores the project, so no /projects call should + // be made even from a real git repo where a remote IS present. (COR-1577) + let url = common::spawn_http_stub(move |path| { + hits_c.lock().unwrap().push(path.to_string()); + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.contains("/check_blocking_rules") { + ( + "200 OK", + r#"{"block":false,"blocking_issues":[],"total_pages":1}"#.to_string(), + ) + } else if path.starts_with("/api/v1/scan/") && path.contains("/issues") { + ( + "200 OK", + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":0,"issues":[]}"# + .to_string(), + ) + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--issues", "--scan-id", "scan-xyz"], &url, &repo); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let hits = hits.lock().unwrap(); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/projects")), + "no /projects resolution for --issues --scan-id; hits: {hits:?}" + ); + assert!( + hits.iter().any(|h| h.contains("/scan/scan-xyz/issues")), + "expected the scan-id issue route; hits: {hits:?}" + ); +} + +#[test] +fn list_resolves_from_subdirectory() { + use std::sync::{Arc, Mutex}; + let hits = Arc::new(Mutex::new(Vec::::new())); + let hits_c = hits.clone(); + let url = common::spawn_http_stub(move |path| { + hits_c.lock().unwrap().push(path.to_string()); + if path.starts_with("/api/v1/verify") { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/projects?repo_url=") { + ("200 OK", projects_match()) + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans_one(CANON)) + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + } + }); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let subdir = repo.join("src"); + std::fs::create_dir(&subdir).expect("create subdir"); + let out = run_list(&[], &url, &subdir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Discovery walks up from src/ to the repo root, so the remote slug — not + // the `src` basename — drives resolution: a /projects hit proves it. + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/projects?repo_url=bohappdev%2Fdotnet-azure-web-tsb")), + "expected /projects resolution from the subdir; hits: {hits:?}" + ); + assert!( + stdout.contains("bohappdev/dotnet-azure-web-tsb"), + "stdout: {stdout}" + ); +} + #[test] fn list_issues_json_miss_keeps_stdout_clean() { let url = spawn_stub(projects_empty(), scans_empty(), issues_miss());