diff --git a/src/list.rs b/src/list.rs index f0b66c0..751a00f 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,10 +13,13 @@ pub fn run( page: &Option, page_size: &Option, scan_id: &Option, + project_name_override: Option, + repo_override: Option, ) { - let project_name = utils::generic::determine_project_name(None); println!(); if *sca_issues { + // SCA has no project parameter; this name is only error copy. + let project_name = utils::generic::determine_project_name(None); let sca_issues_response = match utils::api::get_sca_issues( &config.get_url(), Some((*page).unwrap_or(1)), @@ -108,6 +112,18 @@ pub fn run( Some(sca_issues_response.total_pages), ); } else if *issues { + // The --scan-id route hits /scan/{id}/issues and ignores the project. + let resolved = scan_id.is_none().then(|| { + utils::api::resolve_project_or_exit( + &config.get_url(), + project_name_override.as_deref(), + repo_override.as_deref(), + ) + }); + let project_name = resolved + .as_ref() + .map(|r| r.query_name.clone()) + .unwrap_or_default(); let issues_response = match utils::api::get_scan_issues( &config.get_url(), &project_name, @@ -119,10 +135,14 @@ pub fn run( 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 { - log::error!("Project with name '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", project_name); + // `resolved` is None exactly on the --scan-id route. + match &resolved { + None => 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()), + Some(r) if r.confirmed => log::error!("Project '{}' has no issues yet. Run 'corgea scan' to create a scan for this project.", project_name), + Some(r) => log::error!( + "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", + r.tried_label + ), } } else { log::error!( @@ -259,26 +279,32 @@ pub fn run( utils::terminal::print_table(table, issues_response.page, issues_response.total_pages); } else { + let resolved = utils::api::resolve_project_or_exit( + &config.get_url(), + project_name_override.as_deref(), + repo_override.as_deref(), + ); + let project_name = &resolved.query_name; let (scans, page, total_pages) = match utils::api::query_scan_list( &config.get_url(), - Some(&project_name), + 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) + // The server already filtered by the resolved project; the old + // client-side `scan.project == cwd_basename` pass 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!("Project with name '{}' doesn't exist. Please run 'corgea scan' to create a new scan for this project.", project_name); + 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\ @@ -296,7 +322,29 @@ pub fn run( "total_pages": total_pages, "results": scans }); + // The envelope prints first so JSON consumers get valid stdout even + // when the miss below exits 1. println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + // An unresolved project is a miss (exit 1, as --issues and `wait`); a + // confirmed project with no scans is a valid empty result. So is an + // explicit --project-name: /scans answers 200-empty either way, so the + // caller's own exact name is the better authority. + if scans.is_empty() && !resolved.confirmed && project_name_override.is_none() { + log::error!( + "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", + resolved.tried_label + ); + std::process::exit(1); + } + if *json { + return; + } + if scans.is_empty() { + println!( + "Project '{}' has no scans yet. Run 'corgea scan' to create one.", + project_name + ); return; } let mut table = vec![vec![ diff --git a/src/main.rs b/src/main.rs index 30057f0..d01962a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,7 +147,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 { @@ -157,6 +170,7 @@ enum Commands { #[arg( long, short = 'c', + conflicts_with_all = ["project_name", "repo"], help = "List SCA (Software Composition Analysis) issues instead of regular issues" )] sca_issues: bool, @@ -172,6 +186,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 { @@ -551,7 +578,13 @@ fn main() { if let Some(result) = result { if *wait { - wait::run(&corgea_config, Some(result.scan_id), result.project_id); + wait::run( + &corgea_config, + Some(result.scan_id), + project_name.clone(), + None, + result.project_id, + ); } else { scan::print_scan_tracking_url(&corgea_config, &result); } @@ -689,9 +722,19 @@ 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(), + None, + ); } Some(Commands::List { issues, @@ -700,6 +743,8 @@ fn main() { page_size, scan_id, sca_issues, + project_name, + repo, }) => { verify_token_and_exit_when_fail(&corgea_config); if *issues && *sca_issues { @@ -718,6 +763,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 c533ac7..6d389bb 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -107,8 +107,14 @@ 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()) { + crate::wait::run( + config, + Some(result.scan_id), + project_name, + None, + result.project_id, + ); } } @@ -122,8 +128,14 @@ 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()) { + crate::wait::run( + config, + Some(result.scan_id), + project_name, + None, + result.project_id, + ); } } diff --git a/src/utils/api.rs b/src/utils/api.rs index 2356b6a..801d0cb 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -471,27 +471,28 @@ pub fn get_scan_issues( page_size: Option, scan_id: Option, ) -> Result> { - let mut seperator = "?"; - let mut url = match scan_id { + let mut query_params = Vec::new(); + let endpoint = match scan_id { Some(scan_id) => format!("{}{}/scan/{}/issues", url, API_BASE, scan_id), None => { - seperator = "&"; - format!("{}{}/issues?project={}", url, API_BASE, project) + query_params.push(("project", project.to_string())); + format!("{}{}/issues", url, API_BASE) } }; if let Some(p) = page { - url.push_str(&format!("{}page={}", seperator, p)); + query_params.push(("page", p.to_string())); } if let Some(p_size) = page_size { - url.push_str(&format!("&page_size={}", p_size)); + query_params.push(("page_size", p_size.to_string())); } else { - url.push_str("&page_size=30"); + query_params.push(("page_size", "30".to_string())); } let client = http_client(); - debug(&format!("Sending request to URL: {}", url)); + debug(&format!("Sending request to URL: {}", endpoint)); + debug(&format!("Query params: {:?}", query_params)); - let response = match client.get(&url).send() { + let response = match client.get(&endpoint).query(&query_params).send() { Ok(res) => { check_for_warnings(res.headers(), res.status()); res @@ -733,6 +734,247 @@ pub fn query_scan_list( } } +#[derive(Deserialize, Debug)] +pub struct ProjectSummary { + pub name: String, + #[serde(default)] + pub repo_url: Option, +} + +#[derive(Deserialize, Debug)] +pub struct ProjectsResponse { + pub projects: Vec, + pub total_pages: u32, +} + +const PROJECTS_PAGE_SIZE: u32 = 50; +const MAX_PROJECT_RESOLUTION_PAGES: u32 = 100; + +/// True when a stored `repo_url` points at exactly `expected_path` (a whole +/// post-host path, already lowercased). Comparing whole paths keeps the +/// backend's `repo_url__icontains` results honest: neither the sibling +/// `acme/api-v2` nor the nested `…/mirrors/acme/api` passes for `acme/api`. +/// Falls back to a normalized compare for a stored bare `acme/api`. +fn repo_url_matches_path(repo_url: &str, expected_path: &str) -> bool { + if let Some(path) = utils::generic::extract_repo_path(repo_url) { + return path == expected_path; + } + let r = repo_url.trim().trim_end_matches('/'); + let r = r.strip_suffix(".git").unwrap_or(r).to_lowercase(); + r == expected_path +} + +/// Resolve the canonical project for a repo path 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, so every candidate is +/// re-checked against the path here — on such a backend none match, and the +/// caller falls back to the CWD-name path rather than listing a stranger's +/// scans. +/// +/// `Err` for hard failures (network/auth/5xx, malformed envelopes, ambiguous +/// matches, or a truncated page walk); 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, + repo_path: &str, + repo_host: Option<&str>, +) -> Result, Box> { + let request_url = format!("{}{}/projects", url, API_BASE); + let client = http_client(); + let expected_path = repo_path.to_lowercase(); + let expected_host = repo_host.map(str::to_lowercase); + let mut path_matches = Vec::new(); + let mut page = 1; + + loop { + debug(&format!( + "Resolving project via {} (repo_url={}, page={})", + request_url, repo_path, page + )); + let response = client + .get(&request_url) + .query(&[ + ("repo_url", repo_path.to_string()), + ("page_size", PROJECTS_PAGE_SIZE.to_string()), + ("page", page.to_string()), + ]) + .send()?; + check_for_warnings(response.headers(), response.status()); + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !status.is_success() { + return Err(format!("/projects request failed: HTTP {}", status).into()); + } + let text = response.text()?; + let parsed: ProjectsResponse = serde_json::from_str(&text).map_err(|e| { + debug(&format!( + "Failed to parse /projects response: {} | body: {}", + e, text + )); + format!("Failed to parse /projects response: {}", e) + })?; + + for project in parsed.projects.into_iter().filter(|project| { + project + .repo_url + .as_deref() + .is_some_and(|repo_url| repo_url_matches_path(repo_url, &expected_path)) + }) { + let host_matches = expected_host.as_deref().is_some_and(|host| { + project + .repo_url + .as_deref() + .and_then(utils::generic::extract_repo_host) + .is_some_and(|project_host| project_host == host) + }); + if host_matches { + return Ok(Some(project)); + } + path_matches.push(project); + } + + if page >= parsed.total_pages { + break; + } + if page >= MAX_PROJECT_RESOLUTION_PAGES { + return Err(format!( + "/projects resolution exceeded {} pages; pass --project-name ", + MAX_PROJECT_RESOLUTION_PAGES + ) + .into()); + } + page += 1; + } + + match path_matches.len() { + 0 => Ok(None), + 1 => { + let project = path_matches.pop().unwrap(); + let project_host = project + .repo_url + .as_deref() + .and_then(utils::generic::extract_repo_host); + if expected_host.is_some() && project_host.is_some() { + Ok(None) + } else { + Ok(Some(project)) + } + } + _ => Err(format!( + "Multiple Corgea projects match repo '{}'; pass --project-name to choose one", + repo_path + ) + .into()), + } +} + +/// 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 /projects confirmed a backend project. + pub confirmed: bool, + /// Pre-formatted subject of the miss message ("repo 'org/repo'", …). + pub tried_label: String, +} + +/// Resolve which project `list`/`wait` should query: `--project-name` verbatim, +/// else the repo path from `--repo` or the discovered remote. Unconfirmed, an +/// explicit `--repo` queries that path as a name and everything else falls back +/// to what the pre-COR-1577 CLI queried. Resolver failures are hard errors so +/// malformed or ambiguous `/projects` results never select a fallback project. +pub fn resolve_project( + url: &str, + project_name_override: Option<&str>, + repo_override: Option<&str>, +) -> Result> { + if let Some(name) = project_name_override { + // Normalize before it reaches `?project=`, which the backend matches + // exactly: `--project-name foo/` must not miss the project `foo`. + let name = name.trim().trim_matches('/'); + if name.is_empty() { + return Err("--project-name must name a project".into()); + } + return Ok(ResolvedProject { + query_name: name.to_string(), + confirmed: false, + tried_label: format!("project '{}'", name), + }); + } + + // --repo may be a bare path (`org/repo`, or a GitLab `group/subgroup/repo`) + // rather than a URL; `extract_repo_path` returns None for those, so the + // whole value is the path. + let repo = match repo_override { + Some(r) => { + let host = utils::generic::extract_repo_host(r); + let path = utils::generic::extract_repo_path(r).unwrap_or_else(|| r.trim().to_string()); + Some((path, host)) + } + None => utils::generic::discover_repo_url().and_then(|remote| { + let host = utils::generic::extract_repo_host(&remote); + utils::generic::extract_repo_path(&remote).map(|path| (path, host)) + }), + }; + + if let Some((repo_path, repo_host)) = repo { + if let Some(project) = resolve_project_by_repo(url, &repo_path, repo_host.as_deref())? { + return Ok(ResolvedProject { + query_name: project.name, + confirmed: true, + tried_label: format!("repo '{}'", repo_path), + }); + } + // Unconfirmed: an explicit --repo queries that path as a name, and an + // auto-detected remote queries exactly what the pre-COR-1577 CLI did, + // so an old or not-yet-onboarded backend still resolves. + let query_name = match repo_override { + Some(_) => repo_path.clone(), + None => utils::generic::determine_project_name(None), + }; + return Ok(ResolvedProject { + query_name, + confirmed: false, + tried_label: format!("repo '{}'", repo_path), + }); + } + + let cwd = + utils::generic::get_current_working_directory().unwrap_or_else(|| "unknown".to_string()); + Ok(ResolvedProject { + tried_label: format!("directory '{}'", cwd), + query_name: utils::generic::determine_project_name(None), + confirmed: false, + }) +} + +/// `resolve_project`, or a hard exit with the shared failure copy. Every +/// caller treats a resolver error as fatal. +pub fn resolve_project_or_exit( + url: &str, + project_name_override: Option<&str>, + repo_override: Option<&str>, +) -> ResolvedProject { + match resolve_project(url, project_name_override, repo_override) { + 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); + } + } +} + pub fn exchange_code_for_token( base_url: &str, code: &str, @@ -1327,6 +1569,150 @@ mod tests { assert_eq!(attempts.get(), 1); } + // Single-response-per-connection JSON stub on an ephemeral port; returns + // the base URL. Drains the request first: 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). + fn spawn_projects_stub(body: &'static str) -> String { + spawn_projects_stub_status("200 OK", body) + } + + fn spawn_projects_stub_status(status_line: &'static str, body: &'static str) -> String { + use std::io::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 }; + let _ = corgea::vuln_api_stub::read_http_request(&mut stream); + let resp = corgea::vuln_api_stub::http_response(status_line, "", body); + let _ = stream.write_all(resp.as_bytes()); + } + }); + base + } + + #[test] + fn repo_url_matches_path_compares_the_whole_path() { + // Same repo across scheme/.git/trailing-slash/port variants. + for stored in [ + "https://github.com/acme/api", + "https://github.com/acme/api.git", + "git@github.com:acme/api", + "acme/api", + ] { + assert!(repo_url_matches_path(stored, "acme/api"), "{stored}"); + } + // Sibling / prefix repo, a different org, and — since the owner must be + // top-level on the host — a nested mirror or a deeper path. + for stored in [ + "https://github.com/acme/api-v2", + "https://github.com/notacme/api", + "https://github.com/mirrors/acme/api", + ] { + assert!(!repo_url_matches_path(stored, "acme/api"), "{stored}"); + } + // Multi-segment paths compare in full. + assert!(repo_url_matches_path( + "https://dev.azure.com/org/project/_git/repo", + "org/project/_git/repo" + )); + assert!(repo_url_matches_path( + "git@gitlab.com:group/subgroup/repo.git", + "group/subgroup/repo" + )); + } + + #[test] + fn resolve_project_by_repo_keeps_only_repo_url_matches() { + let base = spawn_projects_stub( + r#"{"status":"ok","total_pages":1,"projects":[{"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", Some("github.com")) + .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() { + // A pre-COR-1426 backend ignores ?repo_url and returns every project. + // Without the path re-check we would confirm a stranger's project and + // list its scans. + let base = spawn_projects_stub( + r#"{"status":"ok","total_pages":1,"projects":[{"name":"other/repo","repo_url":"https://github.com/other/repo"},{"name":"misc/thing","repo_url":"https://github.com/misc/thing"}]}"#, + ); + assert!( + resolve_project_by_repo(&base, "bohappdev/dotnet-azure-web-tsb", None) + .unwrap() + .is_none() + ); + } + + #[test] + fn resolve_project_by_repo_rejects_sibling_prefix_repo() { + // `repo_url__icontains` returns the sibling `acme/api-v2` for `acme/api`. + let base = spawn_projects_stub( + r#"{"status":"ok","total_pages":1,"projects":[{"name":"acme/api-v2","repo_url":"https://github.com/acme/api-v2"}]}"#, + ); + assert!(resolve_project_by_repo(&base, "acme/api", None) + .unwrap() + .is_none()); + } + + #[test] + fn resolve_project_by_repo_empty_and_404_are_soft_none() { + let base = spawn_projects_stub(r#"{"status":"ok","total_pages":1,"projects":[]}"#); + assert!(resolve_project_by_repo(&base, "org/repo", None) + .unwrap() + .is_none()); + // /projects absent on a very old backend -> soft miss, not a failure. + let base = spawn_projects_stub_status("404 Not Found", r#"{"message":"not found"}"#); + assert!(resolve_project_by_repo(&base, "org/repo", None) + .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", None).is_err()); + } + + #[test] + fn resolve_project_by_repo_rejects_ambiguous_hostless_matches() { + let base = spawn_projects_stub( + r#"{"status":"ok","total_pages":1,"projects":[{"name":"github-project","repo_url":"https://github.com/acme/api"},{"name":"gitlab-project","repo_url":"https://gitlab.com/acme/api"}]}"#, + ); + let error = resolve_project_by_repo(&base, "acme/api", None) + .unwrap_err() + .to_string(); + assert!(error.contains("--project-name"), "{error}"); + } + + #[test] + fn resolve_project_by_repo_errors_when_the_page_walk_is_truncated() { + let base = spawn_projects_stub(r#"{"status":"ok","total_pages":101,"projects":[]}"#); + let error = resolve_project_by_repo(&base, "org/repo", None) + .unwrap_err() + .to_string(); + assert!(error.contains("exceeded 100 pages"), "{error}"); + } + + #[test] + fn resolve_project_name_override_is_normalized_and_never_empty() { + // The name goes straight into `?project=`, which the backend matches + // exactly, so a trailing slash would miss the project. + let r = resolve_project("http://127.0.0.1:1", Some("foo/"), None).unwrap(); + assert_eq!(r.query_name, "foo"); + assert!(!r.confirmed); + assert!(resolve_project("http://127.0.0.1:1", Some("/"), None).is_err()); + } + #[test] fn retry_on_network_error_gives_up_after_max_retries() { let attempts = Cell::new(0); diff --git a/src/utils/generic.rs b/src/utils/generic.rs index c316d17..e339dd9 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -297,18 +297,83 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { .map(|commit| commit.id().to_string()) }); - let repo_url = repo - .find_remote("origin") - .ok() - .and_then(|remote| remote.url().map(|url| url.to_string())); - Ok(Some(RepoInfo { branch, - repo_url, + repo_url: origin_url(&repo), sha, })) } +/// `origin`'s URL, or None when the remote is missing or carries no URL. +fn origin_url(repo: &Repository) -> Option { + repo.find_remote("origin") + .ok() + .and_then(|remote| remote.url().map(|url| url.to_string())) +} + +/// The enclosing repository's `origin` remote URL, searched upward from the +/// current directory so `corgea list`/`wait` also resolve from a subdirectory. +/// `get_repo_info` deliberately returns None outside the worktree root; this +/// does not. None outside a git repo or when `origin` carries no URL. +pub fn discover_repo_url() -> Option { + origin_url(&Repository::discover(Path::new(".")).ok()?) +} + +/// The whole repository path after the host, lowercased (`org/repo`, +/// `group/subgroup/repo`, Azure `org/project/_git/repo`). The host is excluded +/// so SSH/HTTPS/port variants of one remote compare equal; None when the value +/// carries no host at all. The full path — not a trailing `org/repo` slug — is +/// what doghouse `normalize_repo_url` stores (`heeler/models.py:201-246`), so +/// it is what an equality compare needs. Azure SSH remotes +/// (`ssh.dev.azure.com/v3/org/…`, no `_git` segment) remain a known limitation. +pub fn extract_repo_path(url: &str) -> Option { + Some(split_remote(url)?[1..].join("/").to_lowercase()) +} + +/// The lowercased host from a network git remote, excluding userinfo and port. +/// Returns None for a bare `org/repo` path. +pub fn extract_repo_host(url: &str) -> Option { + split_remote(url)?.first().map(|host| host.to_lowercase()) +} + +/// Split a git remote into `[host, path segments…]`, dropping scheme, userinfo +/// and port. None when fewer than two path segments follow the host, or when +/// nothing marks the value as a network remote. +fn split_remote(url: &str) -> Option> { + let url = url.trim().trim_end_matches('/'); + let url = url.strip_suffix(".git").unwrap_or(url); + let (had_scheme, url) = match url.split_once("://") { + Some((_, rest)) => (true, rest), + None => (false, url), + }; + // Drop userinfo (`git@`, `oauth2:token@`) so it is never read as the host. + let host_end = url.find('/').unwrap_or(url.len()); + let (had_userinfo, url) = match url[..host_end].rfind('@') { + Some(at) => (true, &url[at + 1..]), + None => (false, url), + }; + // A colon before the first '/' is the scp-style `host:org/repo` separator. + let first_slash = url.find('/').unwrap_or(url.len()); + let had_scp_colon = url[..first_slash].contains(':'); + // URL forms split host from path on '/', scp-like `git@host:org/repo` on ':'. + let mut segments: Vec<&str> = url.split(['/', ':']).filter(|s| !s.is_empty()).collect(); + // segments[0] is the host; an all-digit segment right after it is a port. + if segments.len() >= 4 && segments[1].chars().all(|c| c.is_ascii_digit()) { + segments.remove(1); + } + // Need host + at least org + repo. + if segments.len() < 3 { + return None; + } + // A scheme, userinfo or scp colon is what marks a network remote. Without + // one this is a bare path — a GitLab `group/subgroup/repo`, whose namespace + // may itself contain dots — so every segment belongs to the path. + if !had_scheme && !had_userinfo && !had_scp_colon { + return None; + } + Some(segments) +} + /// True when `dir` is the repository worktree root (not a subdirectory). fn is_at_repo_root(dir: &str) -> bool { let Ok(repo) = Repository::discover(Path::new(dir)) else { @@ -354,41 +419,34 @@ mod tests { use std::fs; use std::process::Command; - #[test] - fn get_repo_info_at_root_only_not_nested_cwd() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - assert!(Command::new("git") - .args(["init"]) - .current_dir(root) - .status() - .unwrap() - .success()); - assert!(Command::new("git") - .args(["config", "user.email", "test@example.com"]) - .current_dir(root) - .status() - .unwrap() - .success()); + /// `git ` in `dir`. Scrubs the GIT_* env a parent git process + /// injects (e.g. when these tests run from a pre-commit hook): an + /// inherited GIT_DIR would point `git init` at the developer's repo + /// instead of `dir`. Same scrub as `tests/cli_deps.rs::run_git`. + fn git(dir: &Path, args: &[&str]) { assert!(Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(root) + .args(args) + .current_dir(dir) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .env_remove("GIT_INDEX_FILE") + .env_remove("GIT_PREFIX") .status() .unwrap() .success()); + } + + #[test] + fn get_repo_info_at_root_only_not_nested_cwd() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + git(root, &["init"]); + git(root, &["config", "user.email", "test@example.com"]); + git(root, &["config", "user.name", "Test"]); fs::write(root.join("README"), "hi").unwrap(); - assert!(Command::new("git") - .args(["add", "README"]) - .current_dir(root) - .status() - .unwrap() - .success()); - assert!(Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(root) - .status() - .unwrap() - .success()); + git(root, &["add", "README"]); + git(root, &["commit", "-m", "init"]); let root_s = root.to_str().unwrap(); let nested = root.join("pkg").join("inner"); @@ -450,4 +508,73 @@ mod tests { added ); } + + #[test] + fn extract_repo_path_handles_common_remote_forms() { + for url in [ + "https://github.com/org/repo.git", + "https://github.com/org/repo", + "git@github.com:org/repo.git", + "git@github.com:org/repo", + "ssh://git@github.com/org/repo", + "https://github.com/org/repo/", + "https://github.com/Org/Repo", + // host:port must not leak the port into the path + "https://git.example.com:8443/org/repo", + // token userinfo must not be read as the host + "https://oauth2:tok@gitlab.com/org/repo", + ] { + assert_eq!(extract_repo_path(url).as_deref(), Some("org/repo"), "{url}"); + } + // Bank of Hope case: the stored name is the whole org/repo path. + assert_eq!( + extract_repo_path("git@github.com:bohappdev/dotnet-azure-web-tsb.git").as_deref(), + Some("bohappdev/dotnet-azure-web-tsb") + ); + // Whole path is kept: Azure `_git` and GitLab subgroups. + assert_eq!( + extract_repo_path("https://dev.azure.com/org/project/_git/repo").as_deref(), + Some("org/project/_git/repo") + ); + assert_eq!( + extract_repo_path("git@gitlab.com:group/subgroup/repo.git").as_deref(), + Some("group/subgroup/repo") + ); + // An SSH-config host alias carries no userinfo and no dot, but the + // colon before the first slash still marks it scp-style. + assert_eq!( + extract_repo_path("corp-github:bohappdev/repo.git").as_deref(), + Some("bohappdev/repo") + ); + } + + #[test] + fn extract_repo_path_returns_none_without_a_host() { + assert_eq!(extract_repo_path("not a url"), None); + assert_eq!(extract_repo_path(""), None); + assert_eq!(extract_repo_path("github.com"), None); // host only + // Nothing marks these as a network remote, so they are bare paths the + // caller keeps verbatim; a GitLab namespace may contain dots, so a + // dotted leading segment is no evidence of a host. + assert_eq!(extract_repo_path("org/repo"), None); + assert_eq!(extract_repo_path("group/subgroup/repo"), None); + assert_eq!(extract_repo_path("my.group/sub/repo"), None); + } + + #[test] + fn extract_repo_host_normalizes_network_remotes() { + for remote in [ + "https://GitHub.com/org/repo.git", + "git@github.com:org/repo.git", + "ssh://git@github.com/org/repo", + "https://github.com:8443/org/repo", + ] { + assert_eq!( + extract_repo_host(remote).as_deref(), + Some("github.com"), + "{remote}" + ); + } + assert_eq!(extract_repo_host("org/repo"), None); + } } diff --git a/src/wait.rs b/src/wait.rs index dafb59c..1e293a1 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -2,25 +2,54 @@ 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 = utils::generic::determine_project_name(None); +pub fn run( + config: &Config, + scan_id: Option, + project_name_override: Option, + repo_override: Option, + project_id: Option, +) { + // A scan id plus the project id from the upload response leaves nothing to + // resolve: everything below keys off the scan, and the id-form URL is + // already known. + let resolved = if scan_id.is_some() && project_id.is_some() { + let name = project_name_override + .clone() + .unwrap_or_else(|| utils::generic::determine_project_name(None)); + utils::api::ResolvedProject { + // `confirmed`/`tried_label` are only read on the no-scan-id path. + tried_label: format!("project '{}'", name), + query_name: name, + confirmed: false, + } + } else { + utils::api::resolve_project_or_exit( + &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); - let scans: Vec = match scans_result { - Ok(result) => result.scans.unwrap_or_default(), - Err(e) => { - log::error!( - "Unable to query the scan list. Please check your connection and ensure that: + // Only the scan-less path reads the listing. + let scans: Vec = if scan_id.is_some() { + Vec::new() + } else { + match utils::api::query_scan_list(&config.get_url(), Some(&project_name), Some(1), None) { + Ok(result) => result.scans.unwrap_or_default(), + Err(e) => { + log::error!( + "Unable to query the scan list. Please check your connection and ensure that: - The server URL is reachable. - Your authentication token is valid. Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli Error details: {}", - e - ); - std::process::exit(1); + e + ); + std::process::exit(1); + } } }; let (scan_id, processed) = match scan_id { @@ -39,7 +68,17 @@ 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); } }, @@ -47,12 +86,19 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) let scan_url = match &project_id { Some(pid) => format!("{}/project/{}/?scan_id={}", config.get_url(), pid, scan_id), - None => format!( - "{}/project/{}?scan_id={}", - config.get_url(), - project_name, - scan_id - ), + None => { + // The web route is `project//`, so the canonical name + // works — but an empty or slash-only one would yield `/project//`, + // which resolves nowhere. + let name = project_name.trim().trim_matches('/'); + if name.is_empty() { + log::error!( + "Cannot build the scan URL: no Corgea project resolved. Pass --project-name ." + ); + std::process::exit(1); + } + format!("{}/project/{}?scan_id={}", config.get_url(), name, scan_id) + } }; if !processed { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f02b6c1..5deb0e4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -433,3 +433,101 @@ 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` matches +/// the slug — the new-backend confirmed path. +#[allow(dead_code)] +pub fn projects_match() -> String { + r#"{"status":"ok","total_pages":1,"projects":[{"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","total_pages":1,"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) +} + +/// Every request target a stub was asked for, in order. +#[allow(dead_code)] +pub type Hits = std::sync::Arc>>; + +/// `spawn_http_stub` that also records every request target, so a test can +/// assert which endpoints were (not) dialed and with what query. +#[allow(dead_code)] +pub fn spawn_recording_http_stub(route: F) -> (String, Hits) +where + F: Fn(&str) -> (&'static str, String) + Send + 'static, +{ + let hits: Hits = Default::default(); + let recorder = hits.clone(); + let base_url = spawn_http_stub(move |path| { + recorder.lock().unwrap().push(path.to_string()); + route(path) + }); + (base_url, hits) +} + +/// Run `corgea ` against `url` from `cwd`, isolated from +/// the host (temp HOME). `CORGEA_URL`/`CORGEA_TOKEN` are layered back on after +/// `corgea_isolated` strips them. +#[allow(dead_code)] +pub fn run_corgea( + subcommand: &str, + args: &[&str], + url: &str, + cwd: &std::path::Path, +) -> std::process::Output { + let (mut cmd, _home) = corgea_isolated(); + cmd.arg(subcommand); + cmd.args(args); + cmd.env("CORGEA_URL", url) + .env("CORGEA_TOKEN", "test-token") + .current_dir(cwd); + cmd.output().expect("spawn corgea") +} diff --git a/tests/list_resolution.rs b/tests/list_resolution.rs new file mode 100644 index 0000000..1f571a2 --- /dev/null +++ b/tests/list_resolution.rs @@ -0,0 +1,439 @@ +//! End-to-end tests for `corgea list` repo-URL project resolution (COR-1577). +//! Stubs route on the request-target path PREFIX: `/projects` carries a +//! percent-encoded query, so the full target is not a stable key. + +mod common; + +use common::{ + projects_empty, projects_match, scans_empty, scans_one, temp_git_repo, temp_plain_dir, Hits, + CANON, NOT_FOUND_JSON, 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, and recording every +/// request target so a test can assert what the CLI actually sent. +fn spawn_stub(projects: String, scans: String, issues: String) -> (String, Hits) { + common::spawn_recording_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", NOT_FOUND_JSON.to_string()) + } + }) +} + +fn run_list(args: &[&str], url: &str, cwd: &Path) -> Output { + common::run_corgea("list", args, url, cwd) +} + +/// True when some request carried `project=`; listing routes +/// percent-encode query parameters via reqwest. +fn queried_project(hits: &[String], name: &str) -> bool { + let encoded = name.replace('/', "%2F"); + hits.iter().any(|h| { + h.contains(&format!("project={name}")) || h.contains(&format!("project={encoded}")) + }) +} + +// --- tests ----------------------------------------------------------------- + +#[test] +fn list_uses_canonical_name_from_repo() { + // The checkout is `build-123`, so the canonical name can only have come + // from resolution — and the assertion is on the query the CLI SENT, since + // the stub answers every /scans the same way regardless. + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, repo) = temp_git_repo("build-123", 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(CANON), "stdout: {stdout}"); + assert!(stdout.contains("scan-123"), "stdout: {stdout}"); + let hits = hits.lock().unwrap(); + assert!( + queried_project(&hits, CANON), + "the canonical project must drive /scans; hits: {hits:?}" + ); +} + +#[test] +fn list_issues_uses_canonical_name_from_repo() { + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, repo) = temp_git_repo("build-123", 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}"); + let hits = hits.lock().unwrap(); + assert!( + queried_project(&hits, CANON), + "the canonical project must drive /issues; hits: {hits:?}" + ); +} + +#[test] +fn list_resolves_from_a_subdirectory() { + // Discovery walks up from src/, so the remote — not the `src` basename — + // drives resolution. + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, repo) = temp_git_repo("build-123", REMOTE); + let subdir = repo.join("src"); + std::fs::create_dir(&subdir).expect("create subdir"); + let out = run_list(&[], &url, &subdir); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let hits = hits.lock().unwrap(); + assert!( + queried_project(&hits, CANON), + "expected resolution from the subdir; hits: {hits:?}" + ); +} + +#[test] +fn list_repo_flag_resolves_from_flag_not_remote() { + // Non-git dir: no remote, so a resolved slug can ONLY have come from --repo. + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON), issues_one()); + let (_tmp, dir) = temp_plain_dir("unrelated-dir"); + let out = run_list(&["--repo", CANON], &url, &dir); + 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?repo_url=bohappdev%2Fdotnet-azure-web-tsb")), + "expected a /projects hit carrying the flag slug; hits: {hits:?}" + ); +} + +#[test] +fn list_resolves_an_exact_repo_match_from_page_two() { + let page_one = r#"{"status":"ok","total_pages":2,"projects":[{"name":"sibling","repo_url":"https://github.com/acme/api-v2"}]}"#; + let page_two = r#"{"status":"ok","total_pages":2,"projects":[{"name":"github/acme-api","repo_url":"https://github.com/acme/api"}]}"#; + let (url, hits) = common::spawn_recording_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?") { + if path.contains("page=2") { + ("200 OK", page_two.to_string()) + } else { + ("200 OK", page_one.to_string()) + } + } else if path.starts_with("/api/v1/scans?") { + ("200 OK", scans_one("github/acme-api")) + } else { + ("404 Not Found", NOT_FOUND_JSON.to_string()) + } + }); + let (_tmp, repo) = temp_git_repo("build-123", "https://github.com/acme/api.git"); + let out = run_list(&[], &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?") && h.contains("page=2")), + "resolver must request page two; hits: {hits:?}" + ); + assert!( + queried_project(&hits, "github/acme-api"), + "page-two match must drive /scans; hits: {hits:?}" + ); +} + +#[test] +fn list_rejects_a_malformed_projects_envelope() { + let (url, hits) = spawn_stub( + r#"{"status":"error"}"#.to_string(), + scans_one("api"), + issues_one(), + ); + let (_tmp, repo) = temp_git_repo("api", "https://github.com/acme/api.git"); + let out = run_list(&[], &url, &repo); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!( + stderr.contains("Unable to resolve the Corgea project"), + "stderr: {stderr}" + ); + let hits = hits.lock().unwrap(); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/scans?")), + "a malformed resolver response must not fall back; hits: {hits:?}" + ); +} + +#[test] +fn list_uses_the_remote_host_to_disambiguate_projects() { + let projects = r#"{"status":"ok","total_pages":1,"projects":[{"name":"gitlab/acme-api","repo_url":"https://gitlab.com/acme/api"},{"name":"github/acme-api","repo_url":"https://github.com/acme/api"}]}"#; + let (url, hits) = spawn_stub( + projects.to_string(), + scans_one("github/acme-api"), + issues_one(), + ); + let (_tmp, repo) = temp_git_repo("build-123", "git@github.com:acme/api.git"); + let out = run_list(&[], &url, &repo); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let hits = hits.lock().unwrap(); + assert!( + queried_project(&hits, "github/acme-api"), + "the github remote must select the github project; hits: {hits:?}" + ); + assert!( + !queried_project(&hits, "gitlab/acme-api"), + "the gitlab project must not be queried; hits: {hits:?}" + ); +} + +#[test] +fn list_miss_names_the_repo_and_renders_no_table() { + // Pre-COR-1577 this silently printed an empty table and exited 0. + let (url, _hits) = 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); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!( + stderr.contains(&format!("repo '{CANON}'")), + "stderr: {stderr}" + ); + assert!( + !stdout.contains("Scan ID"), + "should not render a table; stdout: {stdout}" + ); +} + +#[test] +fn list_confirmed_project_with_no_scans_exits_zero() { + // A confirmed project that simply has no scans is a valid empty result — + // failing it would break CI polling. + let (url, _hits) = spawn_stub(projects_match(), 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("has no scans yet"), "stdout: {stdout}"); +} + +#[test] +fn list_unconfirmed_falls_back_to_the_legacy_name() { + // On a /projects soft miss (old or not-yet-onboarded backend) the query + // must stay what the pre-COR-1577 CLI sent — the repo basename at the + // worktree root — or a working setup starts missing. + let (url, hits) = spawn_stub( + projects_empty(), + scans_one("dotnet-azure-web-tsb"), + issues_one(), + ); + let (_tmp, repo) = temp_git_repo("build-123", REMOTE); + let out = run_list(&[], &url, &repo); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let hits = hits.lock().unwrap(); + assert!( + queried_project(&hits, "dotnet-azure-web-tsb"), + "expected the repo basename, not the checkout dir; hits: {hits:?}" + ); + assert!( + !queried_project(&hits, "build-123"), + "the checkout dir name must not be queried; hits: {hits:?}" + ); +} + +#[test] +fn list_project_name_override_skips_resolution() { + let (url, hits) = 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}"); + let hits = hits.lock().unwrap(); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/projects")), + "an exact name needs no resolution round trip; hits: {hits:?}" + ); +} + +#[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_sca_issues_rejects_project_and_repo_selectors() { + let (_tmp, dir) = temp_plain_dir("whatever"); + for args in [ + ["--sca-issues", "--project-name", "a"], + ["--sca-issues", "--repo", "org/repo"], + ] { + let out = run_list(&args, "http://127.0.0.1:1", &dir); + assert_eq!( + out.status.code(), + Some(2), + "args: {args:?}; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[test] +fn list_issues_percent_encodes_the_project_name() { + let (url, hits) = spawn_stub(projects_empty(), scans_empty(), issues_one()); + let (_tmp, dir) = temp_plain_dir("whatever"); + let out = run_list(&["--issues", "--project-name", "foo&bar"], &url, &dir); + 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/issues?") && h.contains("project=foo%26bar")), + "reserved characters must stay inside the project value; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.contains("project=foo&bar")), + "the raw ampersand must not split query parameters; hits: {hits:?}" + ); +} + +#[test] +fn list_json_miss_is_valid_empty_envelope() { + let (url, _hits) = 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); + // Envelope on stdout, miss on stderr, exit 1. + assert_eq!( + out.status.code(), + Some(1), + "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_with_scan_id_skips_project_resolution() { + // 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, hits) = common::spawn_recording_http_stub(|path| { + 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", NOT_FOUND_JSON.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:?}" + ); +} diff --git a/tests/wait_resolution.rs b/tests/wait_resolution.rs new file mode 100644 index 0000000..545ca9a --- /dev/null +++ b/tests/wait_resolution.rs @@ -0,0 +1,265 @@ +//! End-to-end tests for `corgea wait` repo-URL project resolution (COR-1577). +//! The `/api/v1/scan/` arm serves both `GET /scan/{id}` (`check_scan_status`) +//! and `/scan/{id}/issues` (`report_scan_status`), branching on `/issues`. + +mod common; + +use common::{ + projects_empty, projects_match, scans_empty, scans_one, temp_git_repo, temp_plain_dir, Hits, + CANON, NOT_FOUND_JSON, REMOTE, +}; +use std::path::Path; +use std::process::Output; + +// --- harness --------------------------------------------------------------- + +/// `GET /api/v1/scan/{id}` returning a completed scan (`check_scan_status` +/// checks the lowercase `complete`); `/issues` under it returns an empty page, +/// enough for `report_scan_status` to succeed and print the result link. +fn spawn_stub(projects: String, scans: String) -> (String, Hits) { + common::spawn_recording_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", + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":0,"issues":[]}"# + .to_string(), + ) + } else { + ("200 OK", 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()) + } + } else { + ("404 Not Found", NOT_FOUND_JSON.to_string()) + } + }) +} + +fn run_wait(args: &[&str], url: &str, cwd: &Path) -> Output { + common::run_corgea("wait", args, url, cwd) +} + +fn spawn_post_upload_wait_stub() -> (String, Hits) { + common::spawn_recording_http_stub(|path| { + if path.starts_with("/api/v1/verify") + || path.starts_with("/api/v1/code-upload") + || path.starts_with("/api/v1/git-config-upload") + { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path.starts_with("/api/v1/scan-upload") { + ( + "200 OK", + r#"{"status":"ok","sast_scan_id":"scan-123","project_id":42}"#.to_string(), + ) + } else if path.starts_with("/api/v1/scan/") { + if path.contains("/issues") { + ( + "200 OK", + r#"{"status":"ok","page":1,"total_pages":1,"total_issues":0,"issues":[]}"# + .to_string(), + ) + } else { + ("200 OK", r#"{"id":"scan-123","project":"p","repo":null,"branch":"main","status":"complete","engine":"blast","created_at":"2026-01-01T00:00:00Z"}"#.to_string()) + } + } else { + ("404 Not Found", NOT_FOUND_JSON.to_string()) + } + }) +} + +// --- tests ----------------------------------------------------------------- + +#[test] +fn wait_uses_the_canonical_project_from_the_repo() { + // The checkout is `build-123`; only resolution can produce the canonical + // name, both in the /scans query and in the result link. + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON)); + let (_tmp, repo) = temp_git_repo("build-123", 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 web route is `project//`, so the canonical name links. + assert!( + stdout.contains("/project/bohappdev/dotnet-azure-web-tsb?scan_id=scan-123"), + "stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/scans?") && h.contains("project=bohappdev%2F")), + "the canonical project must drive /scans; hits: {hits:?}" + ); +} + +#[test] +fn wait_miss_names_the_repo_not_a_bare_error() { + // Pre-COR-1577 this printed only "Error querying scan list". + let (url, _hits) = 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}" + ); + assert!( + stderr.contains(&format!("repo '{CANON}'")), + "stderr should name the repo; stderr: {stderr}" + ); + assert!( + !stderr.contains("Error querying scan list"), + "the bare error must be absent; stderr: {stderr}" + ); +} + +#[test] +fn wait_project_name_override_is_trimmed_before_the_query() { + // The name goes into `?project=`, which the backend matches exactly, so a + // trailing slash must be gone before the request — not only in the link. + let (url, hits) = spawn_stub(projects_empty(), scans_one("foo")); + let (_tmp, dir) = temp_plain_dir("whatever"); + let out = run_wait(&["--project-name", "foo/"], &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/foo?scan_id=scan-123"), + "stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + // `project` is the last query param, so `ends_with` also proves the value + // is not the un-trimmed `foo%2F`. + assert!( + hits.iter() + .any(|h| h.starts_with("/api/v1/scans?") && h.ends_with("project=foo")), + "the scan listing must be queried for `foo`; hits: {hits:?}" + ); +} + +#[test] +fn wait_with_scan_id_does_not_list_scans() { + // `scans` is served but must never be dialed: the listing is only read + // when no scan id was given. + let (url, hits) = spawn_stub(projects_match(), scans_one(CANON)); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_wait(&["scan-123"], &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/scans")), + "no scan listing with a scan id; hits: {hits:?}" + ); +} + +/// `corgea scan semgrep` with a fake `semgrep` on PATH: the post-scan wait gets +/// the project id straight from the upload response, so it must resolve nothing +/// — no `/projects`, no `/scans` — and still link the id-form URL. +#[cfg(unix)] +#[test] +fn scan_post_wait_uses_upload_project_id_without_resolving() { + let (url, hits) = spawn_post_upload_wait_stub(); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + std::fs::write(repo.join("app.py"), "x = 1\n").expect("write source"); + let bin = repo.join("fakebin"); + std::fs::create_dir(&bin).expect("create fake bin dir"); + let report = r#"{"version":"1.0.0","errors":[],"results":[{"check_id":"rule","path":"app.py","start":{"line":1},"end":{"line":1},"extra":{"message":"m","severity":"ERROR","metadata":{"source":"https://semgrep.dev/r/rule"}}}]}"#; + common::write_script( + &bin, + "semgrep", + &format!("#!/bin/sh\nprintf '%s' '{report}'\n"), + ); + + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .args(["scan", "semgrep"]) + .env("PATH", &bin) + .env("CORGEA_URL", &url) + .env("CORGEA_TOKEN", "test-token") + // `running_in_ci()` is true under Actions, which sends `upload_scan` + // down the GITHUB_REPOSITORY branch. Pin the non-CI path either way. + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .current_dir(&repo) + .output() + .expect("spawn corgea"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stdout: {stdout}\nstderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("/project/42/?scan_id=scan-123"), + "expected the id-form scan URL; stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + !hits + .iter() + .any(|h| h.starts_with("/api/v1/projects") || h.starts_with("/api/v1/scans")), + "an upload that carried the id must resolve nothing; hits: {hits:?}" + ); +} + +#[test] +fn upload_wait_uses_upload_project_id_without_resolving() { + let (url, hits) = spawn_post_upload_wait_stub(); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let report_path = repo.join("report.json"); + let report = r#"{"version":"1.0.0","errors":[],"results":[{"check_id":"rule","path":"app.py","start":{"line":1},"end":{"line":1},"extra":{"message":"m","severity":"ERROR","metadata":{"source":"https://semgrep.dev/r/rule"}}}]}"#; + std::fs::write(repo.join("app.py"), "x = 1\n").expect("write source"); + std::fs::write(&report_path, report).expect("write report"); + + let (mut cmd, _home) = common::corgea_isolated(); + let out = cmd + .arg("upload") + .arg(&report_path) + .arg("--wait") + .env("CORGEA_URL", &url) + .env("CORGEA_TOKEN", "test-token") + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .current_dir(&repo) + .output() + .expect("spawn corgea"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stdout: {stdout}\nstderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("/project/42/?scan_id=scan-123"), + "expected the id-form scan URL; stdout: {stdout}" + ); + let hits = hits.lock().unwrap(); + assert!( + !hits + .iter() + .any(|h| h.starts_with("/api/v1/projects") || h.starts_with("/api/v1/scans")), + "upload --wait must use the returned project id; hits: {hits:?}" + ); +}