From d73b19f04b75ec39a02d9e9ef90feb37cb8a875c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:52:37 +0000 Subject: [PATCH 1/3] Start fix for #1089 (scan_mod slice) Assisted-by: Claude Code:claude-opus-5-5 From ecca6c1c06175bfd5e892163a080f51c61b36abf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:11:29 +0000 Subject: [PATCH 2/3] CI: scan mod tests assert codes, not sentences Copy edits to scan's terminal output no longer break dozens of unrelated tests, and each scan path now has a --json test that checks its exit code, error code, events, warning codes and files on disk. covgap_commands_scan_mod.rs (58 tests, its own test binary) moves into the scan suite binary as four modules: - scan_failure_paths.rs: the --json contracts, with no 4+-word sentence asserts. New twins cover the total and partial detail-fetch failures, vendor-owned purls, report-only --prune, the --sync GC and the retained hosted wiring warning. - scan_render.rs: the human output, one check per message family, keeping each human run's disk-state checks. - scan_tty.rs: scan on a real terminal (unix only). - scan_fixture.rs: shared mocks, project writers and runners. Four copies of the 500 detail mock and the two patch-API mounts are merged. Refs #1089 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/covgap_commands_scan_mod.rs | 3160 ----------------- crates/socket-patch-cli/tests/scan/main.rs | 7 + .../tests/scan/scan_failure_paths.rs | 923 +++++ .../tests/scan/scan_fixture.rs | 482 +++ .../tests/scan/scan_render.rs | 1261 +++++++ .../socket-patch-cli/tests/scan/scan_tty.rs | 343 ++ docs/testing/bun-compatibility.md | 3 +- scripts/ci-test-durations.json | 3 +- 8 files changed, 3019 insertions(+), 3163 deletions(-) delete mode 100644 crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs create mode 100644 crates/socket-patch-cli/tests/scan/scan_failure_paths.rs create mode 100644 crates/socket-patch-cli/tests/scan/scan_fixture.rs create mode 100644 crates/socket-patch-cli/tests/scan/scan_render.rs create mode 100644 crates/socket-patch-cli/tests/scan/scan_tty.rs diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs deleted file mode 100644 index b5519ac45..000000000 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ /dev/null @@ -1,3160 +0,0 @@ -//! Coverage-gap tests for `commands/scan/mod.rs`. -//! -//! Pins the otherwise-untested surfaces of `scan`: -//! -//! * the remaining `resolve_mode_flags` cross-mode conflict arms (and -//! `ScanMode::Agent.cli_name()` reaching an error message); -//! * human (non-JSON) terminals: `--offline`, the `--mode hosted --prune` -//! no-GC warning, the empty `--global-prefix` hint, the `--vex` success -//! line, and the PnP layout refusal riding a NON-empty scan; -//! * the `redirect_prune_ignored` warning inside the zero-package hosted -//! JSON envelope; -//! * the paid-tier rendering surface (no fixture anywhere else sends -//! `tier: "paid"` in the batch response): the `N+M` table column, the -//! paid-subscription nudge + pricing URL, the paid-access summary, and -//! the "No downloadable patches" terminal; -//! * the human `[UPDATE]` marker + newer-patches summary, the `(+N)` vuln -//! overflow, and the per-package detail-fetch warning; -//! * the human `[skip] … (vendored …)` lines; -//! * per-patch vulnerability rendering in the "Patches to apply" preview; -//! * the human post-apply GC line (both pluralization arms) and the -//! `hosted_wiring_retained` stderr warning after an in-place apply; -//! * human runs: a bare scan runs hosted mode; a mode-less `--prune`/global -//! scan is report-only (no download, no `.socket/`, prints the -//! `scan --mode agent` hint), while `--mode agent` / `--sync` apply -//! without prompting; -//! * the hosted human arm's results table and `[UPDATE]` detection (parity -//! with the agent/vendored arms); -//! * scan on a PTY: never prompts, never opens the select menu under -//! --json, and renders the live status line cleanly. -//! -//! Subprocess runs scrub the `SOCKET_*` flag environment (the -//! `cli_scan_silent.rs` pattern) so ambient developer/CI configuration -//! cannot reroute the branch under test. Network tests use wiremock. - -#[path = "common/mod.rs"] -mod common; -use common::{binary, git_sha256, hermetic_command}; - -#[path = "common/pty_io.rs"] -mod pty_io; -#[path = "vlt_hosted_common/mod.rs"] -mod vlt_hosted_common; -#[path = "vlt_hosted_common/vendored.rs"] -mod vlt_vendored; -use std::path::Path; - -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const ORG_SLUG: &str = "test-org"; -const UUID: &str = "11111111-1111-4111-8111-111111111111"; -const OLD_UUID: &str = "99999999-9999-4999-8999-999999999999"; - -fn write_root_package_json(root: &Path) { - std::fs::write( - root.join("package.json"), - r#"{ "name": "covgap-scan-root", "version": "0.0.0" }"#, - ) - .unwrap(); -} - -fn write_npm_package(root: &Path, name: &str, version: &str, content: &[u8]) { - let pkg_dir = root.join("node_modules").join(name); - std::fs::create_dir_all(&pkg_dir).unwrap(); - std::fs::write( - pkg_dir.join("package.json"), - format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), - ) - .unwrap(); - std::fs::write(pkg_dir.join("index.js"), content).unwrap(); -} - -/// Run `socket-patch scan` in `cwd` in the hermetic env, so the CLI -/// arguments are the sole source of truth (telemetry stays disabled so no -/// run phones home). -fn run_scan(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = hermetic_command(&binary()); - cmd.arg("scan").args(args).current_dir(cwd); - // The unconditional python crawl consults VIRTUAL_ENV first, so an - // activated host virtualenv would make "empty project" scans non-empty. - cmd.env_remove("VIRTUAL_ENV"); - let out = cmd.output().expect("run socket-patch scan"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) -} - -/// Human-path (no `--json`) run against a mock API. -fn run_scan_human(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { - let mut args = vec![ - "--api-url", - api_url, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ]; - args.extend_from_slice(extra); - run_scan(cwd, &args) -} - -/// [`run_scan_human`] in agent mode: v5's bare scan is hosted, and these -/// fixtures exercise the in-place apply flow. -fn run_scan_agent(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { - let mut args = vec!["--mode", "agent"]; - args.extend_from_slice(extra); - run_scan_human(cwd, api_url, &args) -} - -async fn recorded(mock: &MockServer) -> Vec { - mock.received_requests() - .await - .expect("wiremock records requests by default") -} - -fn by_package_gets(reqs: &[wiremock::Request]) -> usize { - reqs.iter() - .filter(|r| { - format!("{}", r.method) == "GET" && r.url.path().contains("/patches/by-package/") - }) - .count() -} - -fn view_gets(reqs: &[wiremock::Request]) -> usize { - reqs.iter() - .filter(|r| format!("{}", r.method) == "GET" && r.url.path().contains("/patches/view/")) - .count() -} - -fn batch_bodies(reqs: &[wiremock::Request]) -> Vec { - reqs.iter() - .filter(|r| format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/batch")) - .map(|r| String::from_utf8_lossy(&r.body).into_owned()) - .collect() -} - -/// Mount the batch endpoint returning one package with one patch of the -/// given tier and vuln ids. -async fn mount_batch_one( - mock: &MockServer, - purl: &str, - uuid: &str, - tier: &str, - cve_ids: &[&str], - can_access_paid: bool, -) { - mount_batch_one_delayed( - mock, - purl, - uuid, - tier, - cve_ids, - can_access_paid, - std::time::Duration::ZERO, - ) - .await; -} - -/// [`mount_batch_one`], answering only after `delay`. -async fn mount_batch_one_delayed( - mock: &MockServer, - purl: &str, - uuid: &str, - tier: &str, - cve_ids: &[&str], - can_access_paid: bool, - delay: std::time::Duration, -) { - let body = serde_json::json!({ - "packages": [{ - "purl": purl, - "patches": [{ - "uuid": uuid, - "purl": purl, - "tier": tier, - "cveIds": cve_ids, - "ghsaIds": [], - "severity": "high", - "title": "covgap test patch" - }] - }], - "canAccessPaidPatches": can_access_paid, - }); - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(body) - .set_delay(delay), - ) - .mount(mock) - .await; -} - -fn encode_purl(purl: &str) -> String { - purl.replace(':', "%3A") - .replace('/', "%2F") - .replace('@', "%40") -} - -/// Mount the by-package detail endpoint for `purl` with the given -/// vulnerabilities object. -async fn mount_by_package( - mock: &MockServer, - purl: &str, - uuid: &str, - vulnerabilities: serde_json::Value, -) { - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [{ - "uuid": uuid, - "purl": purl, - "publishedAt": "2024-01-01T00:00:00Z", - "description": "Covgap test patch", - "license": "MIT", - "tier": "free", - "vulnerabilities": vulnerabilities, - }], - "canAccessPaidPatches": false, - }))) - .mount(mock) - .await; -} - -/// Mount the three endpoints the full human apply flow hits — batch, -/// by-package, and the patch view with an inline blob (fixture shape -/// mirrors `cli_scan_silent.rs` / `scan_sync_e2e.rs`). -async fn mount_one_patch_api(mock: &MockServer, purl: &str, before: &[u8]) { - mount_one_patch_api_delayed(mock, purl, before, std::time::Duration::ZERO).await; -} - -/// [`mount_one_patch_api`] with the batch query answering after `delay`. -async fn mount_one_patch_api_delayed( - mock: &MockServer, - purl: &str, - before: &[u8], - delay: std::time::Duration, -) { - let before_hash = git_sha256(before); - let after_hash = git_sha256(b"after\n"); - mount_batch_one_delayed(mock, purl, UUID, "free", &[], false, delay).await; - mount_by_package(mock, purl, UUID, serde_json::json!({})).await; - // base64 of "after\n" — inline so the apply step needs no blob endpoint. - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "uuid": UUID, - "purl": purl, - "publishedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": before_hash, - "afterHash": after_hash, - "blobContent": "YWZ0ZXIK", - } - }, - "vulnerabilities": {}, - "description": "Covgap test patch", - "license": "MIT", - "tier": "free", - }))) - .mount(mock) - .await; -} - -/// Seed `.socket/manifest.json` with one entry per `(purl, uuid)` pair -/// (camelCase wire shape, mirroring `scan_invariants.rs`). -fn seed_manifest(root: &Path, entries: &[(&str, &str)]) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let patches: serde_json::Map = entries - .iter() - .map(|(purl, uuid)| { - ( - (*purl).to_string(), - serde_json::json!({ - "uuid": uuid, - "exportedAt": "2024-01-01T00:00:00Z", - "files": {}, - "vulnerabilities": {}, - "description": "seed", - "license": "MIT", - "tier": "free", - }), - ) - }) - .collect(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), - ) - .unwrap(); -} - -// --------------------------------------------------------------------------- -// resolve_mode_flags — the `--sync` cross-mode conflict -// --------------------------------------------------------------------------- -// `--sync` means `--mode agent --prune`, so it contradicts any other -// `--mode`. Clap parses each combination fine (no value-dependent conflict -// is expressible); the fold is what rejects them, naming the mode. - -mod mode_fold { - use clap::Parser; - use socket_patch_cli::commands::scan::{resolve_mode_flags, ScanArgs}; - use socket_patch_cli::{Cli, Commands}; - - /// Every env var that could reroute the parse or the fold (superset: - /// the shared scrub lists from `args.rs`). - fn with_clean_env(f: impl FnOnce() -> T) -> T { - let vars: Vec<&str> = socket_patch_cli::args::GLOBAL_ARG_ENV_VARS - .iter() - .chain(socket_patch_cli::args::LOCAL_ARG_ENV_VARS.iter()) - .copied() - .collect(); - let saved: Vec<(&str, Option)> = - vars.iter().map(|k| (*k, std::env::var(k).ok())).collect(); - for k in &vars { - std::env::remove_var(k); - } - let result = f(); - for (k, orig) in saved { - match orig { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } - result - } - - fn parse_scan(extra: &[&str]) -> ScanArgs { - let mut argv = vec!["socket-patch", "scan"]; - argv.extend_from_slice(extra); - let cli = with_clean_env(|| Cli::try_parse_from(&argv)).expect("parse"); - match cli.command { - Commands::Scan(a) => a, - _ => panic!("expected Scan"), - } - } - - fn fold_err(extra: &[&str]) -> String { - let mut args = parse_scan(extra); - resolve_mode_flags(&mut args).expect_err("cross-mode contradiction must error") - } - - #[test] - #[serial_test::serial] - fn mode_hosted_with_sync_boolean_errors() { - // --sync means agent mode, so it contradicts hosted. - let err = fold_err(&["--mode", "hosted", "--sync"]); - assert!( - err.contains("--mode hosted cannot be used with --sync"), - "the --sync arm must name the conflicting boolean: {err}" - ); - } - - #[test] - #[serial_test::serial] - fn mode_vendored_with_sync_boolean_errors() { - let err = fold_err(&["--mode", "vendored", "--sync"]); - assert!( - err.contains("--mode vendored cannot be used with --sync"), - "the vendored arm must render cli_name(): {err}" - ); - } -} - -// --------------------------------------------------------------------------- -// Human terminals reachable without a network: --offline, hosted --prune, -// empty --global-prefix -// --------------------------------------------------------------------------- - -/// `scan --offline` on the human path: hard error on stderr, exit 1, -/// nothing on stdout (the JSON twin is covered elsewhere). -#[test] -fn scan_offline_human_path_errors_on_stderr() { - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run_scan(tmp.path(), &["--offline"]); - assert_eq!(code, 1, "offline scan must fail; stderr={stderr:?}"); - assert!( - stderr.contains("scan requires network access"), - "the strict-airgap refusal must reach stderr; got {stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "the human offline error must not print a JSON envelope; got {stdout:?}" - ); -} - -/// `scan --mode hosted --prune` (human path): the no-GC warning fires -/// BEFORE the crawl, so an empty project suffices — no network at all. -#[test] -fn scan_hosted_prune_human_warns_prune_is_ignored() { - let tmp = tempfile::tempdir().unwrap(); - let (code, _stdout, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--prune"]); - assert_eq!( - code, 0, - "hosted --prune stays accepted (never a usage error)" - ); - assert!( - stderr.contains("Warning: --prune has no effect with --mode hosted"), - "the ignored-prune warning must reach stderr; got {stderr:?}" - ); - assert!( - stderr.contains("runs no GC sweep"), - "the warning must explain hosted mode runs no GC; got {stderr:?}" - ); -} - -/// `--global-prefix `: the global-flavored empty-scan hint. -#[test] -fn scan_empty_global_prefix_prints_global_hint() { - let tmp = tempfile::tempdir().unwrap(); - let prefix = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run_scan( - tmp.path(), - &["--global-prefix", prefix.path().to_str().unwrap()], - ); - assert_eq!(code, 0, "an empty global scan succeeds; stderr={stderr:?}"); - assert!( - stdout.contains("No global packages found."), - "the global empty-scan hint must print; got {stdout:?}" - ); - assert!( - !stdout.contains("No packages found. Run"), - "the local-flavored hint must not print on a global scan; got {stdout:?}" - ); -} - -// --------------------------------------------------------------------------- -// Zero-package hosted JSON envelope: redirect_prune_ignored rides -// redirect.warnings -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn scan_hosted_prune_zero_package_json_carries_the_warning() { - let mock = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - - let (code, stdout, stderr) = run_scan_human( - tmp.path(), - &mock.uri(), - &["--json", "--mode", "hosted", "--prune"], - ); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "success"); - assert_eq!( - v["scannedPackages"], 0, - "classic keys stay schema-consistent" - ); - assert_eq!(v["redirect"]["mode"], "hosted"); - assert_eq!(v["summary"]["applied"], 0, "nothing pinned: {v}"); - // v5.0: warnings ride the envelope's top-level `warnings[]`. - let warnings = v["warnings"].as_array().expect("warnings array"); - let prune_warning = warnings - .iter() - .find(|w| w["code"] == "redirect_prune_ignored") - .unwrap_or_else(|| panic!("redirect_prune_ignored must ride the envelope: {v}")); - assert!( - prune_warning["detail"] - .as_str() - .is_some_and(|d| d.contains("runs no GC sweep")), - "the warning detail must explain the no-op: {prune_warning}" - ); - - // The empty crawl never queries the API. - let reqs = recorded(&mock).await; - assert!( - batch_bodies(&reqs).is_empty(), - "empty project must not query" - ); -} - -// --------------------------------------------------------------------------- -// Paid-tier surface — no other fixture sends tier: "paid" -// --------------------------------------------------------------------------- - -/// Paid patch, NO paid access: the `free+paid` table column, the -/// paid-subscription nudge + pricing URL, and the "No downloadable -/// patches" terminal (exit 0, and no detail fetch is even attempted). -#[tokio::test] -async fn scan_paid_patch_without_access_nudges_and_downloads_nothing() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "paid", &["CVE-2024-0001"], false).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("0+1"), - "the table column must render free+paid counts; got {stdout:?}" - ); - assert!( - stdout.contains("Summary: 1 package with 0 free patches"), - "the no-access summary counts FREE patches only; got {stdout:?}" - ); - assert!( - stdout.contains("+ 1 additional patch is available with a paid Socket plan"), - "the paid nudge must print; got {stdout:?}" - ); - assert!( - stdout.contains("https://socket.dev/pricing"), - "the pricing URL must print; got {stdout:?}" - ); - assert!( - stdout.contains("No downloadable patches: every patch found requires a paid Socket plan."), - "the gated-catalog terminal must print; got {stdout:?}" - ); - - // Returned before the detail-fetch loop: nothing was downloadable. - let reqs = recorded(&mock).await; - assert_eq!( - by_package_gets(&reqs), - 0, - "no detail fetch may happen when nothing is downloadable" - ); -} - -/// Paid patch WITH paid access: the summary counts ALL patches (the -/// can-access arm) and no nudge prints. The deliberately-failing detail -/// fetch (500) then pins the per-package warning and the terminal error. -#[tokio::test] -async fn scan_paid_patch_with_access_counts_all_and_reports_detail_failure() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "paid", &[], true).await; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); - assert_eq!( - code, 1, - "a failed detail fetch fails the scan; stdout={stdout}" - ); - assert!( - stdout.contains("Summary: 1 package with 1 available patch"), - "the can-access summary counts all patches; got {stdout:?}" - ); - assert!( - stdout.contains("0+1"), - "the free+paid column renders for paid-access users too; got {stdout:?}" - ); - assert!( - !stdout.contains("paid subscription"), - "no nudge for a subscriber; got {stdout:?}" - ); - assert!( - stderr.contains("Error: could not fetch patch details for"), - "the terminal detail-failure must reach stderr; got {stderr:?}" - ); -} - -/// The JSON twin pinning the paid counter itself: `paidPatches` counts -/// every non-free tier, `freePatches` stays 0. -#[tokio::test] -async fn scan_json_counts_paid_patches_separately() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "paid", &[], false).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - // v5.0: the tier counts derive from the discovery payload. - let tiers: Vec<&str> = v["packages"] - .as_array() - .unwrap() - .iter() - .flat_map(|p| p["patches"].as_array().unwrap()) - .map(|p| p["tier"].as_str().unwrap()) - .collect(); - assert_eq!(tiers, ["paid"], "{v}"); - assert_eq!(v["canAccessPaidPatches"], false); -} - -// --------------------------------------------------------------------------- -// Human table: [UPDATE] marker, newer-patches summary, (+N) vuln overflow -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn scan_human_table_renders_update_marker_and_vuln_overflow() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - // Three CVEs force the `(+N)` truncation; the manifest's OLD uuid - // against the batch's NEW uuid drives the update marker. - mount_batch_one( - &mock, - purl, - UUID, - "free", - &["CVE-2024-0003", "CVE-2024-0001", "CVE-2024-0002"], - false, - ) - .await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - seed_manifest(tmp.path(), &[(purl, OLD_UUID)]); - - // --dry-run keeps the run read-only past the table (the download and - // apply never run), so no view/blob mocks are needed. - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("[UPDATE]"), - "the human update marker must render; got {stdout:?}" - ); - assert!( - stdout.contains("1 package has a newer patch available."), - "the newer-patches summary must print; got {stdout:?}" - ); - // Deterministic order: collect_vuln_ids sorts CVEs, so the first two - // sorted ids show and the third folds into (+1). - assert!( - stdout.contains("CVE-2024-0001, CVE-2024-0002 (+1)"), - "3+ vuln ids must truncate to two plus (+N); got {stdout:?}" - ); - assert!( - stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), - "dry-run must stop before the confirm; got {stdout:?}" - ); -} - -/// When every detail fetch fails, the human path prints ONE Error line that -/// names the package and the cause (no per-package Warning repeating it). -#[tokio::test] -async fn scan_human_detail_fetch_failure_errors_once() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains(&format!( - "Error: could not fetch patch details for {purl}: " - )), - "the terminal error names the purl and the cause; got {stderr:?}" - ); - assert!( - !stderr.contains("Warning: could not fetch details"), - "a total failure must not repeat the cause as a Warning first; got {stderr:?}" - ); -} - -/// A partial detail-fetch failure warns per failed package on stderr and -/// carries on with the packages that did resolve. -#[tokio::test] -async fn scan_human_partial_detail_fetch_failure_warns_per_package() { - let mock = MockServer::start().await; - let ok = "pkg:npm/minimist@1.2.2"; - let bad = "pkg:npm/lodash@4.17.20"; - let body = serde_json::json!({ - "packages": [ - {"purl": ok, "patches": [{ - "uuid": UUID, "purl": ok, "tier": "free", "cveIds": [], - "ghsaIds": [], "severity": "high", "title": "t" - }]}, - {"purl": bad, "patches": [{ - "uuid": "33333333-3333-4333-8333-333333333333", "purl": bad, - "tier": "free", "cveIds": [], "ghsaIds": [], "severity": "high", - "title": "t" - }]} - ], - "canAccessPaidPatches": false, - }); - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(&mock) - .await; - mount_by_package(&mock, ok, UUID, serde_json::json!({})).await; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(bad) - ))) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert_eq!( - stderr - .matches(&format!("Warning: could not fetch details for {bad}: ")) - .count(), - 1, - "one warning naming the failed purl; got {stderr:?}" - ); - assert!( - !stderr.contains("Error:"), - "a partial failure is not an error: {stderr:?}" - ); - assert!( - stdout.contains("[dry-run] Would download and apply 1 patch."), - "the resolved package still goes through; got {stdout:?}" - ); -} - -// --------------------------------------------------------------------------- -// Vendor-owned purls on the human path: the [skip] lines -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn scan_human_skips_vendored_purls_without_downloading() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - // Vendored state ledger claiming the purl (camelCase wire shape). - let vendor_dir = tmp.path().join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - std::fs::write( - vendor_dir.join("state.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "version": 1, - "entries": { - purl: { - "ecosystem": "npm", - "basePurl": purl, - "uuid": UUID, - "artifact": { - "path": format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz"), - }, - "wiring": [], - } - } - })) - .unwrap(), - ) - .unwrap(); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains(&format!( - "[skip] {purl} (vendored; run `socket-patch scan --mode vendored` to update it)" - )), - "the vendored skip line must name the purl and the remedy; got {stdout:?}" - ); - assert!( - stdout.contains("No patches selected."), - "with everything vendor-owned nothing is selected; got {stdout:?}" - ); - - // The vendor-owned purl never downloads: no /view/ fetch happened. - let reqs = recorded(&mock).await; - assert_eq!(view_gets(&reqs), 0, "vendor-owned purls must not download"); - // …and no manifest write happened (the skip is calm, not a mutation). - assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "a fully-skipped run must not create the manifest" - ); -} - -// --------------------------------------------------------------------------- -// "Patches to apply" preview: per-patch vulnerability rendering -// --------------------------------------------------------------------------- -// Every other human-path fixture ships `vulnerabilities: {}`, so the -// CVE/GHSA fold, the "Fixes:" line, and the per-vuln summary lines had -// never executed. - -#[tokio::test] -async fn scan_human_preview_renders_vulnerability_details() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package( - &mock, - purl, - UUID, - serde_json::json!({ - "GHSA-aaaa-bbbb-cccc": { - "cves": ["CVE-2024-9999"], - "summary": "heap overflow in parse", - "severity": "HIGH", - "description": "d" - }, - "GHSA-dddd-eeee-ffff": { - "cves": [], - "summary": "no-cve issue", - "severity": "LOW", - "description": "d" - } - }), - ) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("Fixes: "), - "the Fixes line must print; got {stdout:?}" - ); - assert!( - stdout.contains("CVE-2024-9999"), - "a vuln WITH CVEs contributes its CVE ids; got {stdout:?}" - ); - assert!( - stdout.contains("GHSA-dddd-eeee-ffff"), - "a CVE-less vuln falls back to its GHSA id in Fixes; got {stdout:?}" - ); - assert!( - stdout.contains("- CVE-2024-9999: heap overflow in parse"), - "the per-vuln summary line carries its CVE label; got {stdout:?}" - ); - assert!( - stdout.contains("- GHSA-dddd-eeee-ffff: no-cve issue"), - "a CVE-less vuln's summary is labeled with its advisory id; got {stdout:?}" - ); - assert!( - stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), - "dry-run stops before any mutation; got {stdout:?}" - ); -} - -/// `scan --dry-run --vex`: the embedded VEX is skipped with the same note -/// apply, vendor and hosted scan print, and no document is written. -#[tokio::test] -async fn scan_human_dry_run_vex_prints_the_shared_skip_line() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_agent( - tmp.path(), - &mock.uri(), - &["--dry-run", "--yes", "--vex", "out.vex.json"], - ); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout - .lines() - .any(|l| l == "Skipping VEX generation (--dry-run: nothing was applied)."), - "got {stdout:?}" - ); - assert!(!tmp.path().join("out.vex.json").exists()); -} - -// --------------------------------------------------------------------------- -// Human post-apply GC line (`--sync`) — both pluralization arms — and the -// hosted_wiring_retained warning after an in-place apply -// --------------------------------------------------------------------------- - -/// Run the full human apply pipeline with `flags` (e.g. `--sync --yes`) -/// and `orphans` pre-seeded manifest entries (plus one orphan blob file -/// each), and return `(code, stdout, stderr, root)`. -async fn run_apply_with_orphans( - mock: &MockServer, - orphans: &[(&str, &str, char)], - flags: &[&str], -) -> (i32, String, String, tempfile::TempDir) { - let purl = "pkg:npm/silent-target@1.0.0"; - let before = b"before\n"; - mount_one_patch_api(mock, purl, before).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "silent-target", "1.0.0", before); - let entries: Vec<(&str, &str)> = orphans.iter().map(|(p, u, _)| (*p, *u)).collect(); - seed_manifest(tmp.path(), &entries); - let blobs = tmp.path().join(".socket/blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - for (_, _, fill) in orphans { - std::fs::write(blobs.join(fill.to_string().repeat(64)), b"orphan").unwrap(); - } - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); - (code, stdout, stderr, tmp) -} - -#[tokio::test] -async fn scan_sync_human_gc_line_singular_arms() { - let mock = MockServer::start().await; - let (code, stdout, stderr, tmp) = run_apply_with_orphans( - &mock, - &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')], - &["--sync", "--yes"], - ) - .await; - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("GC: pruned 1 manifest entry and removed 1 orphan file ("), - "the singular GC line must print; got {stdout:?}" - ); - // The line reports real work: the orphan entry and blob are gone. - let manifest = - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); - let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); - assert!( - v["patches"]["pkg:npm/gone@1.0.0"].is_null(), - "the orphan entry must be pruned: {v}" - ); - assert_eq!( - v["patches"]["pkg:npm/silent-target@1.0.0"]["uuid"], UUID, - "the applied patch must be recorded: {v}" - ); - assert!( - !tmp.path() - .join(".socket/blobs") - .join("c".repeat(64)) - .exists(), - "the orphan blob must be swept" - ); -} - -#[tokio::test] -async fn scan_sync_human_gc_line_plural_arms() { - let mock = MockServer::start().await; - let (code, stdout, stderr, _tmp) = run_apply_with_orphans( - &mock, - &[ - ("pkg:npm/gone@1.0.0", OLD_UUID, 'c'), - ( - "pkg:npm/also-gone@2.0.0", - "88888888-8888-4888-8888-888888888888", - 'd', - ), - ], - &["--sync", "--yes"], - ) - .await; - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("GC: pruned 2 manifest entries and removed 2 orphan files ("), - "the plural GC line must print; got {stdout:?}" - ); -} - -/// After an in-place apply over LIVE hosted wiring (redirect ledger record -/// + a lockfile still resolving the purl to the hosted patch server), the -/// human path must warn that the conversion did not complete. -#[tokio::test] -async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { - let mock = MockServer::start().await; - let purl = "pkg:npm/silent-target@1.0.0"; - let before = b"before\n"; - mount_one_patch_api(&mock, purl, before).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "silent-target", "1.0.0", before); - - // The live lock: resolves the purl to the hosted patch server (the - // inventory proof fires on the patch.socket.dev host). - let lock = serde_json::json!({ - "name": "covgap-scan-root", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "covgap-scan-root", - "version": "0.0.0", - "dependencies": { "silent-target": "^1.0.0" } - }, - "node_modules/silent-target": { - "version": "1.0.0", - "resolved": format!( - "https://patch.socket.dev/patch/npm/tok/{UUID}/silent-target-1.0.0.tgz" - ), - "integrity": "sha512-orig==", - } - } - }); - std::fs::write( - tmp.path().join("package-lock.json"), - serde_json::to_string_pretty(&lock).unwrap(), - ) - .unwrap(); - - // v5: the lock pin above is the whole hosted state (no ledger). - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("Warning: agent-mode scan left the hosted wiring live"), - "the retained-wiring warning must reach stderr; got {stderr:?}" - ); - assert!( - stderr.contains(purl), - "the warning must name the package; got {stderr:?}" - ); - // The apply itself still happened (warning, not a refusal). - assert_eq!( - std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), - b"after\n", - "the in-place apply proceeds despite the warning" - ); -} - -// --------------------------------------------------------------------------- -// embed_vex_human success line -// --------------------------------------------------------------------------- - -#[test] -fn scan_human_vex_success_prints_wrote_line() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - // Manifest with one vulnerable record; nothing installed. With - // --vex-no-verify the manifest is trusted, so the empty-crawl early - // return still generates the document (no network at all). - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "patches": { - "pkg:npm/vuln-pkg@1.0.0": { - "uuid": UUID, - "exportedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": "a".repeat(64), - "afterHash": "b".repeat(64), - } - }, - "vulnerabilities": { - "GHSA-aaaa-bbbb-cccc": { - "cves": ["CVE-2024-0001"], - "summary": "s", - "severity": "high", - "description": "d" - } - }, - "description": "seed", - "license": "MIT", - "tier": "free", - } - } - })) - .unwrap(), - ) - .unwrap(); - let vex_path = tmp.path().join("scan.vex.json"); - - let (code, stdout, stderr) = run_scan( - tmp.path(), - &[ - "--vex", - vex_path.to_str().unwrap(), - "--vex-no-verify", - "--vex-product", - "pkg:npm/my-app@1.0.0", - ], - ); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("Wrote OpenVEX document with 1 statement to"), - "the human VEX success line must print; got {stdout:?}" - ); - assert!( - stdout.contains(vex_path.to_str().unwrap()), - "the success line must name the output path; got {stdout:?}" - ); - let doc: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&vex_path).expect("VEX doc written")) - .expect("valid OpenVEX JSON"); - assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); - assert_eq!(doc["statements"].as_array().map(|s| s.len()), Some(1)); -} - -// --------------------------------------------------------------------------- -// PnP layout refusal rides the NON-empty human scan (polyglot repo) -// --------------------------------------------------------------------------- -// The zero-package twin is covered; this pins that another ecosystem's -// discovery does not silently bless the structurally-invisible npm half. - -#[tokio::test] -async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - // yarn-berry PnP shape: yarn.lock + .pnp.cjs, no node_modules. - write_root_package_json(tmp.path()); - std::fs::write(tmp.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); - std::fs::write(tmp.path().join(".pnp.cjs"), "// stub PnP loader\n").unwrap(); - // …plus one installed gem so the scan is NON-empty. - std::fs::create_dir_all( - tmp.path() - .join("vendor/bundle/ruby/3.0.0/gems/rack-2.2.0/lib"), - ) - .unwrap(); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); - assert_eq!( - code, 0, - "refusals never flip the exit; stdout={stdout}; stderr={stderr}" - ); - assert!( - stderr.contains("Found 1 package ("), - "the gem must be discovered (non-empty path); got {stderr:?}" - ); - assert!( - stderr.contains("Warning: ") && stderr.contains("Plug'n'Play"), - "the PnP refusal must print on the non-empty path; got {stderr:?}" - ); - assert!( - stderr.contains("Plug'n'Play"), - "the refusal must name the layout; got {stderr:?}" - ); - - // The scan genuinely queried for the gem — the other ecosystem's - // discovery ran while the npm half was refused. - let reqs = recorded(&mock).await; - let bodies = batch_bodies(&reqs); - assert_eq!(bodies.len(), 1, "one batch POST for the gem"); - assert!( - bodies[0].contains("pkg:gem/rack@2.2.0"), - "the batch body must carry the crawled gem purl; got {}", - bodies[0] - ); -} - -// --------------------------------------------------------------------------- -// Empty batch response: the human result line -// --------------------------------------------------------------------------- - -/// One installed package, a batch response with no patches: the result is -/// said once, on stdout — the status line ends without repeating it (and -/// leaves no stale status tail). -#[tokio::test] -async fn scan_human_empty_batch_reports_no_patches_once() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .expect(1) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.5", b"module.exports = {};\n"); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert_eq!(stdout, "\nNo patches available for installed packages.\n"); - assert!(!stderr.contains("No patches"), "stderr={stderr:?}"); - assert!( - stderr.lines().any(|l| l == "Found 1 package (1 npm)"), - "stderr={stderr:?}" - ); -} - -// --------------------------------------------------------------------------- -// Human scans never prompt: a mode-less --prune/global scan is report-only, -// agent mode applies -// --------------------------------------------------------------------------- -// A bare scan runs hosted mode; a `--prune` or -// global scan with no mode only reports (it has no lockfile to rewire), -// and `--mode agent` applies in place without asking. - -/// `--prune` with no mode: the discovery, table and per-patch preview -/// print (the report IS the value), then the run stops — no view fetch, no -/// `.socket/`, the installed file untouched. -#[tokio::test] -async fn scan_prune_without_a_mode_is_report_only() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_one_patch_api(&mock, purl, b"x\n").await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune"]); - assert_eq!( - code, 0, - "report-only is a success; stdout={stdout}; stderr={stderr}" - ); - assert!( - stdout.contains("Patches to apply:") && stdout.contains(purl), - "the per-patch preview still prints; got {stdout:?}" - ); - assert!( - stdout.contains("To apply these patches in place, run:") - && stdout.contains("socket-patch scan --mode agent"), - "the agent-mode hint must print; got {stdout:?}" - ); - assert!( - !stderr.contains("Non-interactive mode detected"), - "scan never prompts; got {stderr:?}" - ); - assert!( - !tmp.path().join(".socket").exists(), - "a report-only scan must not create .socket/" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), - b"x\n", - "a report-only scan must not patch the installed file" - ); - let reqs = recorded(&mock).await; - assert_eq!( - view_gets(&reqs), - 0, - "a report-only scan must not download the patch" - ); -} - -/// #464: a report-only global scan hints at commands that keep the global -/// scope. Run verbatim without `--global-prefix`, the hint would scan the -/// cwd project and leave the global copy unpatched. -#[tokio::test] -async fn scan_global_report_only_hint_keeps_the_global_scope() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_one_patch_api(&mock, purl, b"x\n").await; - - let tmp = tempfile::tempdir().unwrap(); - // A prefix with a space: the hint must quote it to stay runnable. - let prefix = tmp.path().join("global lib").join("node_modules"); - let pkg_dir = prefix.join("minimist"); - std::fs::create_dir_all(&pkg_dir).unwrap(); - std::fs::write( - pkg_dir.join("package.json"), - r#"{ "name": "minimist", "version": "1.2.2" }"#, - ) - .unwrap(); - std::fs::write(pkg_dir.join("index.js"), b"x\n").unwrap(); - let cwd = tmp.path().join("elsewhere"); - std::fs::create_dir_all(&cwd).unwrap(); - - let prefix_arg = prefix.to_str().unwrap(); - let (code, stdout, stderr) = - run_scan_human(&cwd, &mock.uri(), &["--global-prefix", prefix_arg]); - assert_eq!( - code, 0, - "report-only is a success; stdout={stdout}; stderr={stderr}" - ); - assert!( - stdout.contains("Patches to apply:") && stdout.contains(purl), - "the global scan must find the patch; got {stdout:?}" - ); - let quoted = if cfg!(windows) { - format!("\"{prefix_arg}\"") - } else { - format!("'{prefix_arg}'") - }; - for command in [ - format!(" socket-patch scan --mode agent --global-prefix {quoted}"), - format!(" socket-patch get --global-prefix {quoted} "), - ] { - assert!( - stdout.lines().any(|line| line == command), - "the hint must keep the global scope ({command:?}); got {stdout:?}" - ); - } - assert_eq!( - std::fs::read(pkg_dir.join("index.js")).unwrap(), - b"x\n", - "a report-only scan must not patch the global copy" - ); -} - -/// Each spelling that selects `--mode agent` applies without prompting. -#[tokio::test] -async fn scan_human_agent_mode_applies_without_prompting() { - for flags in [&["--mode", "agent"][..], &["--sync"][..]] { - let mock = MockServer::start().await; - let purl = "pkg:npm/silent-target@1.0.0"; - let before = b"before\n"; - mount_one_patch_api(&mock, purl, before).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "silent-target", "1.0.0", before); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); - assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); - assert!( - !stderr.contains("Non-interactive mode detected"), - "flags={flags:?}: scan never prompts; got {stderr:?}" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), - b"after\n", - "flags={flags:?}: the apply must proceed" - ); - let manifest = - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); - let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); - assert_eq!(v["patches"][purl]["uuid"], UUID, "flags={flags:?}: {v}"); - } -} - -/// `--mode agent --prune` applies AND garbage-collects, unprompted. -#[tokio::test] -async fn scan_human_agent_prune_applies_and_collects() { - let mock = MockServer::start().await; - let (code, stdout, stderr, tmp) = run_apply_with_orphans( - &mock, - &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')], - &["--mode", "agent", "--prune"], - ) - .await; - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("GC: pruned 1 manifest entry and removed 1 orphan file ("), - "the GC still runs; got {stdout:?}" - ); - let manifest = - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); - let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); - assert!(v["patches"]["pkg:npm/gone@1.0.0"].is_null(), "{v}"); - assert_eq!( - v["patches"]["pkg:npm/silent-target@1.0.0"]["uuid"], UUID, - "{v}" - ); -} - -/// An empty discovery stops every human mode at "No patches available" -/// with exit 0 and no `.socket/`: vendored mode does not fall through to -/// its vendor step to re-vendor a committed manifest (scan vendors what -/// discovery selects), and hosted mode does not enter its engine for a -/// zero-package redirect. -#[tokio::test] -async fn scan_human_empty_discovery_stops_in_every_mode() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .mount(&mock) - .await; - for flags in [ - &[][..], - &["--mode", "vendored"][..], - &["--mode", "hosted"][..], - &["--mode", "agent"][..], - ] { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); - assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("No patches available for installed packages."), - "flags={flags:?}: got {stdout:?}" - ); - assert!( - !tmp.path().join(".socket").exists(), - "flags={flags:?}: an empty discovery must create nothing" - ); - } -} - -// --------------------------------------------------------------------------- -// Human hosted scan: table + update detection parity -// --------------------------------------------------------------------------- -// The hosted human arm shares the table/update block with the agent/vendored -// arms, then runs the engine without prompting (scan never prompts). - -/// Reference endpoint denying the grant: the engine runs (proving the -/// hosted arm reached it) but rewrites nothing, so no lockfile fixture is -/// needed. -async fn mount_forbidden_reference(mock: &MockServer, purl: &str) { - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "results": { UUID: { - "status": "forbidden", "url": null, "purl": purl, - "artifacts": [], "registryOverride": null - } } - }))) - .mount(mock) - .await; -} - -fn reference_posts(reqs: &[wiremock::Request]) -> usize { - reqs.iter() - .filter(|r| format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/package")) - .count() -} - -/// Seed a hosted pin for the npm `purl` in `package-lock.json` — the -/// lockfile resolving it to the Socket patch server under `uuid`. v5 hosted -/// mode keeps its state only in lockfile pins, so this is update -/// detection's "old" side. -fn seed_hosted_pin(root: &Path, purl: &str, uuid: &str) { - let (name, version) = purl - .strip_prefix("pkg:npm/") - .and_then(|rest| rest.rsplit_once('@')) - .expect("an npm purl"); - let lock = serde_json::json!({ - "name": "covgap-scan-root", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "covgap-scan-root", - "version": "0.0.0", - "dependencies": { name: version } - }, - format!("node_modules/{name}"): { - "version": version, - "resolved": format!( - "https://patch.socket.dev/patch/npm/{name}/{version}/tok/{uuid}/{name}-{version}.tgz" - ), - "integrity": "sha512-orig==", - } - } - }); - std::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&lock).unwrap(), - ) - .unwrap(); -} - -/// Hosted PATHs name project directories: each runs as its own scan -/// (its own discovery and redirect), under a `== ==` header. -#[tokio::test] -async fn scan_hosted_paths_run_once_per_project_directory() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - mount_forbidden_reference(&mock, purl).await; - - let tmp = tempfile::tempdir().unwrap(); - for app in ["apps/a", "apps/b"] { - let dir = tmp.path().join(app); - std::fs::create_dir_all(&dir).unwrap(); - write_root_package_json(&dir); - write_npm_package(&dir, "minimist", "1.2.2", b"x\n"); - } - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["apps/*"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - // glob rebuilds matches with the native separator (`apps\a` on Windows). - for app in ["a", "b"] { - let header = format!("== {} ==", Path::new("apps").join(app).display()); - assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); - } - assert_eq!( - stdout - .matches("Switched 0 packages to hosted patches") - .count(), - 2, - "{stdout}" - ); - let reqs = recorded(&mock).await; - assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); -} - -#[tokio::test] -async fn scan_hosted_human_prints_table_updates_and_redirects() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-0001"], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - mount_forbidden_reference(&mock, purl).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - // The lock pins an OLDER hosted patch: the shared update detection must - // flag the newer offer in hosted mode too. - seed_hosted_pin(tmp.path(), purl, OLD_UUID); - - // v5: a bare scan is hosted. - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("VULNERABILITIES") && stdout.contains("CVE-2024-0001"), - "the results table must print in hosted mode; got {stdout:?}" - ); - assert!( - stdout.contains("Summary: 1 package with 1 free patch"), - "the summary must print in hosted mode; got {stdout:?}" - ); - assert!( - stdout.contains("[UPDATE]") && stdout.contains("1 package has a newer patch available."), - "update detection must run in hosted mode; got {stdout:?}" - ); - assert!( - !stderr.contains("Non-interactive mode detected"), - "scan never prompts; got {stderr:?}" - ); - assert!( - stdout.contains("Switched 0 packages to hosted patches"), - "the engine must run; got {stdout:?}" - ); - let reqs = recorded(&mock).await; - assert_eq!( - reference_posts(&reqs), - 1, - "the engine resolved the reference" - ); - - // `--dry-run` previews without confirming (the engine honors it itself). - let (code, _stdout, stderr) = - run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted", "--dry-run"]); - assert_eq!(code, 0, "stderr={stderr}"); - assert!( - !stderr.contains("Non-interactive mode detected"), - "a hosted dry run never prompts; got {stderr:?}" - ); -} - -// --------------------------------------------------------------------------- -// Scan on a PTY (unix only) -// --------------------------------------------------------------------------- - -#[cfg(unix)] -mod pty { - use super::*; - use std::time::Duration; - - use portable_pty::{native_pty_system, PtySize}; - - /// Spawn the binary in a PTY, send `input`, and collect all output - /// until exit (the `interactive_prompts_e2e.rs` harness pattern: - /// reader thread + kill-after-timeout watchdog, no polling). - fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { - run_in_pty_with(args, cwd, &[], "", input, timeout) - } - - /// [`run_in_pty`] with extra child `env`, first writing `typeahead` - /// straight after spawn — keystrokes a user types while the scan is - /// still running, before any prompt is on screen. - fn run_in_pty_with( - args: &[&str], - cwd: &Path, - env: &[(&str, &str)], - typeahead: &str, - input: &str, - timeout: Duration, - ) -> (i32, String) { - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .expect("openpty"); - - // The hermetic environment (`tests/common/hermetic.rs`), which also - // forces the update notifier off: this child gets a REAL terminal, - // so the stderr-TTY guard does not protect it. - let mut cmd = common::hermetic::pty_binary_command(); - for a in args { - cmd.arg(a); - } - cmd.cwd(cwd); - // These tests assert VT rendering, even when the parent has TERM=dumb. - cmd.env("TERM", "xterm-256color"); - for (k, v) in env { - cmd.env(k, v); - } - - let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); - drop(pair.slave); - - let reader_handle = - crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); - - let mut killer = child.clone_killer(); - std::thread::spawn(move || { - std::thread::sleep(timeout); - let _ = killer.kill(); - }); - - let mut writer = pair.master.take_writer().expect("take writer"); - if !typeahead.is_empty() { - use std::io::Write as _; - let _ = writer.write_all(typeahead.as_bytes()); - let _ = writer.flush(); - } - crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); - drop(writer); - - let status = child.wait().expect("child.wait"); - drop(pair.master); - - let output = reader_handle.finish(); - ( - status.exit_code() as i32, - String::from_utf8_lossy(&output).to_string(), - ) - } - - /// v5 scan never prompts, even on a TTY: agent mode applies with no - /// input at all. - #[tokio::test(flavor = "multi_thread")] - async fn scan_on_a_tty_applies_without_prompting() { - let mock = MockServer::start().await; - let purl = "pkg:npm/silent-target@1.0.0"; - let before = b"before\n"; - mount_one_patch_api(&mock, purl, before).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "silent-target", "1.0.0", before); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty( - &[ - "scan", - "--mode", - "agent", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - "", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - - assert_eq!(code, 0, "output:\n{output}"); - assert!( - !output.contains("[Y/n]") && !output.contains("Download and apply"), - "scan must not prompt; got:\n{output}" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), - b"after\n", - "the apply must proceed unattended" - ); - } - - /// `scan --json` on a real terminal must never open the interactive - /// "Select one" menu over its machine-read output: with several free - /// patches for one package it picks the top-ranked one, like a non-TTY - /// run, and finishes on its own. - #[tokio::test(flavor = "multi_thread")] - async fn scan_json_on_a_tty_never_opens_the_select_menu() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - let second = "22222222-2222-4222-8222-222222222222"; - let body = serde_json::json!({ - "packages": [{ - "purl": purl, - "patches": [ - { "uuid": UUID, "purl": purl, "tier": "free", "cveIds": ["CVE-2024-1"], - "ghsaIds": [], "severity": "high", "title": "a" }, - { "uuid": second, "purl": purl, "tier": "free", "cveIds": ["CVE-2024-2"], - "ghsaIds": [], "severity": "high", "title": "b" }, - ] - }], - "canAccessPaidPatches": false, - }); - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(&mock) - .await; - let patch = |uuid: &str, published: &str| { - serde_json::json!({ - "uuid": uuid, "purl": purl, "publishedAt": published, - "description": "d", "license": "MIT", "tier": "free", - "vulnerabilities": {}, - }) - }; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [patch(UUID, "2024-01-01T00:00:00Z"), patch(second, "2025-01-01T00:00:00Z")], - "canAccessPaidPatches": false, - }))) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty( - &[ - "scan", - "--json", - "--mode", - "agent", - "--dry-run", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - "", - Duration::from_secs(30), - ) - }) - .await - .expect("spawn_blocking join"); - - assert!( - !output.contains("Select one") && !output.contains("Multiple patches available"), - "no interactive menu under --json; got:\n{output}" - ); - assert_eq!(code, 0, "the run must finish on its own; output:\n{output}"); - // The pty merges stdout and stderr; the envelope is the only `{…}`. - let text = output.replace("\r\n", "\n"); - let json_text = &text - [text.find('{').expect("JSON envelope")..=text.rfind('}').expect("JSON envelope end")]; - let json: serde_json::Value = serde_json::from_str(json_text) - .unwrap_or_else(|e| panic!("envelope must parse ({e}); got:\n{output}")); - assert_eq!(json["status"], "success", "{json}"); - let planned = json["events"] - .as_array() - .unwrap_or_else(|| panic!("events must be an array: {json}")); - assert_eq!(planned.len(), 1, "exactly one patch selected: {json}"); - assert_eq!( - planned[0]["uuid"], second, - "the top-ranked (newer) patch is picked, not the first listed: {json}" - ); - } - - /// The live status line on a real terminal: every progress message is - /// replaced (never overwritten in place), so what the user sees is the - /// result lines only — no "Found 1 patch for 1 packagesatch 1/1)" - /// residue from the longer "Querying API ... (batch 1/1)" line. - #[tokio::test(flavor = "multi_thread")] - async fn scan_progress_on_a_tty_renders_without_stale_tail() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - "n\n", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - assert_eq!(code, 0, "output:\n{output}"); - - // The raw stream really used the live line (so this test would - // catch a regression to bare `\r` rewrites)... - assert!( - output.contains("\r\x1b[2KQuerying API for patches... (batch 1/1)"), - "expected a live status update; raw={output:?}" - ); - // ...and the screen shows only clean result lines. - let screen = crate::pty_io::render(output.as_bytes()); - assert!( - screen.iter().any(|l| l == "Found 1 package (1 npm)"), - "screen:\n{}", - screen.join("\n") - ); - assert!( - screen.iter().any(|l| l == "Found 1 patch for 1 package"), - "screen:\n{}", - screen.join("\n") - ); - for transient in [ - "Scanning packages", - "Querying API", - "Fetching patch details", - ] { - assert!( - !screen.iter().any(|l| l.contains(transient)), - "transient status {transient:?} must not stay on screen:\n{}", - screen.join("\n") - ); - } - } - - /// Under `SOCKET_DEBUG` core prints `[socket-patch debug] ...` lines - /// straight to stderr. The live status line is off then, so those - /// lines never land glued onto the end of a progress message. - #[tokio::test(flavor = "multi_thread")] - async fn scan_debug_mode_on_a_tty_keeps_debug_lines_off_the_status() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - - let uri = mock.uri(); - let cwd = tmp.path().to_path_buf(); - let (code, output) = tokio::task::spawn_blocking(move || { - run_in_pty_with( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], - &cwd, - &[("SOCKET_DEBUG", "1")], - "", - "n\n", - Duration::from_secs(60), - ) - }) - .await - .expect("spawn_blocking join"); - assert_eq!(code, 0, "output:\n{output}"); - - let screen = crate::pty_io::render(output.as_bytes()); - let debug: Vec<&String> = screen - .iter() - .filter(|l| l.contains("[socket-patch debug]")) - .collect(); - assert!( - !debug.is_empty(), - "SOCKET_DEBUG must produce debug lines; screen:\n{}", - screen.join("\n") - ); - for line in debug { - assert!( - line.starts_with("[socket-patch debug]"), - "a debug line was glued onto other output: {line:?}" - ); - } - assert!( - !output.contains("Querying API for patches..."), - "no transient status is drawn in debug mode; raw={output:?}" - ); - assert!( - screen.iter().any(|l| l == "Found 1 patch for 1 package"), - "the result lines still print; screen:\n{}", - screen.join("\n") - ); - } -} - -// --------------------------------------------------------------------------- -// Invalid binary lockfiles must report a format error in every scan mode. -// --------------------------------------------------------------------------- - -fn write_invalid_bun_lockb_project(root: &Path) { - std::fs::write( - root.join("package.json"), - r#"{ "name": "covgap-lockb-only", "version": "0.0.0", "dependencies": { "minimist": "1.2.2" } }"#, - ) - .unwrap(); - std::fs::write(root.join("bun.lockb"), b"\0binary bun lockfile").unwrap(); -} - -fn bun_lockb_warning(v: &serde_json::Value) -> Option<&serde_json::Value> { - v["warnings"] - .as_array() - .and_then(|ws| ws.iter().find(|w| w["code"] == "bun_lockb_invalid")) -} - -/// A corrupt binary lock must remain visible as a format error on the -/// zero-package path, including when hosted mode has nothing to redirect. -#[test] -fn scan_invalid_bun_lockb_warns_instead_of_silent_success() { - for mode in [None, Some("vendored"), Some("hosted")] { - let tmp = tempfile::tempdir().unwrap(); - write_invalid_bun_lockb_project(tmp.path()); - let mut args = vec!["--json"]; - if let Some(mode) = mode { - args.extend(["--mode", mode]); - } - let (code, stdout, stderr) = run_scan(tmp.path(), &args); - assert_eq!( - code, 0, - "mode={mode:?}: refusals never flip the exit; stdout={stdout}; stderr={stderr}" - ); - let v: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); - assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); - assert_eq!(v["scannedPackages"], 0, "mode={mode:?}: {v}"); - assert_eq!(v["lockfileOnlyPackages"], 0, "mode={mode:?}: {v}"); - let warning = bun_lockb_warning(&v).unwrap_or_else(|| { - panic!("mode={mode:?}: warnings[] must carry bun_lockb_invalid: {v}") - }); - let detail = warning["detail"].as_str().unwrap_or_default(); - assert!( - detail.contains("cannot inventory bun.lockb"), - "mode={mode:?}: the binary format error must name its file: {detail}" - ); - assert!( - !stdout.contains("Warning:"), - "mode={mode:?}: the human warning line must not leak into the JSON stream: {stdout}" - ); - } - - // Human path: the same diagnosis as a stderr `Warning: detail` - // line, exit 0, and the generic "No packages found" hint still prints. - let tmp = tempfile::tempdir().unwrap(); - write_invalid_bun_lockb_project(tmp.path()); - let (code, stdout, stderr) = run_scan(tmp.path(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("Warning: cannot inventory bun.lockb"), - "the human path must name the layout and the code; got {stderr:?}" - ); - assert!( - stderr.contains("cannot inventory bun.lockb"), - "the human path must carry the remedy; got {stderr:?}" - ); - assert!(stdout.contains("No packages found"), "{stdout:?}"); -} - -/// Installed dependencies do not suppress binary format diagnoses in any -/// mode, including when hosted mode finds no patches to redirect. -#[tokio::test] -async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .mount(&mock) - .await; - - for mode in [None, Some("vendored"), Some("hosted")] { - let tmp = tempfile::tempdir().unwrap(); - write_invalid_bun_lockb_project(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); - let uri = mock.uri(); - let mut args = vec![ - "--json", - "--api-url", - uri.as_str(), - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ]; - if let Some(mode) = mode { - args.extend(["--mode", mode]); - } - let (code, stdout, stderr) = run_scan(tmp.path(), &args); - assert_eq!(code, 0, "mode={mode:?}: stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); - assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); - assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); - let warning = bun_lockb_warning(&v).unwrap_or_else(|| { - panic!("mode={mode:?}: the non-empty envelope must keep bun_lockb_invalid: {v}") - }); - assert!( - warning["detail"] - .as_str() - .is_some_and(|d| d.contains("cannot inventory bun.lockb")), - "mode={mode:?}: {v}" - ); - } - - // Human hosted path: the same warning line on stderr before the driver - // runs (exit 0; nothing to redirect). - let tmp = tempfile::tempdir().unwrap(); - write_invalid_bun_lockb_project(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("Warning: cannot inventory bun.lockb"), - "the human hosted path must keep the warning; got {stderr:?}" - ); -} - -// --------------------------------------------------------------------------- -// Human `--mode vendored --dry-run`: the `[would-refuse]` lines -// --------------------------------------------------------------------------- -// The contract promises the human vendored preview names what the wet run -// would refuse. Only `get --mode vendored --dry-run` printed the lines; a -// human `scan --mode vendored --dry-run` on a refused Bun project said -// "Would download and vendor 1 patch(es). No changes made." for a run that -// exits 1. Both commands now share the printer. - -/// A real bun 1.3.14 lockfileVersion-1 workspace lock resolving minimist -/// from the registry: the shape the vendored preflight refuses. -fn write_bun_v1_workspace_lock(root: &Path) { - std::fs::write( - root.join("bun.lock"), - "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"covgap-scan-root\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"minimist\": \"1.2.2\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"minimist\": [\"minimist@1.2.2\", \"\", {}, \"sha512-AAAA==\"],\n }\n}\n", - ) - .unwrap(); -} - -#[tokio::test] -async fn scan_human_vendored_dry_run_names_would_refuse_records() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - write_bun_v1_workspace_lock(tmp.path()); - - let (code, stdout, stderr) = run_scan_human( - tmp.path(), - &mock.uri(), - &["--mode", "vendored", "--dry-run"], - ); - assert_eq!( - code, 0, - "a preview never flips the exit; stdout={stdout}; stderr={stderr}" - ); - assert!( - stdout.contains("[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). No changes made."), - "the count line stays; got {stdout:?}" - ); - assert!( - stdout.contains(&format!( - " [would-refuse] {purl} (vendor_bun_workspace_unsupported): " - )), - "the human scan preview must name the refusal like get's does; got {stdout:?}" - ); - assert!( - stdout.contains("lockfileVersion-1 lock") && stdout.contains("--mode hosted"), - "the engine's detail rides along; got {stdout:?}" - ); - // Nothing written: no manifest, no blobs, no vendor ledger. (The dry-run - // return precedes the human arm's baseline pre-verify, so this preview - // fetches no patch view either; the on-disk state is the oracle here.) - assert!( - !tmp.path().join(".socket").exists(), - "a dry run writes nothing" - ); - - // `--silent`: informational, so nothing at all on stdout. - let (code, stdout, _) = run_scan_human( - tmp.path(), - &mock.uri(), - &["--mode", "vendored", "--dry-run", "--silent"], - ); - assert_eq!(code, 0); - assert!( - stdout.trim().is_empty(), - "silent dry run prints nothing:\n{stdout}" - ); -} - -// --------------------------------------------------------------------------- -// Human-path wording and flow fixes (terminal UI polish) -// --------------------------------------------------------------------------- - -/// Mount a batch endpoint that finds no patches at all. -async fn mount_empty_batch(mock: &MockServer) { - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .mount(mock) - .await; -} - -/// `scan --prune` with nothing to apply still garbage-collects, like the -/// JSON path, and a -/// `--dry-run` previews it without touching the manifest. -#[tokio::test] -async fn scan_human_prune_runs_gc_even_when_no_patches_are_available() { - let mock = MockServer::start().await; - mount_empty_batch(&mock).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.5", b"x\n"); - seed_manifest(tmp.path(), &[("pkg:npm/gone@1.0.0", OLD_UUID)]); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("[dry-run] GC would prune 1 manifest entry and remove 0 orphan files"), - "the dry run previews the GC; got {stdout:?}" - ); - let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); - assert!( - manifest.contains("pkg:npm/gone@1.0.0"), - "a preview must not prune" - ); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains("No patches available for installed packages."), - "{stdout:?}" - ); - assert!( - stdout.contains("GC: pruned 1 manifest entry and removed 0 orphan files"), - "the GC must run on the early exit; got {stdout:?}" - ); - let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); - assert!(!manifest.contains("pkg:npm/gone@1.0.0"), "{manifest}"); -} - -/// An empty crawl never prunes (too destructive), but says so instead of -/// silently dropping `--prune`; `--silent` keeps it quiet. -#[test] -fn scan_prune_on_empty_crawl_warns_the_gc_was_skipped() { - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune"]); - assert_eq!(code, 0, "stderr={stderr:?}"); - assert!( - stderr.contains("Warning: --prune skipped: no installed packages were found"), - "{stderr:?}" - ); - assert!(stdout.contains("No packages found."), "{stdout:?}"); - let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune", "--silent"]); - assert_eq!(code, 0); - assert!( - stdout.is_empty() && stderr.is_empty(), - "{stdout:?} {stderr:?}" - ); -} - -/// `--ecosystems` that filters everything out names the filter instead of -/// telling the user to run `cargo install`/`go install`. -#[test] -fn scan_empty_after_ecosystem_filter_names_the_filter() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.5", b"x\n"); - let (code, stdout, stderr) = run_scan(tmp.path(), &["-e", "pypi"]); - assert_eq!(code, 0, "stderr={stderr:?}"); - assert!(stdout.contains("No pypi packages found."), "{stdout:?}"); -} - -/// Global installs have no project lockfile: `--mode hosted --global` is -/// a usage error, not a silent "redirected 0 packages". -#[test] -fn scan_hosted_rejects_global() { - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--global"]); - assert_eq!(code, 2, "stderr={stderr:?}"); - assert!( - stderr.starts_with( - "Error: --global cannot be used with --mode hosted: global installs have no \ - project lockfile to redirect" - ), - "{stderr:?}" - ); - assert!(stdout.is_empty()); - // Under --json a self-enforced usage error prints the coded error on - // stdout (v5.0; clap's own parse errors still print nothing there). - let (code, stdout, _) = run_scan(tmp.path(), &["--mode", "hosted", "--global", "--json"]); - assert_eq!(code, 2); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("JSON on stdout"); - assert_eq!(v["status"], "error", "{v}"); - assert_eq!(v["error"]["code"], "global_scope_unsupported", "{v}"); - assert!(v["error"]["message"] - .as_str() - .unwrap() - .starts_with("--global cannot be used with --mode hosted")); - let prefix = tempfile::tempdir().unwrap(); - let (code, _, stderr) = run_scan( - tmp.path(), - &[ - "--mode", - "hosted", - "--global-prefix", - prefix.path().to_str().unwrap(), - ], - ); - assert_eq!(code, 2); - assert!( - stderr.starts_with("Error: --global-prefix cannot be used with --mode hosted"), - "{stderr:?}" - ); -} - -/// Usage errors from scan's own flag checks use the capitalized `Error:` -/// prefix like every other error line. -#[test] -fn scan_mode_conflict_error_is_capitalized_and_names_no_hidden_flag() { - let tmp = tempfile::tempdir().unwrap(); - let (code, _, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--sync"]); - assert_eq!(code, 2); - assert!( - stderr.starts_with("Error: --mode hosted cannot be used with --sync"), - "{stderr:?}" - ); - assert!(!stderr.contains("--redirect"), "{stderr:?}"); -} - -/// Mount batch, by-package and view endpoints for two patched packages -/// (each `index.js` goes from `before\n` to `after\n`). -async fn mount_two_patch_api(mock: &MockServer, pkgs: &[(&str, &str)], before: &[u8]) { - let patches = |purl: &str, uuid: &str| { - serde_json::json!({ - "purl": purl, - "patches": [{ - "uuid": uuid, "purl": purl, "tier": "free", - "cveIds": [], "ghsaIds": [], "severity": "high", - "title": "covgap test patch" - }] - }) - }; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": pkgs.iter().map(|(p, u)| patches(p, u)).collect::>(), - "canAccessPaidPatches": false, - }))) - .mount(mock) - .await; - for (purl, uuid) in pkgs { - mount_by_package(mock, purl, uuid, serde_json::json!({})).await; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "uuid": uuid, - "purl": purl, - "publishedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": git_sha256(before), - "afterHash": git_sha256(b"after\n"), - "blobContent": "YWZ0ZXIK", - } - }, - "vulnerabilities": {}, - "description": "Covgap test patch", - "license": "MIT", - "tier": "free", - }))) - .mount(mock) - .await; - } -} - -/// #732: a human-output `scan --mode agent` / `scan --sync` re-applies a -/// patch the manifest already records once a reinstall has put the -/// pristine bytes back (the `--json` path has done so since #454). -#[tokio::test] -async fn scan_human_reapplies_an_already_recorded_patch_after_reinstall() { - for flags in [&["--mode", "agent"][..], &["--sync"][..]] { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - let before = b"before\n"; - mount_one_patch_api(&mock, purl, before).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", before); - let index = tmp.path().join("node_modules/minimist/index.js"); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); - assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); - assert_eq!( - std::fs::read(&index).unwrap(), - b"after\n", - "flags={flags:?}" - ); - - // `npm ci` / `rm -rf node_modules && npm install`. - std::fs::write(&index, before).unwrap(); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); - assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); - assert_eq!( - std::fs::read(&index).unwrap(), - b"after\n", - "flags={flags:?}: the recorded patch must be re-applied; stdout={stdout}; stderr={stderr}" - ); - assert!( - stdout.contains(&format!("[re-apply] {purl} (already recorded: 11111111)")), - "flags={flags:?}: {stdout:?}" - ); - assert!( - !stdout.contains("run `socket-patch apply` to re-apply them"), - "flags={flags:?}: {stdout:?}" - ); - let manifest = - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); - let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); - assert_eq!(v["patches"][purl]["uuid"], UUID, "flags={flags:?}: {v}"); - } -} - -/// #732: when a run also has a new patch, the recorded-but-reverted one -/// is re-applied alongside it rather than dropped from the selection. -#[tokio::test] -async fn scan_human_reapplies_recorded_patch_alongside_a_new_one() { - const UUID_B: &str = "22222222-2222-4222-8222-222222222222"; - let a = "pkg:npm/minimist@1.2.2"; - let b = "pkg:npm/left-pad@1.3.0"; - let before = b"before\n"; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", before); - write_npm_package(tmp.path(), "left-pad", "1.3.0", before); - let index_a = tmp.path().join("node_modules/minimist/index.js"); - let index_b = tmp.path().join("node_modules/left-pad/index.js"); - - // First run: only `a` has a patch. - let first = MockServer::start().await; - mount_two_patch_api(&first, &[(a, UUID)], before).await; - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &first.uri(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert_eq!(std::fs::read(&index_a).unwrap(), b"after\n"); - assert_eq!(std::fs::read(&index_b).unwrap(), before); - - // Reinstall, then a patch for `b` is published. - std::fs::write(&index_a, before).unwrap(); - let second = MockServer::start().await; - mount_two_patch_api(&second, &[(a, UUID), (b, UUID_B)], before).await; - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &second.uri(), &[]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert_eq!( - std::fs::read(&index_b).unwrap(), - b"after\n", - "new patch applied; stdout={stdout}; stderr={stderr}" - ); - assert_eq!( - std::fs::read(&index_a).unwrap(), - b"after\n", - "recorded patch re-applied; stdout={stdout}; stderr={stderr}" - ); - assert!(stdout.contains("Patches to apply:"), "{stdout:?}"); - assert!( - stdout.contains(&format!("[re-apply] {a} (already recorded: 11111111)")), - "{stdout:?}" - ); -} - -/// `--dry-run` stays a non-mutating preview: an already-recorded -/// selection is neither downloaded nor re-applied, and the message says a -/// wet run re-applies it. -#[tokio::test] -async fn scan_human_dry_run_previews_reapply_of_a_recorded_patch() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - seed_manifest(tmp.path(), &[(purl, UUID)]); - let manifest_before = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains(&format!("[skip] {purl} (already recorded: 11111111)")), - "{stdout:?}" - ); - assert!( - stdout.contains("a run without --dry-run re-applies them"), - "{stdout:?}" - ); - assert!(!stdout.contains("Patches to apply:"), "{stdout:?}"); - assert_eq!( - view_gets(&recorded(&mock).await), - 0, - "nothing is downloaded" - ); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), - b"x\n" - ); - assert_eq!( - std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), - manifest_before - ); -} - -/// A report-only `scan --prune --dry-run` never applies, so it must not say that -/// dropping `--dry-run` re-applies a recorded patch; it points at -/// `socket-patch apply` instead. -#[tokio::test] -async fn scan_report_only_dry_run_points_recorded_patch_at_apply() { - let mock = MockServer::start().await; - let purl = "pkg:npm/minimist@1.2.2"; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - seed_manifest(tmp.path(), &[(purl, UUID)]); - - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stdout.contains(&format!("[skip] {purl} (already recorded: 11111111)")), - "{stdout:?}" - ); - assert!( - !stdout.contains("a run without --dry-run re-applies them"), - "{stdout:?}" - ); - assert!(!stdout.contains("[re-apply]"), "{stdout:?}"); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), - b"x\n" - ); -} - -/// The human table's PACKAGE column grows to fit the PURL (a fixed-width -/// cut would drop the version), and the rule matches the table. -#[tokio::test] -async fn scan_human_table_shows_full_purl_with_version() { - let mock = MockServer::start().await; - let purl = "pkg:npm/@typescript-eslint/typescript-estree@6.0.0"; - mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-1"], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - let pkg_dir = tmp - .path() - .join("node_modules/@typescript-eslint/typescript-estree"); - std::fs::create_dir_all(&pkg_dir).unwrap(); - std::fs::write( - pkg_dir.join("package.json"), - r#"{ "name": "@typescript-eslint/typescript-estree", "version": "6.0.0" }"#, - ) - .unwrap(); - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let row = stdout - .lines() - .find(|l| l.contains("CVE-2024-1") && l.starts_with("pkg:npm/")) - .unwrap_or_else(|| panic!("no table row in {stdout:?}")); - assert!(row.starts_with(&format!("{purl} ")), "{row:?}"); - let header = stdout.lines().find(|l| l.starts_with("PACKAGE")).unwrap(); - // The right-aligned count ends where the PATCHES header ends. - assert_eq!( - header.find("PATCHES").map(|i| i + "PATCHES".len()), - row.find(" 1 ").map(|i| i + 2), - "PATCHES header sits over the count: {stdout}" - ); - // The rule is exactly as wide as the widest table line. - let rule = stdout.lines().find(|l| l.starts_with("===")).unwrap(); - assert_eq!(rule.len(), header.len().max(row.len()), "{stdout}"); -} - -// --------------------------------------------------------------------------- -// vlt vendored ledgers: the GC probe and the degraded-ledger `~` boundary -// --------------------------------------------------------------------------- - -/// `scan --mode vendored --prune`: the lock-driven GC keeps the vlt entry -/// `vlt-lock.json` still resolves to its dir (the in-use verdict is -/// structural: a `file` node under the uuid) and reclaims one it does not. -#[tokio::test] -async fn scan_prune_keeps_a_wired_vlt_uuid_and_sweeps_an_unwired_one() { - use vlt_hosted_common as hosted; - let server = MockServer::start().await; - hosted::mock_all(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - vlt_vendored::vendored_project(root, false); - let unwired = "22222222-2222-4222-8222-222222222222"; - let other = "pkg:npm/right-pad@1.0.0"; - let other_rel = format!(".socket/vendor/npm/{unwired}/right-pad-1.0.0/node_modules/right-pad"); - std::fs::create_dir_all(root.join(&other_rel)).unwrap(); - std::fs::write(root.join(&other_rel).join("index.js"), "x\n").unwrap(); - let mut state = vlt_vendored::state(root); - let mut entry = state["entries"][hosted::PURL].clone(); - entry["basePurl"] = serde_json::json!(other); - entry["uuid"] = serde_json::json!(unwired); - entry["artifact"]["path"] = serde_json::json!(other_rel); - entry["artifact"]["fileInventory"] = serde_json::json!({ "index.js": "0" }); - entry["wiring"] = serde_json::json!([]); - state["entries"][other] = entry; - std::fs::write( - root.join(".socket/vendor/state.json"), - serde_json::to_vec_pretty(&state).unwrap(), - ) - .unwrap(); - - let cwd = root.to_str().unwrap().to_string(); - let uri = server.uri(); - let (code, env, stderr) = hosted::run_json( - root, - &[ - "scan", - "--mode", - "vendored", - "--prune", - "--yes", - "--cwd", - &cwd, - "--api-url", - &uri, - "--org", - hosted::ORG, - "--api-token", - "fake", - ], - &[], - ); - assert!(code == 0 || code == 1, "{env:#}\n{stderr}"); - let after = vlt_vendored::state(root); - assert!( - after["entries"][hosted::PURL].is_object(), - "the wired entry stays: {after:#}\n{env:#}" - ); - assert!(root.join(vlt_vendored::rel()).join("index.js").is_file()); - assert!( - after["entries"].get(other).is_none(), - "the unwired entry is reclaimed: {after:#}\n{env:#}" - ); - assert!( - !root.join(format!(".socket/vendor/npm/{unwired}")).exists(), - "{env:#}" - ); -} - -/// A pre-v5 redirect ledger holding only a vlt node edit keyed at a peer -/// variant of the vendored package's DepID is IGNORED by a vendored scan: -/// v5 has no `vendor_supersedes_redirect` reconciliation (once the lock -/// routes a package to `.socket/vendor/`, no hosted state is left), so no -/// warning fires at either side of the `~` boundary and the legacy file is -/// left byte-identical. -#[tokio::test] -async fn scan_vendored_ignores_a_degraded_pre_v5_vlt_ledger_edit() { - use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; - use vlt_hosted_common as hosted; - for key in ["left-pad@1.3.0~peer.2", "left-pad@1.3.00~peer.2"] { - let server = MockServer::start().await; - hosted::mock_all(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - vlt_vendored::vendored_project(root, false); - let mut ledger = RedirectState::new(); - ledger.edits = vec![FileEdit { - path: "vlt-lock.json".to_string(), - kind: socket_patch_core::patch::redirect::vlt::KIND.to_string(), - action: "modified".to_string(), - key: Some(key.to_string()), - original: None, - new: None, - }]; - let bytes = serde_json::to_string_pretty(&ledger).unwrap(); - std::fs::write(hosted::ledger_path(root), &bytes).unwrap(); - let cwd = root.to_str().unwrap().to_string(); - let uri = server.uri(); - let (code, env, stderr) = hosted::run_json( - root, - &[ - "scan", - "--mode", - "vendored", - "--yes", - "--cwd", - &cwd, - "--api-url", - &uri, - "--org", - hosted::ORG, - "--api-token", - "fake", - ], - &[], - ); - assert_eq!(code, 0, "{key}: {env:#}\n{stderr}"); - let text = env.to_string(); - assert!( - !text.contains("vendor_supersedes_redirect"), - "{key}: the v5 vendored scan never reconciles a hosted ledger: {env:#}" - ); - assert_eq!( - std::fs::read_to_string(hosted::ledger_path(root)).unwrap(), - bytes, - "{key}: the pre-v5 ledger is left byte-identical: {env:#}" - ); - } -} - -/// `scan --mode agent --json` whose nested apply fails (the installed copy -/// is a symlink to a first-party `packages/` directory, which apply refuses -/// to patch): the envelope must carry the per-patch failure — a `failed` -/// event with `errorCode` and `error` — and count it in `summary.failed`, -/// not report the patch as cleanly applied (#424). -#[cfg(unix)] -#[tokio::test] -async fn scan_agent_json_nested_apply_failure_reaches_the_apply_block() { - let mock = MockServer::start().await; - let purl = "pkg:npm/apply-fails@1.0.0"; - mount_one_patch_api(&mock, purl, b"before\n").await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "apply-fails", "1.0.0", b"before\n"); - let member = tmp.path().join("packages/apply-fails"); - std::fs::create_dir_all(member.parent().unwrap()).unwrap(); - std::fs::rename(tmp.path().join("node_modules/apply-fails"), &member).unwrap(); - std::os::unix::fs::symlink( - "../packages/apply-fails", - tmp.path().join("node_modules/apply-fails"), - ) - .unwrap(); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); - assert_eq!(v["status"], "partialFailure", "{v}"); - assert_eq!( - v["summary"]["failed"], 1, - "the apply failure must be counted: {v}" - ); - assert_eq!(v["summary"]["applied"], 0, "{v}"); - let rec = common::envelope::find_event(&v, "failed", None); - assert_eq!(rec["purl"], purl, "{v}"); - assert_eq!(rec["action"], "failed", "{v}"); - assert_eq!(rec["errorCode"], "apply_failed", "{v}"); - assert!( - rec["error"].as_str().is_some_and(|e| !e.is_empty()), - "the apply error text must reach the envelope: {v}" - ); - assert_eq!(std::fs::read(member.join("index.js")).unwrap(), b"before\n",); -} - -/// `scan --mode agent --json` over an installed file a local edit changed -/// (neither beforeHash nor afterHash): the default policy overwrites it, -/// and the envelope's `warnings[]` must report that overwrite — the -/// same `content_mismatch_overwritten` warning `apply --json` and the human -/// scan print — instead of dropping it (#1004). -#[tokio::test] -async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { - let mock = MockServer::start().await; - let purl = "pkg:npm/locally-edited@1.0.0"; - mount_one_patch_api(&mock, purl, b"before\n").await; - - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package( - tmp.path(), - "locally-edited", - "1.0.0", - b"before\n// local edit\n", - ); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); - assert_eq!(v["summary"]["applied"], 1, "{v}"); - let warned = v["warnings"].as_array().is_some_and(|ws| { - ws.iter().any(|w| { - let d = w["detail"].as_str().unwrap_or_default(); - w["code"] == "content_mismatch_overwritten" - && d.contains(purl) - && d.contains("package/index.js") - }) - }); - assert!(warned, "the overwrite must be reported: {v}"); - assert_eq!( - std::fs::read(tmp.path().join("node_modules/locally-edited/index.js")).unwrap(), - b"after\n" - ); -} - -// --------------------------------------------------------------------------- -// v5.0 envelope: `scan --json` prints one shared Envelope -// --------------------------------------------------------------------------- - -/// A wet agent-mode `scan --json`: one envelope (`command: "scan"`), the -/// download as a `downloaded` event carrying the patch metadata, the nested -/// apply as an `applied` event, `summary` equal to the event counts, the -/// discovery payload beside them — and none of the retired keys. -#[tokio::test] -async fn scan_agent_json_is_one_envelope_with_events() { - let purl = "pkg:npm/minimist@1.2.2"; - let mock = MockServer::start().await; - mount_one_patch_api(&mock, purl, b"before\n").await; - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["status"], "success", "{v:#}"); - assert_eq!(v["dryRun"], false); - assert_eq!( - common::envelope::event_triples(&v), - vec![ - (purl.to_string(), "downloaded".to_string(), String::new()), - (purl.to_string(), "applied".to_string(), String::new()), - ], - "{v:#}" - ); - let downloaded = common::envelope::find_event(&v, "downloaded", None); - assert_eq!(downloaded["uuid"], UUID); - assert_eq!(downloaded["details"]["tier"], "free", "{v:#}"); - assert!( - downloaded["details"].get("mode").is_none(), - "agent events carry no mode" - ); - assert_eq!(v["scannedPackages"], 1); - assert_eq!(v["packages"][0]["purl"], purl); - for retired in [ - "apply", - "totalPatches", - "freePatches", - "paidPatches", - "packagesWithPatches", - "patches", - "found", - ] { - assert!(v.get(retired).is_none(), "retired key {retired}: {v:#}"); - } -} - -/// An agent-mode dry run previews without writing: a `verified` event per -/// patch a wet run would record, top-level `dryRun`, no nested one. -#[tokio::test] -async fn scan_agent_json_dry_run_previews_verified_events() { - let purl = "pkg:npm/minimist@1.2.2"; - let mock = MockServer::start().await; - mount_one_patch_api(&mock, purl, b"before\n").await; - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); - seed_manifest(tmp.path(), &[(purl, OLD_UUID)]); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["dryRun"], true); - let e = common::envelope::find_event(&v, "verified", None); - assert_eq!(e["purl"], purl); - assert_eq!(e["uuid"], UUID); - assert_eq!( - e["oldUuid"], OLD_UUID, - "a would-be update names the uuid it replaces" - ); - assert_eq!(v["summary"]["verified"], 1); -} - -/// Agent mode with the apply lock held: the engine's hard error is the -/// run's ONE JSON document (v5.0 fixes a run that printed the engine's -/// error and then scan's own result). -#[tokio::test] -async fn scan_agent_json_lock_held_prints_one_document() { - let purl = "pkg:npm/minimist@1.2.2"; - let mock = MockServer::start().await; - mount_one_patch_api(&mock, purl, b"before\n").await; - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let _lock = - socket_patch_core::patch::apply_lock::acquire(&socket, std::time::Duration::ZERO).unwrap(); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be ONE JSON document ({e}): {stdout}")); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["status"], "error"); - assert_eq!(v["error"]["code"], "lock_held", "{v:#}"); -} - -/// Agent mode over a manifest that exists but does not parse fails closed -/// before any query, in both outputs: `manifest_invalid` (the shared code), -/// exit 1; report-only mode warns with the same code and goes on. -#[tokio::test] -async fn scan_unparseable_manifest_fails_agent_mode_and_warns_otherwise() { - let purl = "pkg:npm/minimist@1.2.2"; - let mock = MockServer::start().await; - mount_one_patch_api(&mock, purl, b"before\n").await; - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); - std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); - std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); - - let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["error"]["code"], "manifest_invalid", "{v:#}"); - assert_eq!(v["events"], serde_json::json!([])); - let (code, _, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); - assert_eq!(code, 1, "the human arm fails alike: {stderr}"); - - let (code, stdout, stderr) = - run_scan_human(tmp.path(), &mock.uri(), &["--prune", "--dry-run", "--json"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert!( - common::envelope::warning_codes(&v).contains(&"manifest_invalid".to_string()), - "{v:#}" - ); -} - -/// `--offline` and usage errors print the full envelope too. -#[test] -fn scan_json_early_errors_print_full_envelopes() { - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, _) = run_scan(tmp.path(), &["--offline", "--json"]); - assert_eq!(code, 1); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["error"]["code"], "offline_unsupported"); - assert_eq!(v["summary"]["applied"], 0); - - let (code, stdout, _) = run_scan(tmp.path(), &["--sync", "--mode", "hosted", "--json"]); - assert_eq!(code, 2); - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["error"]["code"], "invalid_args"); -} - -// --------------------------------------------------------------------------- -// #1062: human / JSON parity when every detail query succeeds but is empty -// --------------------------------------------------------------------------- - -/// Mount a by-package response that succeeds with NO patch records (the -/// batch said the package has one; the detail query found none to offer). -async fn mount_by_package_empty(mock: &MockServer, purl: &str) { - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [], - "canAccessPaidPatches": false, - }))) - .mount(mock) - .await; -} - -/// #1062: when every detail query succeeds but returns no records, scan -/// has nothing to select. That is a successful run with no applicable -/// patches in every mode — a successful empty answer is not a failure — -/// so the human arm exits like the `--json` arm (0), and neither reports a -/// fetch failure. -#[tokio::test] -async fn scan_empty_detail_results_exit_alike_in_human_and_json() { - let purl = "pkg:npm/minimist@1.2.2"; - // `--prune` alone is report-only (no mode); `--dry-run` keeps every - // mode read-only. - let modes: [&[&str]; 4] = [ - &["--mode", "agent"], - &["--mode", "vendored"], - &["--mode", "hosted"], - &["--prune"], - ]; - for mode in modes { - for dry in [false, true] { - let mock = MockServer::start().await; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package_empty(&mock, purl).await; - - let mut codes = Vec::new(); - for json in [false, true] { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let mut extra: Vec<&str> = mode.to_vec(); - if dry { - extra.push("--dry-run"); - } - if json { - extra.push("--json"); - } - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); - assert!( - !stderr.contains("could not fetch patch details"), - "{extra:?}: no query failed, so no fetch failure; stderr={stderr}" - ); - if json { - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_ne!(v["status"], "error", "{extra:?}: {v}"); - assert_eq!(v["events"], serde_json::json!([]), "{extra:?}: {v}"); - } - codes.push((code, stdout, stderr)); - } - let (human, json) = (&codes[0], &codes[1]); - assert_eq!( - human.0, json.0, - "{mode:?} dry={dry}: human and --json exit alike\n\ - human stdout={}\nhuman stderr={}\njson stdout={}\njson stderr={}", - human.1, human.2, json.1, json.2 - ); - assert_eq!(human.0, 0, "{mode:?} dry={dry}: nothing to do is a success"); - } - } -} - -/// #1062: report-only `scan --prune` fetches the detail records in both -/// outputs whenever a patch is downloadable, so every detail query failing -/// fails both alike (exit 1; the JSON arm used to skip the fetch and exit -/// 0). -#[tokio::test] -async fn scan_report_only_detail_failure_exits_alike_in_human_and_json() { - let purl = "pkg:npm/minimist@1.2.2"; - let mock = MockServer::start().await; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(purl) - ))) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - let mut codes = Vec::new(); - for json in [false, true] { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - let mut extra = vec!["--prune", "--dry-run"]; - if json { - extra.push("--json"); - } - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); - if json { - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - assert_eq!(v["error"]["code"], "patch_details_failed", "{v:#}"); - } - codes.push((code, stderr)); - } - assert_eq!( - codes[0].0, codes[1].0, - "human and --json exit alike: {codes:?}" - ); - assert_eq!( - codes[0].0, 1, - "every detail query failing is a failure: {codes:?}" - ); -} - -/// #1062: a `--dry-run --prune` previews the GC in both outputs, in agent, -/// report-only and vendored mode alike: the human `[dry-run] GC would -/// prune …` line once, the JSON `verified` event (`details.manifest`) and -/// `gc`; nothing is written. -#[tokio::test] -async fn scan_dry_run_prune_previews_gc_in_human_and_json() { - let purl = "pkg:npm/minimist@1.2.2"; - let stale = "pkg:npm/left-pad@1.3.0"; - for mode in [ - &["--mode", "agent"][..], - &[][..], - &["--mode", "vendored"][..], - ] { - let mock = MockServer::start().await; - mount_batch_one(&mock, purl, UUID, "free", &[], false).await; - mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; - for json in [false, true] { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - // A recorded patch for a package that is not installed: the - // GC would prune its entry. - seed_manifest(tmp.path(), &[(stale, OLD_UUID)]); - let manifest = tmp.path().join(".socket/manifest.json"); - let before = std::fs::read(&manifest).unwrap(); - let mut extra: Vec<&str> = mode.to_vec(); - extra.extend(["--prune", "--dry-run", "--yes"]); - if json { - extra.push("--json"); - } - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); - assert_eq!(code, 0, "{extra:?}: stdout={stdout}; stderr={stderr}"); - if json { - let v = common::parse_json_envelope(&stdout); - common::envelope::assert_envelope_invariants(&v, "scan"); - let pruned = common::envelope::events(&v) - .iter() - .find(|e| e["purl"] == stale) - .unwrap_or_else(|| panic!("{extra:?}: no prune preview: {v:#}")); - assert_eq!(pruned["action"], "verified", "{v:#}"); - assert_eq!(pruned["details"]["manifest"], true, "{v:#}"); - assert!(v["gc"].is_object(), "{extra:?}: {v:#}"); - } else { - assert_eq!( - stdout - .matches("[dry-run] GC would prune 1 manifest entry") - .count(), - 1, - "{extra:?}: the human dry run previews the GC once; stdout={stdout}" - ); - } - assert_eq!( - std::fs::read(&manifest).unwrap(), - before, - "{extra:?}: a dry run writes nothing" - ); - } - } -} - -/// #1062: when some detail queries fail and the rest succeed with no -/// records, the human run still warns per failed package (the `--json` -/// arm adds a `patch_details_failed` warning each) and exits like it. -#[tokio::test] -async fn scan_partial_failure_with_empty_results_still_warns() { - let empty = "pkg:npm/minimist@1.2.2"; - let bad = "pkg:npm/lodash@4.17.20"; - let mock = MockServer::start().await; - let patch = |purl: &str| { - serde_json::json!({ - "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [], - "ghsaIds": [], "severity": "high", "title": "t" - }) - }; - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [ - {"purl": empty, "patches": [patch(empty)]}, - {"purl": bad, "patches": [patch(bad)]}, - ], - "canAccessPaidPatches": false, - }))) - .mount(&mock) - .await; - mount_by_package_empty(&mock, empty).await; - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", - encode_purl(bad) - ))) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - - let mut codes = Vec::new(); - for json in [false, true] { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); - let mut extra = vec!["--mode", "agent", "--dry-run"]; - if json { - extra.push("--json"); - } - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); - if json { - let v = common::parse_json_envelope(&stdout); - assert!( - common::envelope::warning_codes(&v).contains(&"patch_details_failed".to_string()), - "{v}" - ); - } else { - assert!( - stderr.contains(&format!("Warning: could not fetch details for {bad}")), - "the failed package is named; stderr={stderr}" - ); - } - codes.push(code); - } - assert_eq!(codes[0], codes[1], "human and --json exit alike"); -} diff --git a/crates/socket-patch-cli/tests/scan/main.rs b/crates/socket-patch-cli/tests/scan/main.rs index b9d4e031b..ca506dfd5 100644 --- a/crates/socket-patch-cli/tests/scan/main.rs +++ b/crates/socket-patch-cli/tests/scan/main.rs @@ -4,6 +4,8 @@ #[path = "../common/mod.rs"] mod common; +#[path = "../common/pty_io.rs"] +mod pty_io; #[path = "../vlt_hosted_common/mod.rs"] mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] @@ -20,9 +22,14 @@ mod hosted_yarn_berry_manifest; mod hosted_yarn_classic_sha1; mod scan_batch_sizing_e2e; mod scan_ecosystems_scope_e2e; +mod scan_failure_paths; +mod scan_fixture; mod scan_invariants; mod scan_nested_project_e2e; mod scan_ordered_concurrency_e2e; mod scan_paths_e2e; +mod scan_render; mod scan_sync_e2e; +#[cfg(unix)] +mod scan_tty; mod scan_vendor_step_error_e2e; diff --git a/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs new file mode 100644 index 000000000..ff0c38642 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs @@ -0,0 +1,923 @@ +//! `scan`'s `--json` contracts for the paths the rest of the suite never +//! drove (#1089): exit code, `status`, `error.code`, `events[]` actions and +//! `errorCode`s, `warnings[]` codes, the mock API's request log and the +//! bytes on disk. A message is read only for what it must name (a flag, a +//! purl, a file). The human output of the same paths is pinned in +//! `scan_render.rs`. +//! +//! 1. `resolve_mode_flags`' cross-mode conflicts and the early errors; +//! 2. hosted `--prune` and the paid-tier counters; +//! 3. detail-fetch failures (total and partial) and empty detail answers; +//! 4. vendor-owned purls, report-only runs and the `--sync` GC; +//! 5. the agent-mode envelope: events, dry runs, nested apply failures, +//! mismatch and retained-wiring warnings, the lock and the manifest; +//! 6. corrupt `bun.lockb` inventories; +//! 7. vlt vendored ledgers. + +use std::path::Path; + +use wiremock::MockServer; + +use crate::common::envelope::{ + assert_envelope_invariants, envelope_error_code, envelope_error_message, event_purls, + event_triples, events, find_event, parse_json_envelope, warning_codes, +}; +use crate::scan_fixture::*; +use crate::{vlt_hosted_common as hosted, vlt_vendored}; + +/// The first `warnings[]` entry with `code`; panics with the envelope when +/// there is none. +fn warning<'a>(v: &'a serde_json::Value, code: &str) -> &'a serde_json::Value { + v["warnings"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["code"] == code)) + .unwrap_or_else(|| panic!("warnings[] must carry {code}: {v:#}")) +} + +fn warning_detail<'a>(v: &'a serde_json::Value, code: &str) -> &'a str { + warning(v, code)["detail"].as_str().unwrap_or_default() +} + +/// `scan --json` against the mock API; returns the exit code and the parsed +/// envelope (after checking the shared envelope invariants). +fn run_json(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, serde_json::Value) { + let mut args = vec!["--json"]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_scan_api(cwd, api_url, &args); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "scan"); + assert!( + !stdout.contains("Warning:"), + "human warning lines must not leak into the JSON stream: {stdout}\nstderr={stderr}" + ); + (code, v) +} + +// --------------------------------------------------------------------------- +// resolve_mode_flags: the `--sync` cross-mode conflict +// --------------------------------------------------------------------------- +// `--sync` means `--mode agent --prune`, so it contradicts any other +// `--mode`. Clap parses each combination fine (no value-dependent conflict +// is expressible); the fold is what rejects them, naming both flags. + +mod mode_fold { + use clap::Parser; + use socket_patch_cli::commands::scan::{resolve_mode_flags, ScanArgs}; + use socket_patch_cli::{Cli, Commands}; + + /// Every env var that could reroute the parse or the fold (superset: + /// the shared scrub lists from `args.rs`). + fn with_clean_env(f: impl FnOnce() -> T) -> T { + let vars: Vec<&str> = socket_patch_cli::args::GLOBAL_ARG_ENV_VARS + .iter() + .chain(socket_patch_cli::args::LOCAL_ARG_ENV_VARS.iter()) + .copied() + .collect(); + let saved: Vec<(&str, Option)> = + vars.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + for k in &vars { + std::env::remove_var(k); + } + let result = f(); + for (k, orig) in saved { + match orig { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + result + } + + fn fold_err(extra: &[&str]) -> String { + let mut argv = vec!["socket-patch", "scan"]; + argv.extend_from_slice(extra); + let cli = with_clean_env(|| Cli::try_parse_from(&argv)).expect("parse"); + let mut args: ScanArgs = match cli.command { + Commands::Scan(a) => a, + _ => panic!("expected Scan"), + }; + resolve_mode_flags(&mut args).expect_err("cross-mode contradiction must error") + } + + /// Each `--mode` against `--sync` errors, naming the mode through + /// `cli_name()` and the conflicting boolean. + #[test] + #[serial_test::serial] + fn mode_with_sync_boolean_errors_naming_both_flags() { + for mode in ["hosted", "vendored"] { + let err = fold_err(&["--mode", mode, "--sync"]); + assert!( + err.contains(&format!("--mode {mode}")) && err.contains("--sync"), + "the error must name --mode {mode} and --sync: {err}" + ); + } + } +} + +/// `--offline` and usage errors print the full envelope too. +#[test] +fn scan_json_early_errors_print_full_envelopes() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _) = run_scan(tmp.path(), &["--offline", "--json"]); + assert_eq!(code, 1); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "scan"); + assert_eq!( + envelope_error_code(&v), + Some("offline_unsupported"), + "{v:#}" + ); + assert_eq!(v["summary"]["applied"], 0); + + let (code, stdout, _) = run_scan(tmp.path(), &["--sync", "--mode", "hosted", "--json"]); + assert_eq!(code, 2); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "scan"); + assert_eq!(envelope_error_code(&v), Some("invalid_args"), "{v:#}"); +} + +/// Global installs have no project lockfile: `--mode hosted --global` is a +/// self-enforced usage error, so `--json` prints the coded error on stdout +/// (v5.0; clap's own parse errors still print nothing there). +#[test] +fn scan_hosted_global_json_is_global_scope_unsupported() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _) = run_scan(tmp.path(), &["--mode", "hosted", "--global", "--json"]); + assert_eq!(code, 2, "{stdout}"); + let v = parse_json_envelope(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!( + envelope_error_code(&v), + Some("global_scope_unsupported"), + "{v}" + ); + let message = envelope_error_message(&v).unwrap_or_default(); + assert!( + message.contains("--global") && message.contains("--mode hosted"), + "the error must name both flags: {v}" + ); +} + +// --------------------------------------------------------------------------- +// Hosted --prune and the paid-tier counters +// --------------------------------------------------------------------------- + +/// Zero-package hosted run: `redirect_prune_ignored` rides the envelope's +/// top-level `warnings[]`, and the empty crawl never queries the API. +#[tokio::test] +async fn scan_hosted_prune_zero_package_json_carries_the_warning() { + let mock = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "hosted", "--prune"]); + assert_eq!(code, 0, "{v:#}"); + assert_eq!(v["status"], "success"); + assert_eq!( + v["scannedPackages"], 0, + "classic keys stay schema-consistent" + ); + assert_eq!(v["redirect"]["mode"], "hosted"); + assert_eq!(v["summary"]["applied"], 0, "nothing pinned: {v}"); + assert!( + warning_detail(&v, "redirect_prune_ignored").contains("--prune"), + "the warning must name the ignored flag: {v:#}" + ); + assert!( + batch_bodies(&recorded(&mock).await).is_empty(), + "empty project must not query" + ); +} + +/// The paid counter: the discovery payload carries the patch's `paid` tier +/// and `canAccessPaidPatches`. +#[tokio::test] +async fn scan_json_counts_paid_patches_separately() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "paid", &[], false).await; + let tmp = minimist_project(b"x\n"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "{v:#}"); + // v5.0: the tier counts derive from the discovery payload. + let tiers: Vec<&str> = v["packages"] + .as_array() + .unwrap() + .iter() + .flat_map(|p| p["patches"].as_array().unwrap()) + .map(|p| p["tier"].as_str().unwrap()) + .collect(); + assert_eq!(tiers, ["paid"], "{v}"); + assert_eq!(v["canAccessPaidPatches"], false); +} + +// --------------------------------------------------------------------------- +// Detail-fetch failures +// --------------------------------------------------------------------------- + +/// Every detail fetch failing fails an agent-mode scan: exit 1, +/// `patch_details_failed`, nothing downloaded. +#[tokio::test] +async fn scan_agent_json_total_detail_failure_is_patch_details_failed() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "paid", &[], true).await; + mount_by_package_500(&mock, MINIMIST).await; + let tmp = minimist_project(b"x\n"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 1, "{v:#}"); + assert_eq!( + envelope_error_code(&v), + Some("patch_details_failed"), + "{v:#}" + ); + assert_eq!(view_gets(&recorded(&mock).await), 0, "{v:#}"); + assert!(!tmp.path().join(".socket").exists()); +} + +/// A partial detail-fetch failure warns once per failed package and goes +/// on with the packages that did resolve. +#[tokio::test] +async fn scan_agent_json_partial_detail_failure_warns_per_package() { + let mock = MockServer::start().await; + let bad = "pkg:npm/lodash@4.17.20"; + mount_batch_free( + &mock, + &[ + (MINIMIST, UUID), + (bad, "33333333-3333-4333-8333-333333333333"), + ], + ) + .await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + mount_by_package_500(&mock, bad).await; + let tmp = minimist_project(b"x\n"); + write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + assert_eq!(code, 0, "a partial failure is not an error: {v:#}"); + assert_eq!(envelope_error_code(&v), None, "{v:#}"); + let failed: Vec<_> = v["warnings"] + .as_array() + .unwrap() + .iter() + .filter(|w| w["code"] == "patch_details_failed") + .collect(); + assert_eq!(failed.len(), 1, "one warning per failed package: {v:#}"); + assert!( + failed[0]["detail"] + .as_str() + .is_some_and(|d| d.contains(bad)), + "the warning names the failed purl: {v:#}" + ); + assert_eq!( + event_purls(&v, "verified"), + [MINIMIST], + "the resolved package still goes through: {v:#}" + ); +} + +/// #1062: when every detail query succeeds but returns no records, scan +/// has nothing to select. That is a successful run with no applicable +/// patches in every mode (a successful empty answer is not a failure), so +/// the human arm exits like the `--json` arm (0), and neither reports a +/// fetch failure. +#[tokio::test] +async fn scan_empty_detail_results_exit_alike_in_human_and_json() { + // `--prune` alone is report-only (no mode); `--dry-run` keeps every + // mode read-only. + let modes: [&[&str]; 4] = [ + &["--mode", "agent"], + &["--mode", "vendored"], + &["--mode", "hosted"], + &["--prune"], + ]; + for mode in modes { + for dry in [false, true] { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package_empty(&mock, MINIMIST).await; + let mut extra: Vec<&str> = mode.to_vec(); + if dry { + extra.push("--dry-run"); + } + + let tmp = minimist_project(b"x\n"); + let (human, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &extra); + assert!( + !stderr.contains("Error:"), + "{extra:?}: no query failed, so no fetch failure; stdout={stdout}\nstderr={stderr}" + ); + + let tmp = minimist_project(b"x\n"); + let (json, v) = run_json(tmp.path(), &mock.uri(), &extra); + assert_ne!(v["status"], "error", "{extra:?}: {v}"); + assert_eq!(v["events"], serde_json::json!([]), "{extra:?}: {v}"); + assert!( + !warning_codes(&v).contains(&"patch_details_failed".to_string()), + "{extra:?}: {v:#}" + ); + assert_eq!( + human, json, + "{mode:?} dry={dry}: human and --json exit alike\n\ + human stdout={stdout}\nhuman stderr={stderr}\njson={v:#}" + ); + assert_eq!(human, 0, "{mode:?} dry={dry}: nothing to do is a success"); + } + } +} + +/// #1062: report-only `scan --prune` fetches the detail records in both +/// outputs whenever a patch is downloadable, so every detail query failing +/// fails both alike (exit 1; the JSON arm used to skip the fetch and exit +/// 0). +#[tokio::test] +async fn scan_report_only_detail_failure_exits_alike_in_human_and_json() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package_500(&mock, MINIMIST).await; + let extra = ["--prune", "--dry-run"]; + + let tmp = minimist_project(b"x\n"); + let (human, _, stderr) = run_scan_api(tmp.path(), &mock.uri(), &extra); + let tmp = minimist_project(b"x\n"); + let (json, v) = run_json(tmp.path(), &mock.uri(), &extra); + assert_eq!( + envelope_error_code(&v), + Some("patch_details_failed"), + "{v:#}" + ); + assert_eq!(human, json, "human and --json exit alike: {stderr}\n{v:#}"); + assert_eq!(json, 1, "every detail query failing is a failure: {v:#}"); +} + +/// #1062: some detail queries fail and the rest succeed with no records: +/// the `--json` arm adds a `patch_details_failed` warning and both outputs +/// exit alike. The human warning line is pinned in `scan_render.rs`. +#[tokio::test] +async fn scan_partial_failure_with_empty_results_exits_alike() { + let bad = "pkg:npm/lodash@4.17.20"; + let mock = MockServer::start().await; + mount_batch_free(&mock, &[(MINIMIST, UUID), (bad, UUID)]).await; + mount_by_package_empty(&mock, MINIMIST).await; + mount_by_package_500(&mock, bad).await; + let extra = ["--mode", "agent", "--dry-run"]; + + let project = || { + let tmp = minimist_project(b"x\n"); + write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); + tmp + }; + let tmp = project(); + let (human, _, stderr) = run_scan_api(tmp.path(), &mock.uri(), &extra); + let tmp = project(); + let (json, v) = run_json(tmp.path(), &mock.uri(), &extra); + assert!( + warning_detail(&v, "patch_details_failed").contains(bad), + "{v:#}" + ); + assert_eq!(human, json, "human and --json exit alike: {stderr}\n{v:#}"); +} + +// --------------------------------------------------------------------------- +// Vendor-owned purls, report-only runs and the GC +// --------------------------------------------------------------------------- + +/// A purl the vendored ledger owns is skipped by an agent-mode scan: no +/// download, no manifest, and no apply event for it. +#[tokio::test] +async fn scan_agent_json_skips_vendored_purls_without_downloading() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_vendored_entry(tmp.path(), MINIMIST); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--yes"]); + assert_eq!(code, 0, "{v:#}"); + assert!( + !event_triples(&v) + .iter() + .any(|(purl, action, _)| purl == MINIMIST && action != "skipped"), + "a vendor-owned purl is only ever skipped: {v:#}" + ); + assert_eq!(v["summary"]["applied"], 0, "{v:#}"); + assert_eq!(view_gets(&recorded(&mock).await), 0, "{v:#}"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "a fully-skipped run must not create the manifest" + ); +} + +/// `--prune` with no mode only reports: no view fetch, no `.socket/`, the +/// installed file untouched, and no apply event. +#[tokio::test] +async fn scan_prune_without_a_mode_json_is_report_only() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"x\n").await; + let tmp = minimist_project(b"x\n"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--prune"]); + assert_eq!(code, 0, "report-only is a success: {v:#}"); + assert_eq!(v["summary"]["applied"], 0, "{v:#}"); + assert!(event_purls(&v, "applied").is_empty(), "{v:#}"); + assert!(!tmp.path().join(".socket").exists(), "{v:#}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"x\n" + ); + assert_eq!(view_gets(&recorded(&mock).await), 0, "{v:#}"); +} + +/// Each spelling of apply-and-collect (`--sync`, `--mode agent --prune`) +/// applies the patch, records it, prunes the orphan manifest entry and +/// sweeps its blob, and reports the GC. +#[tokio::test] +async fn scan_sync_json_applies_and_collects() { + for flags in [ + &["--sync", "--yes"][..], + &["--mode", "agent", "--prune"][..], + ] { + let mock = MockServer::start().await; + let tmp = orphans_project(&mock, &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')]).await; + + let (code, v) = run_json(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "{flags:?}: {v:#}"); + assert_eq!( + event_purls(&v, "applied"), + [SILENT_TARGET], + "{flags:?}: {v:#}" + ); + assert!(v["gc"].is_object(), "{flags:?}: {v:#}"); + let manifest = read_manifest(tmp.path()); + assert!( + manifest["patches"]["pkg:npm/gone@1.0.0"].is_null(), + "{flags:?}: the orphan entry must be pruned: {manifest}" + ); + assert_eq!( + manifest["patches"][SILENT_TARGET]["uuid"], UUID, + "{flags:?}: the applied patch must be recorded: {manifest}" + ); + assert!( + !tmp.path() + .join(".socket/blobs") + .join(orphan_blob_name('c')) + .exists(), + "{flags:?}: the orphan blob must be swept" + ); + } +} + +/// #1062: a `--dry-run --prune` previews the GC in agent, report-only and +/// vendored mode alike: a `verified` event (`details.manifest`) for the +/// stale entry and `gc`; nothing is written. The human preview line is +/// pinned in `scan_render.rs`. +#[tokio::test] +async fn scan_dry_run_prune_json_previews_gc() { + let stale = "pkg:npm/left-pad@1.3.0"; + for mode in [ + &["--mode", "agent"][..], + &[][..], + &["--mode", "vendored"][..], + ] { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + // A recorded patch for a package that is not installed: the GC + // would prune its entry. + seed_manifest(tmp.path(), &[(stale, OLD_UUID)]); + let manifest = tmp.path().join(".socket/manifest.json"); + let before = std::fs::read(&manifest).unwrap(); + let mut extra: Vec<&str> = mode.to_vec(); + extra.extend(["--prune", "--dry-run", "--yes"]); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &extra); + assert_eq!(code, 0, "{extra:?}: {v:#}"); + let pruned = events(&v) + .iter() + .find(|e| e["purl"] == stale) + .unwrap_or_else(|| panic!("{extra:?}: no prune preview: {v:#}")); + assert_eq!(pruned["action"], "verified", "{v:#}"); + assert_eq!(pruned["details"]["manifest"], true, "{v:#}"); + assert!(v["gc"].is_object(), "{extra:?}: {v:#}"); + assert_eq!( + std::fs::read(&manifest).unwrap(), + before, + "{extra:?}: a dry run writes nothing" + ); + } +} + +// --------------------------------------------------------------------------- +// v5.0 envelope: `scan --json` prints one shared Envelope +// --------------------------------------------------------------------------- + +/// A wet agent-mode `scan --json`: one envelope (`command: "scan"`), the +/// download as a `downloaded` event carrying the patch metadata, the nested +/// apply as an `applied` event, `summary` equal to the event counts, the +/// discovery payload beside them, and none of the retired keys. +#[tokio::test] +async fn scan_agent_json_is_one_envelope_with_events() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"before\n").await; + let tmp = minimist_project(b"before\n"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 0, "{v:#}"); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["dryRun"], false); + assert_eq!( + event_triples(&v), + vec![ + ( + MINIMIST.to_string(), + "downloaded".to_string(), + String::new() + ), + (MINIMIST.to_string(), "applied".to_string(), String::new()), + ], + "{v:#}" + ); + let downloaded = find_event(&v, "downloaded", None); + assert_eq!(downloaded["uuid"], UUID); + assert_eq!(downloaded["details"]["tier"], "free", "{v:#}"); + assert!( + downloaded["details"].get("mode").is_none(), + "agent events carry no mode" + ); + assert_eq!(v["scannedPackages"], 1); + assert_eq!(v["packages"][0]["purl"], MINIMIST); + for retired in [ + "apply", + "totalPatches", + "freePatches", + "paidPatches", + "packagesWithPatches", + "patches", + "found", + ] { + assert!(v.get(retired).is_none(), "retired key {retired}: {v:#}"); + } +} + +/// An agent-mode dry run previews without writing: a `verified` event per +/// patch a wet run would record, top-level `dryRun`, no nested one. +#[tokio::test] +async fn scan_agent_json_dry_run_previews_verified_events() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"before\n").await; + let tmp = minimist_project(b"before\n"); + seed_manifest(tmp.path(), &[(MINIMIST, OLD_UUID)]); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + assert_eq!(code, 0, "{v:#}"); + assert_eq!(v["dryRun"], true); + let e = find_event(&v, "verified", None); + assert_eq!(e["purl"], MINIMIST); + assert_eq!(e["uuid"], UUID); + assert_eq!( + e["oldUuid"], OLD_UUID, + "a would-be update names the uuid it replaces" + ); + assert_eq!(v["summary"]["verified"], 1); +} + +/// `scan --mode agent --json` whose nested apply fails (the installed copy +/// is a symlink to a first-party `packages/` directory, which apply refuses +/// to patch): the envelope must carry the per-patch failure (a `failed` +/// event with `errorCode` and `error`) and count it in `summary.failed`, +/// not report the patch as cleanly applied (#424). +#[cfg(unix)] +#[tokio::test] +async fn scan_agent_json_nested_apply_failure_reaches_the_apply_block() { + let mock = MockServer::start().await; + let purl = "pkg:npm/apply-fails@1.0.0"; + mount_one_patch_api(&mock, purl, b"before\n").await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "apply-fails", "1.0.0", b"before\n"); + let member = tmp.path().join("packages/apply-fails"); + std::fs::create_dir_all(member.parent().unwrap()).unwrap(); + std::fs::rename(tmp.path().join("node_modules/apply-fails"), &member).unwrap(); + std::os::unix::fs::symlink( + "../packages/apply-fails", + tmp.path().join("node_modules/apply-fails"), + ) + .unwrap(); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 1, "{v:#}"); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_eq!( + v["summary"]["failed"], 1, + "the apply failure must be counted: {v}" + ); + assert_eq!(v["summary"]["applied"], 0, "{v}"); + let rec = find_event(&v, "failed", Some("apply_failed")); + assert_eq!(rec["purl"], purl, "{v}"); + assert!( + rec["error"].as_str().is_some_and(|e| !e.is_empty()), + "the apply error text must reach the envelope: {v}" + ); + assert_eq!(std::fs::read(member.join("index.js")).unwrap(), b"before\n"); +} + +/// `scan --mode agent --json` over an installed file a local edit changed +/// (neither beforeHash nor afterHash): the default policy overwrites it, +/// and `warnings[]` must report that overwrite with the same +/// `content_mismatch_overwritten` code `apply --json` uses instead of +/// dropping it (#1004). +#[tokio::test] +async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { + let mock = MockServer::start().await; + let purl = "pkg:npm/locally-edited@1.0.0"; + mount_one_patch_api(&mock, purl, b"before\n").await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package( + tmp.path(), + "locally-edited", + "1.0.0", + b"before\n// local edit\n", + ); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 0, "{v:#}"); + assert_eq!(v["summary"]["applied"], 1, "{v}"); + let detail = warning_detail(&v, "content_mismatch_overwritten"); + assert!( + detail.contains(purl) && detail.contains("package/index.js"), + "the overwrite must name the purl and the file: {v:#}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/locally-edited/index.js")).unwrap(), + b"after\n" + ); +} + +/// An in-place apply over LIVE hosted wiring (a lockfile still resolving +/// the purl to the hosted patch server) proceeds, and warns that the +/// conversion did not complete. +#[tokio::test] +async fn scan_agent_json_over_live_hosted_wiring_warns_retained() { + let mock = MockServer::start().await; + let before = b"before\n"; + mount_one_patch_api(&mock, SILENT_TARGET, before).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + // v5: the lock pin is the whole hosted state (no ledger). + write_live_hosted_lock(tmp.path()); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--yes"]); + assert_eq!(code, 0, "{v:#}"); + assert!( + warning_detail(&v, "hosted_wiring_retained").contains(SILENT_TARGET), + "the warning names the package: {v:#}" + ); + assert_eq!(event_purls(&v, "applied"), [SILENT_TARGET], "{v:#}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "the in-place apply proceeds despite the warning" + ); +} + +/// Agent mode with the apply lock held: the engine's hard error is the +/// run's ONE JSON document (v5.0 fixes a run that printed the engine's +/// error and then scan's own result). +#[tokio::test] +async fn scan_agent_json_lock_held_prints_one_document() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"before\n").await; + let tmp = minimist_project(b"before\n"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let _lock = + socket_patch_core::patch::apply_lock::acquire(&socket, std::time::Duration::ZERO).unwrap(); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 1, "{v:#}"); + assert_eq!(v["status"], "error"); + assert_eq!(envelope_error_code(&v), Some("lock_held"), "{v:#}"); +} + +/// Agent mode over a manifest that exists but does not parse fails closed +/// before any query, in both outputs: `manifest_invalid` (the shared code), +/// exit 1; report-only mode warns with the same code and goes on. +#[tokio::test] +async fn scan_unparseable_manifest_fails_agent_mode_and_warns_otherwise() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"before\n").await; + let tmp = minimist_project(b"before\n"); + std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + assert_eq!(code, 1, "{v:#}"); + assert_eq!(envelope_error_code(&v), Some("manifest_invalid"), "{v:#}"); + assert_eq!(v["events"], serde_json::json!([])); + let (code, _, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 1, "the human arm fails alike: {stderr}"); + + let (code, v) = run_json(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); + assert_eq!(code, 0, "{v:#}"); + assert!( + warning_codes(&v).contains(&"manifest_invalid".to_string()), + "{v:#}" + ); +} + +// --------------------------------------------------------------------------- +// Corrupt binary lockfiles report a format error in every scan mode +// --------------------------------------------------------------------------- + +/// A corrupt `bun.lockb` stays visible as `bun_lockb_invalid` (naming the +/// file) on the zero-package path, including when hosted mode has nothing +/// to redirect. +#[test] +fn scan_invalid_bun_lockb_warns_instead_of_silent_success() { + for mode in [None, Some("vendored"), Some("hosted")] { + let tmp = tempfile::tempdir().unwrap(); + write_invalid_bun_lockb_project(tmp.path()); + let mut args = vec!["--json"]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, stdout, stderr) = run_scan(tmp.path(), &args); + assert_eq!( + code, 0, + "mode={mode:?}: refusals never flip the exit; stdout={stdout}; stderr={stderr}" + ); + let v = parse_json_envelope(&stdout); + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + assert_eq!(v["scannedPackages"], 0, "mode={mode:?}: {v}"); + assert_eq!(v["lockfileOnlyPackages"], 0, "mode={mode:?}: {v}"); + assert!( + warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), + "mode={mode:?}: the format error must name its file: {v:#}" + ); + assert!( + !stdout.contains("Warning:"), + "mode={mode:?}: the human warning line must not leak into the JSON stream: {stdout}" + ); + } +} + +/// Installed dependencies do not suppress the binary format diagnosis in +/// any mode, including when hosted mode finds no patches to redirect. +#[tokio::test] +async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + + for mode in [None, Some("vendored"), Some("hosted")] { + let tmp = tempfile::tempdir().unwrap(); + write_invalid_bun_lockb_project(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + let mut args = vec![]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, v) = run_json(tmp.path(), &mock.uri(), &args); + assert_eq!(code, 0, "mode={mode:?}: {v:#}"); + assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + assert!( + warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), + "mode={mode:?}: {v:#}" + ); + } +} + +// --------------------------------------------------------------------------- +// vlt vendored ledgers: the GC probe and the degraded-ledger `~` boundary +// --------------------------------------------------------------------------- + +/// `scan --mode vendored --prune`: the lock-driven GC keeps the vlt entry +/// `vlt-lock.json` still resolves to its dir (the in-use verdict is +/// structural: a `file` node under the uuid) and reclaims one it does not. +#[tokio::test] +async fn scan_prune_keeps_a_wired_vlt_uuid_and_sweeps_an_unwired_one() { + let server = MockServer::start().await; + hosted::mock_all(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vlt_vendored::vendored_project(root, false); + let unwired = UUID_B; + let other = "pkg:npm/right-pad@1.0.0"; + let other_rel = format!(".socket/vendor/npm/{unwired}/right-pad-1.0.0/node_modules/right-pad"); + std::fs::create_dir_all(root.join(&other_rel)).unwrap(); + std::fs::write(root.join(&other_rel).join("index.js"), "x\n").unwrap(); + let mut state = vlt_vendored::state(root); + let mut entry = state["entries"][hosted::PURL].clone(); + entry["basePurl"] = serde_json::json!(other); + entry["uuid"] = serde_json::json!(unwired); + entry["artifact"]["path"] = serde_json::json!(other_rel); + entry["artifact"]["fileInventory"] = serde_json::json!({ "index.js": "0" }); + entry["wiring"] = serde_json::json!([]); + state["entries"][other] = entry; + std::fs::write( + root.join(".socket/vendor/state.json"), + serde_json::to_vec_pretty(&state).unwrap(), + ) + .unwrap(); + + let cwd = root.to_str().unwrap().to_string(); + let uri = server.uri(); + let (code, env, stderr) = hosted::run_json( + root, + &[ + "scan", + "--mode", + "vendored", + "--prune", + "--yes", + "--cwd", + &cwd, + "--api-url", + &uri, + "--org", + hosted::ORG, + "--api-token", + "fake", + ], + &[], + ); + assert!(code == 0 || code == 1, "{env:#}\n{stderr}"); + let after = vlt_vendored::state(root); + assert!( + after["entries"][hosted::PURL].is_object(), + "the wired entry stays: {after:#}\n{env:#}" + ); + assert!(root.join(vlt_vendored::rel()).join("index.js").is_file()); + assert!( + after["entries"].get(other).is_none(), + "the unwired entry is reclaimed: {after:#}\n{env:#}" + ); + assert!( + !root.join(format!(".socket/vendor/npm/{unwired}")).exists(), + "{env:#}" + ); +} + +/// A pre-v5 redirect ledger holding only a vlt node edit keyed at a peer +/// variant of the vendored package's DepID is IGNORED by a vendored scan: +/// v5 has no `vendor_supersedes_redirect` reconciliation (once the lock +/// routes a package to `.socket/vendor/`, no hosted state is left), so no +/// warning fires at either side of the `~` boundary and the legacy file is +/// left byte-identical. +#[tokio::test] +async fn scan_vendored_ignores_a_degraded_pre_v5_vlt_ledger_edit() { + use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; + for key in ["left-pad@1.3.0~peer.2", "left-pad@1.3.00~peer.2"] { + let server = MockServer::start().await; + hosted::mock_all(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vlt_vendored::vendored_project(root, false); + let mut ledger = RedirectState::new(); + ledger.edits = vec![FileEdit { + path: "vlt-lock.json".to_string(), + kind: socket_patch_core::patch::redirect::vlt::KIND.to_string(), + action: "modified".to_string(), + key: Some(key.to_string()), + original: None, + new: None, + }]; + let bytes = serde_json::to_string_pretty(&ledger).unwrap(); + std::fs::write(hosted::ledger_path(root), &bytes).unwrap(); + let cwd = root.to_str().unwrap().to_string(); + let uri = server.uri(); + let (code, env, stderr) = hosted::run_json( + root, + &[ + "scan", + "--mode", + "vendored", + "--yes", + "--cwd", + &cwd, + "--api-url", + &uri, + "--org", + hosted::ORG, + "--api-token", + "fake", + ], + &[], + ); + assert_eq!(code, 0, "{key}: {env:#}\n{stderr}"); + assert!( + !env.to_string().contains("vendor_supersedes_redirect"), + "{key}: the v5 vendored scan never reconciles a hosted ledger: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(hosted::ledger_path(root)).unwrap(), + bytes, + "{key}: the pre-v5 ledger is left byte-identical: {env:#}" + ); + } +} diff --git a/crates/socket-patch-cli/tests/scan/scan_fixture.rs b/crates/socket-patch-cli/tests/scan/scan_fixture.rs new file mode 100644 index 000000000..03423e7eb --- /dev/null +++ b/crates/socket-patch-cli/tests/scan/scan_fixture.rs @@ -0,0 +1,482 @@ +//! Fixtures and runners shared by `scan_failure_paths.rs` (the `--json` +//! contracts), `scan_render.rs` (the human output) and `scan_tty.rs` (scan +//! on a real terminal) (#1089). +//! +//! Every run spawns the built binary through `common::hermetic_command`, so +//! the ambient `SOCKET_*` surface can't reroute the branch under test and +//! the runs need no serialization. Network tests use wiremock. + +use std::path::Path; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::common::{binary, git_sha256, hermetic_command}; + +pub const ORG_SLUG: &str = "test-org"; +pub const UUID: &str = "11111111-1111-4111-8111-111111111111"; +pub const UUID_B: &str = "22222222-2222-4222-8222-222222222222"; +pub const OLD_UUID: &str = "99999999-9999-4999-8999-999999999999"; +/// The npm package most fixtures install and patch. +pub const MINIMIST: &str = "pkg:npm/minimist@1.2.2"; + +pub fn write_root_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "covgap-scan-root", "version": "0.0.0" }"#, + ) + .unwrap(); +} + +pub fn write_npm_package(root: &Path, name: &str, version: &str, content: &[u8]) { + let pkg_dir = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .unwrap(); + std::fs::write(pkg_dir.join("index.js"), content).unwrap(); +} + +/// A root `package.json` plus `node_modules/minimist@1.2.2` holding +/// `content`, the project most tests scan. +pub fn minimist_project(content: &[u8]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", content); + tmp +} + +/// Run `socket-patch scan` in `cwd` in the hermetic env, so the CLI +/// arguments are the sole source of truth (telemetry stays disabled so no +/// run phones home). +pub fn run_scan(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = hermetic_command(&binary()); + cmd.arg("scan").args(args).current_dir(cwd); + // The unconditional python crawl consults VIRTUAL_ENV first, so an + // activated host virtualenv would make "empty project" scans non-empty. + cmd.env_remove("VIRTUAL_ENV"); + let out = cmd.output().expect("run socket-patch scan"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// [`run_scan`] against a mock API (human output unless `extra` has +/// `--json`). +pub fn run_scan_api(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "--api-url", + api_url, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ]; + args.extend_from_slice(extra); + run_scan(cwd, &args) +} + +/// [`run_scan_api`] in agent mode: v5's bare scan is hosted, and these +/// fixtures exercise the in-place apply flow. +pub fn run_scan_agent(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec!["--mode", "agent"]; + args.extend_from_slice(extra); + run_scan_api(cwd, api_url, &args) +} + +pub async fn recorded(mock: &MockServer) -> Vec { + mock.received_requests() + .await + .expect("wiremock records requests by default") +} + +fn count_requests(reqs: &[wiremock::Request], verb: &str, needle: &str) -> usize { + reqs.iter() + .filter(|r| format!("{}", r.method) == verb && r.url.path().contains(needle)) + .count() +} + +pub fn by_package_gets(reqs: &[wiremock::Request]) -> usize { + count_requests(reqs, "GET", "/patches/by-package/") +} + +pub fn view_gets(reqs: &[wiremock::Request]) -> usize { + count_requests(reqs, "GET", "/patches/view/") +} + +pub fn reference_posts(reqs: &[wiremock::Request]) -> usize { + reqs.iter() + .filter(|r| format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/package")) + .count() +} + +pub fn batch_bodies(reqs: &[wiremock::Request]) -> Vec { + reqs.iter() + .filter(|r| format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/batch")) + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect() +} + +/// One batch-response patch record. +fn batch_patch(purl: &str, uuid: &str, tier: &str, cve_ids: &[&str]) -> serde_json::Value { + serde_json::json!({ + "uuid": uuid, + "purl": purl, + "tier": tier, + "cveIds": cve_ids, + "ghsaIds": [], + "severity": "high", + "title": "covgap test patch" + }) +} + +/// Mount the batch endpoint answering with `packages` as given. +pub async fn mount_batch(mock: &MockServer, packages: serde_json::Value, can_access_paid: bool) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": packages, + "canAccessPaidPatches": can_access_paid, + }))) + .mount(mock) + .await; +} + +/// Mount a batch endpoint that finds no patches at all. +pub async fn mount_empty_batch(mock: &MockServer) { + mount_batch(mock, serde_json::json!([]), false).await; +} + +/// Mount the batch endpoint returning one free patch for each +/// `(purl, uuid)` pair. +pub async fn mount_batch_free(mock: &MockServer, pkgs: &[(&str, &str)]) { + let packages: Vec<_> = pkgs + .iter() + .map(|(purl, uuid)| { + serde_json::json!({ "purl": purl, "patches": [batch_patch(purl, uuid, "free", &[])] }) + }) + .collect(); + mount_batch(mock, serde_json::json!(packages), false).await; +} + +/// Mount the batch endpoint returning one package with one patch of the +/// given tier and vuln ids. +pub async fn mount_batch_one( + mock: &MockServer, + purl: &str, + uuid: &str, + tier: &str, + cve_ids: &[&str], + can_access_paid: bool, +) { + let packages = serde_json::json!([ + { "purl": purl, "patches": [batch_patch(purl, uuid, tier, cve_ids)] } + ]); + mount_batch(mock, packages, can_access_paid).await; +} + +pub fn encode_purl(purl: &str) -> String { + purl.replace(':', "%3A") + .replace('/', "%2F") + .replace('@', "%40") +} + +fn by_package_path(purl: &str) -> String { + format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", + encode_purl(purl) + ) +} + +/// Mount the by-package detail endpoint for `purl` answering `body`. +pub async fn mount_by_package_body(mock: &MockServer, purl: &str, body: serde_json::Value) { + Mock::given(method("GET")) + .and(path(by_package_path(purl))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(mock) + .await; +} + +/// Mount the by-package detail endpoint for `purl` with the given +/// vulnerabilities object. +pub async fn mount_by_package( + mock: &MockServer, + purl: &str, + uuid: &str, + vulnerabilities: serde_json::Value, +) { + let body = serde_json::json!({ + "patches": [{ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Covgap test patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": vulnerabilities, + }], + "canAccessPaidPatches": false, + }); + mount_by_package_body(mock, purl, body).await; +} + +/// Mount a by-package response that succeeds with NO patch records (the +/// batch said the package has one; the detail query found none to offer). +pub async fn mount_by_package_empty(mock: &MockServer, purl: &str) { + let body = serde_json::json!({ "patches": [], "canAccessPaidPatches": false }); + mount_by_package_body(mock, purl, body).await; +} + +/// Mount a by-package detail endpoint for `purl` that fails with a 500. +pub async fn mount_by_package_500(mock: &MockServer, purl: &str) { + Mock::given(method("GET")) + .and(path(by_package_path(purl))) + .respond_with(ResponseTemplate::new(500)) + .mount(mock) + .await; +} + +/// The patch view for `uuid` with an inline blob turning `before` into +/// `after\n` in `package/index.js`, so the apply step needs no blob endpoint. +async fn mount_view(mock: &MockServer, purl: &str, uuid: &str, before: &[u8]) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(b"after\n"), + // base64 of "after\n" + "blobContent": "YWZ0ZXIK", + } + }, + "vulnerabilities": {}, + "description": "Covgap test patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +/// Mount the three endpoints the full apply flow hits (batch, by-package, +/// and the patch view with an inline blob) for one free patch per +/// `(purl, uuid)` pair; each `index.js` goes from `before` to `after\n`. +pub async fn mount_patch_api(mock: &MockServer, pkgs: &[(&str, &str)], before: &[u8]) { + mount_batch_free(mock, pkgs).await; + for (purl, uuid) in pkgs { + mount_by_package(mock, purl, uuid, serde_json::json!({})).await; + mount_view(mock, purl, uuid, before).await; + } +} + +/// [`mount_patch_api`] for one patch, `UUID`, on `purl`. +pub async fn mount_one_patch_api(mock: &MockServer, purl: &str, before: &[u8]) { + mount_patch_api(mock, &[(purl, UUID)], before).await; +} + +/// Seed `.socket/manifest.json` with one entry per `(purl, uuid)` pair +/// (camelCase wire shape, mirroring `scan_invariants.rs`). +pub fn seed_manifest(root: &Path, entries: &[(&str, &str)]) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let patches: serde_json::Map = entries + .iter() + .map(|(purl, uuid)| { + ( + (*purl).to_string(), + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "seed", + "license": "MIT", + "tier": "free", + }), + ) + }) + .collect(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); +} + +/// `.socket/manifest.json` parsed. +pub fn read_manifest(root: &Path) -> serde_json::Value { + let text = std::fs::read_to_string(root.join(".socket/manifest.json")).expect("manifest"); + serde_json::from_str(&text).unwrap() +} + +/// The patch target every apply-with-orphans run installs and patches. +pub const SILENT_TARGET: &str = "pkg:npm/silent-target@1.0.0"; + +/// One stale manifest entry and the fill char of its orphan blob's name. +pub type Orphan<'a> = (&'a str, &'a str, char); + +/// The project behind [`run_apply_with_orphans`]: `silent-target@1.0.0` +/// installed (with its patch mounted on `mock`) and `orphans` pre-seeded as +/// manifest entries, each with one orphan blob file. +pub async fn orphans_project(mock: &MockServer, orphans: &[Orphan<'_>]) -> tempfile::TempDir { + let before = b"before\n"; + mount_one_patch_api(mock, SILENT_TARGET, before).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + let entries: Vec<(&str, &str)> = orphans.iter().map(|(p, u, _)| (*p, *u)).collect(); + seed_manifest(tmp.path(), &entries); + let blobs = tmp.path().join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + for (_, _, fill) in orphans { + std::fs::write(blobs.join(orphan_blob_name(*fill)), b"orphan").unwrap(); + } + tmp +} + +/// The file name of the orphan blob seeded for `fill`. +pub fn orphan_blob_name(fill: char) -> String { + fill.to_string().repeat(64) +} + +/// Run the full apply pipeline with `flags` (e.g. `--sync --yes`) over an +/// [`orphans_project`] and return `(code, stdout, stderr, root)`. +pub async fn run_apply_with_orphans( + mock: &MockServer, + orphans: &[Orphan<'_>], + flags: &[&str], +) -> (i32, String, String, tempfile::TempDir) { + let tmp = orphans_project(mock, orphans).await; + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), flags); + (code, stdout, stderr, tmp) +} + +/// Write a `package-lock.json` resolving the npm `purl` to the hosted +/// patch server at `resolved_path` (relative to `https://patch.socket.dev/`). +fn write_hosted_lock(root: &Path, purl: &str, resolved_path: &str) { + let (name, version) = purl + .strip_prefix("pkg:npm/") + .and_then(|rest| rest.rsplit_once('@')) + .expect("an npm purl"); + let lock = serde_json::json!({ + "name": "covgap-scan-root", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "covgap-scan-root", + "version": "0.0.0", + "dependencies": { name: version } + }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!("https://patch.socket.dev/{resolved_path}"), + "integrity": "sha512-orig==", + } + } + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .unwrap(); +} + +/// A live hosted lock for `silent-target@1.0.0` (the inventory proof fires +/// on the patch.socket.dev host), the wiring an agent-mode apply leaves in +/// place and warns about. +pub fn write_live_hosted_lock(root: &Path) { + write_hosted_lock( + root, + SILENT_TARGET, + &format!("patch/npm/tok/{UUID}/silent-target-1.0.0.tgz"), + ); +} + +/// Seed a hosted pin for the npm `purl` in `package-lock.json`: the +/// lockfile resolving it to the Socket patch server under `uuid`. v5 hosted +/// mode keeps its state only in lockfile pins, so this is update +/// detection's "old" side. +pub fn seed_hosted_pin(root: &Path, purl: &str, uuid: &str) { + let (name, version) = purl + .strip_prefix("pkg:npm/") + .and_then(|rest| rest.rsplit_once('@')) + .expect("an npm purl"); + write_hosted_lock( + root, + purl, + &format!("patch/npm/{name}/{version}/tok/{uuid}/{name}-{version}.tgz"), + ); +} + +/// Reference endpoint denying the grant: the hosted engine runs (proving +/// the hosted arm reached it) but rewrites nothing, so no lockfile fixture +/// is needed. +pub async fn mount_forbidden_reference(mock: &MockServer, purl: &str) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { + "status": "forbidden", "url": null, "purl": purl, + "artifacts": [], "registryOverride": null + } } + }))) + .mount(mock) + .await; +} + +/// A project whose only npm inventory is a corrupt `bun.lockb`. +pub fn write_invalid_bun_lockb_project(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "covgap-lockb-only", "version": "0.0.0", "dependencies": { "minimist": "1.2.2" } }"#, + ) + .unwrap(); + std::fs::write(root.join("bun.lockb"), b"\0binary bun lockfile").unwrap(); +} + +/// A real bun 1.3.14 lockfileVersion-1 workspace lock resolving minimist +/// from the registry: the shape the vendored preflight refuses. +pub fn write_bun_v1_workspace_lock(root: &Path) { + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"covgap-scan-root\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"minimist\": \"1.2.2\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"minimist\": [\"minimist@1.2.2\", \"\", {}, \"sha512-AAAA==\"],\n }\n}\n", + ) + .unwrap(); +} + +/// A vendored state ledger claiming `purl` (camelCase wire shape), so scan +/// treats it as vendor-owned. +pub fn seed_vendored_entry(root: &Path, purl: &str) { + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("state.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": 1, + "entries": { + purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz"), + }, + "wiring": [], + } + } + })) + .unwrap(), + ) + .unwrap(); +} diff --git a/crates/socket-patch-cli/tests/scan/scan_render.rs b/crates/socket-patch-cli/tests/scan/scan_render.rs new file mode 100644 index 000000000..a60115b4c --- /dev/null +++ b/crates/socket-patch-cli/tests/scan/scan_render.rs @@ -0,0 +1,1261 @@ +//! `scan`'s human output (#1089): one check per message family, plus the +//! disk state the human arm (its own code path) leaves behind. The +//! `--json` contracts of the same paths are pinned in +//! `scan_failure_paths.rs`; scan on a real terminal is `scan_tty.rs`. +//! +//! 1. terminals reachable without a network: `--offline`, hosted +//! `--prune`, an empty `--global-prefix`, `--ecosystems`, usage errors; +//! 2. the paid-tier table column, summaries and nudge; +//! 3. the results table: `[UPDATE]`, `(+N)`, full purls, the preview's +//! vulnerability lines, and the detail-fetch warning and error; +//! 4. `[skip]`, `[re-apply]` and `[would-refuse]` lines; +//! 5. report-only runs and their hints, agent-mode applies, the GC line +//! and the retained-wiring warning; +//! 6. hosted mode's table, per-directory runs and corrupt `bun.lockb`. + +use std::path::Path; + +use wiremock::MockServer; + +use crate::scan_fixture::*; + +// --------------------------------------------------------------------------- +// Terminals reachable without a network +// --------------------------------------------------------------------------- + +/// `scan --offline`: hard error on stderr, exit 1, nothing on stdout. +#[test] +fn scan_offline_human_path_errors_on_stderr() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--offline"]); + assert_eq!(code, 1, "offline scan must fail; stderr={stderr:?}"); + assert!( + stderr.contains("scan requires network access"), + "the strict-airgap refusal must reach stderr; got {stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "the human offline error must not print a JSON envelope; got {stdout:?}" + ); +} + +/// `scan --mode hosted --prune`: the no-GC warning fires BEFORE the crawl, +/// so an empty project suffices (no network at all). +#[test] +fn scan_hosted_prune_human_warns_prune_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let (code, _stdout, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--prune"]); + assert_eq!( + code, 0, + "hosted --prune stays accepted (never a usage error)" + ); + assert!( + stderr.contains("Warning: --prune has no effect with --mode hosted"), + "the ignored-prune warning must reach stderr; got {stderr:?}" + ); +} + +/// `--global-prefix `: the global-flavored empty-scan hint. +#[test] +fn scan_empty_global_prefix_prints_global_hint() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &["--global-prefix", prefix.path().to_str().unwrap()], + ); + assert_eq!(code, 0, "an empty global scan succeeds; stderr={stderr:?}"); + assert!( + stdout.contains("No global packages found."), + "the global empty-scan hint must print; got {stdout:?}" + ); + assert!( + !stdout.contains("No packages found. Run"), + "the local-flavored hint must not print on a global scan; got {stdout:?}" + ); +} + +/// An empty crawl never prunes (too destructive), but says so instead of +/// silently dropping `--prune`; `--silent` keeps it quiet. +#[test] +fn scan_prune_on_empty_crawl_warns_the_gc_was_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune"]); + assert_eq!(code, 0, "stderr={stderr:?}"); + assert!( + stderr.contains("Warning: --prune skipped: no installed packages were found"), + "{stderr:?}" + ); + assert!(stdout.contains("No packages found."), "{stdout:?}"); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune", "--silent"]); + assert_eq!(code, 0); + assert!( + stdout.is_empty() && stderr.is_empty(), + "{stdout:?} {stderr:?}" + ); +} + +/// `--ecosystems` that filters everything out names the filter instead of +/// telling the user to run `cargo install`/`go install`. +#[test] +fn scan_empty_after_ecosystem_filter_names_the_filter() { + let tmp = minimist_project(b"x\n"); + let (code, stdout, stderr) = run_scan(tmp.path(), &["-e", "pypi"]); + assert_eq!(code, 0, "stderr={stderr:?}"); + assert!(stdout.contains("No pypi packages found."), "{stdout:?}"); +} + +/// Global installs have no project lockfile: `--mode hosted` with +/// `--global` or `--global-prefix` is a usage error naming the flag, not a +/// silent "redirected 0 packages". The `--json` code is pinned in +/// `scan_failure_paths.rs`. +#[test] +fn scan_hosted_rejects_global() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--global"]); + assert_eq!(code, 2, "stderr={stderr:?}"); + assert!( + stderr.starts_with( + "Error: --global cannot be used with --mode hosted: global installs have no \ + project lockfile to redirect" + ), + "{stderr:?}" + ); + assert!(stdout.is_empty()); + let prefix = tempfile::tempdir().unwrap(); + let (code, _, stderr) = run_scan( + tmp.path(), + &[ + "--mode", + "hosted", + "--global-prefix", + prefix.path().to_str().unwrap(), + ], + ); + assert_eq!(code, 2); + assert!( + stderr.starts_with("Error: --global-prefix cannot be used with --mode hosted"), + "{stderr:?}" + ); +} + +/// Usage errors from scan's own flag checks use the capitalized `Error:` +/// prefix like every other error line, and name no hidden flag. +#[test] +fn scan_mode_conflict_error_is_capitalized_and_names_no_hidden_flag() { + let tmp = tempfile::tempdir().unwrap(); + let (code, _, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--sync"]); + assert_eq!(code, 2); + assert!( + stderr.starts_with("Error: --mode hosted cannot be used with --sync"), + "{stderr:?}" + ); + assert!(!stderr.contains("--redirect"), "{stderr:?}"); +} + +// --------------------------------------------------------------------------- +// Paid tier: no other fixture sends tier: "paid" +// --------------------------------------------------------------------------- + +/// Paid patch, NO paid access: the `free+paid` table column, the free-only +/// summary, the paid-plan nudge with the pricing URL, and the "No +/// downloadable patches" terminal (exit 0, no detail fetch attempted). +#[tokio::test] +async fn scan_paid_patch_without_access_nudges_and_downloads_nothing() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "paid", &["CVE-2024-0001"], false).await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("0+1"), + "the table column must render free+paid counts; got {stdout:?}" + ); + assert!( + stdout.contains("Summary: 1 package with 0 free patches"), + "the no-access summary counts FREE patches only; got {stdout:?}" + ); + assert!( + stdout.contains("+ 1 additional patch is available with a paid Socket plan"), + "the paid nudge must print; got {stdout:?}" + ); + assert!( + stdout.contains("https://socket.dev/pricing"), + "the pricing URL must print; got {stdout:?}" + ); + assert!( + stdout.contains("No downloadable patches: every patch found requires a paid Socket plan."), + "the gated-catalog terminal must print; got {stdout:?}" + ); + assert_eq!( + by_package_gets(&recorded(&mock).await), + 0, + "no detail fetch may happen when nothing is downloadable" + ); +} + +/// Paid patch WITH paid access: the summary counts ALL patches (the +/// can-access arm) and no nudge prints. The deliberately failing detail +/// fetch then fails the run (its error line is pinned below). +#[tokio::test] +async fn scan_paid_patch_with_access_counts_all_patches() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "paid", &[], true).await; + mount_by_package_500(&mock, MINIMIST).await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!( + code, 1, + "a failed detail fetch fails the scan; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("Summary: 1 package with 1 available patch"), + "the can-access summary counts all patches; got {stdout:?}" + ); + assert!( + stdout.contains("0+1"), + "the free+paid column renders for paid-access users too; got {stdout:?}" + ); + assert!( + !stdout.contains("paid Socket plan"), + "no nudge for a subscriber; got {stdout:?}" + ); +} + +// --------------------------------------------------------------------------- +// The results table and the per-patch preview +// --------------------------------------------------------------------------- + +/// The `[UPDATE]` marker and newer-patches summary (the manifest's OLD uuid +/// against the batch's NEW uuid), the `(+N)` vuln overflow, and the +/// dry-run terminal before any confirm. +#[tokio::test] +async fn scan_human_table_renders_update_marker_and_vuln_overflow() { + let mock = MockServer::start().await; + mount_batch_one( + &mock, + MINIMIST, + UUID, + "free", + &["CVE-2024-0003", "CVE-2024-0001", "CVE-2024-0002"], + false, + ) + .await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_manifest(tmp.path(), &[(MINIMIST, OLD_UUID)]); + + // --dry-run keeps the run read-only past the table (the download and + // apply never run), so no view/blob mocks are needed. + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("[UPDATE]"), + "the human update marker must render; got {stdout:?}" + ); + assert!( + stdout.contains("1 package has a newer patch available."), + "the newer-patches summary must print; got {stdout:?}" + ); + // collect_vuln_ids sorts CVEs, so the first two sorted ids show and the + // third folds into (+1). + assert!( + stdout.contains("CVE-2024-0001, CVE-2024-0002 (+1)"), + "3+ vuln ids must truncate to two plus (+N); got {stdout:?}" + ); + assert!( + stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), + "dry-run must stop before the confirm; got {stdout:?}" + ); +} + +/// The table's PACKAGE column grows to fit the PURL (a fixed-width cut +/// would drop the version), and the rule matches the table. +#[tokio::test] +async fn scan_human_table_shows_full_purl_with_version() { + let mock = MockServer::start().await; + let purl = "pkg:npm/@typescript-eslint/typescript-estree@6.0.0"; + mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-1"], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + let pkg_dir = tmp + .path() + .join("node_modules/@typescript-eslint/typescript-estree"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "@typescript-eslint/typescript-estree", "version": "6.0.0" }"#, + ) + .unwrap(); + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let row = stdout + .lines() + .find(|l| l.contains("CVE-2024-1") && l.starts_with("pkg:npm/")) + .unwrap_or_else(|| panic!("no table row in {stdout:?}")); + assert!(row.starts_with(&format!("{purl} ")), "{row:?}"); + let header = stdout.lines().find(|l| l.starts_with("PACKAGE")).unwrap(); + // The right-aligned count ends where the PATCHES header ends. + assert_eq!( + header.find("PATCHES").map(|i| i + "PATCHES".len()), + row.find(" 1 ").map(|i| i + 2), + "PATCHES header sits over the count: {stdout}" + ); + // The rule is exactly as wide as the widest table line. + let rule = stdout.lines().find(|l| l.starts_with("===")).unwrap(); + assert_eq!(rule.len(), header.len().max(row.len()), "{stdout}"); +} + +/// Per-patch vulnerability rendering in the "Patches to apply" preview. +/// Every other human-path fixture ships `vulnerabilities: {}`, so the +/// CVE/GHSA fold, the "Fixes:" line, and the per-vuln summary lines had +/// never executed. +#[tokio::test] +async fn scan_human_preview_renders_vulnerability_details() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package( + &mock, + MINIMIST, + UUID, + serde_json::json!({ + "GHSA-aaaa-bbbb-cccc": { + "cves": ["CVE-2024-9999"], + "summary": "heap overflow in parse", + "severity": "HIGH", + "description": "d" + }, + "GHSA-dddd-eeee-ffff": { + "cves": [], + "summary": "no-cve issue", + "severity": "LOW", + "description": "d" + } + }), + ) + .await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run", "--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let fixes = stdout + .lines() + .find(|l| l.trim_start().starts_with("Fixes: ")) + .unwrap_or_else(|| panic!("the Fixes line must print; got {stdout:?}")); + assert!( + fixes.contains("CVE-2024-9999") && fixes.contains("GHSA-dddd-eeee-ffff"), + "a vuln WITH CVEs contributes its CVE ids, a CVE-less one its GHSA id: {fixes:?}" + ); + for line in [ + "- CVE-2024-9999: heap overflow in parse", + "- GHSA-dddd-eeee-ffff: no-cve issue", + ] { + assert!( + stdout.lines().any(|l| l.trim() == line), + "each vuln's summary is labeled with its CVE or advisory id ({line:?}); got {stdout:?}" + ); + } +} + +/// When every detail fetch fails, the human path prints ONE Error line that +/// names the package and the cause (no per-package Warning repeating it). +#[tokio::test] +async fn scan_human_detail_fetch_failure_errors_once() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package_500(&mock, MINIMIST).await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains(&format!( + "Error: could not fetch patch details for {MINIMIST}: " + )), + "the terminal error names the purl and the cause; got {stderr:?}" + ); + assert!( + !stderr.contains("Warning: could not fetch details"), + "a total failure must not repeat the cause as a Warning first; got {stderr:?}" + ); +} + +/// A partial detail-fetch failure warns once per failed package on stderr +/// and is not an error, whether the packages that did resolve have patches +/// or (#1062) came back empty. +#[tokio::test] +async fn scan_human_partial_detail_fetch_failure_warns_per_package() { + let bad = "pkg:npm/lodash@4.17.20"; + for other_has_patches in [true, false] { + let mock = MockServer::start().await; + mount_batch_free( + &mock, + &[ + (MINIMIST, UUID), + (bad, "33333333-3333-4333-8333-333333333333"), + ], + ) + .await; + if other_has_patches { + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + } else { + mount_by_package_empty(&mock, MINIMIST).await; + } + mount_by_package_500(&mock, bad).await; + let tmp = minimist_project(b"x\n"); + write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!( + stderr + .matches(&format!("Warning: could not fetch details for {bad}: ")) + .count(), + 1, + "other_has_patches={other_has_patches}: one warning naming the failed purl; \ + stdout={stdout}; stderr={stderr:?}" + ); + assert!( + !stderr.contains("Error:"), + "other_has_patches={other_has_patches}: a partial failure is not an error: {stderr:?}" + ); + assert_eq!(code, 0, "other_has_patches={other_has_patches}: {stderr}"); + } +} + +// --------------------------------------------------------------------------- +// [skip], [re-apply] and [would-refuse] lines +// --------------------------------------------------------------------------- + +/// A purl the vendored ledger owns gets a `[skip]` line naming it and the +/// remedy, and with everything vendor-owned nothing is selected. +#[tokio::test] +async fn scan_human_skips_vendored_purls_without_downloading() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_vendored_entry(tmp.path(), MINIMIST); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains(&format!( + "[skip] {MINIMIST} (vendored; run `socket-patch scan --mode vendored` to update it)" + )), + "the vendored skip line must name the purl and the remedy; got {stdout:?}" + ); + assert!( + stdout.contains("No patches selected."), + "with everything vendor-owned nothing is selected; got {stdout:?}" + ); + assert_eq!( + view_gets(&recorded(&mock).await), + 0, + "vendor-owned purls must not download" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "a fully-skipped run must not create the manifest" + ); +} + +/// #732: a human-output `scan --mode agent` / `scan --sync` re-applies a +/// patch the manifest already records once a reinstall has put the +/// pristine bytes back (the `--json` path has done so since #454). +#[tokio::test] +async fn scan_human_reapplies_an_already_recorded_patch_after_reinstall() { + for flags in [&["--mode", "agent"][..], &["--sync"][..]] { + let mock = MockServer::start().await; + let before = b"before\n"; + mount_one_patch_api(&mock, MINIMIST, before).await; + let tmp = minimist_project(before); + let index = tmp.path().join("node_modules/minimist/index.js"); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert_eq!( + std::fs::read(&index).unwrap(), + b"after\n", + "flags={flags:?}" + ); + + // `npm ci` / `rm -rf node_modules && npm install`. + std::fs::write(&index, before).unwrap(); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert_eq!( + std::fs::read(&index).unwrap(), + b"after\n", + "flags={flags:?}: the recorded patch must be re-applied; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains(&format!( + "[re-apply] {MINIMIST} (already recorded: 11111111)" + )), + "flags={flags:?}: {stdout:?}" + ); + assert!( + !stdout.contains("run `socket-patch apply` to re-apply them"), + "flags={flags:?}: {stdout:?}" + ); + assert_eq!( + read_manifest(tmp.path())["patches"][MINIMIST]["uuid"], + UUID, + "flags={flags:?}" + ); + } +} + +/// #732: when a run also has a new patch, the recorded-but-reverted one +/// is re-applied alongside it rather than dropped from the selection. +#[tokio::test] +async fn scan_human_reapplies_recorded_patch_alongside_a_new_one() { + let b = "pkg:npm/left-pad@1.3.0"; + let before = b"before\n"; + let tmp = minimist_project(before); + write_npm_package(tmp.path(), "left-pad", "1.3.0", before); + let index_a = tmp.path().join("node_modules/minimist/index.js"); + let index_b = tmp.path().join("node_modules/left-pad/index.js"); + + // First run: only minimist has a patch. + let first = MockServer::start().await; + mount_patch_api(&first, &[(MINIMIST, UUID)], before).await; + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &first.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert_eq!(std::fs::read(&index_a).unwrap(), b"after\n"); + assert_eq!(std::fs::read(&index_b).unwrap(), before); + + // Reinstall, then a patch for left-pad is published. + std::fs::write(&index_a, before).unwrap(); + let second = MockServer::start().await; + mount_patch_api(&second, &[(MINIMIST, UUID), (b, UUID_B)], before).await; + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &second.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert_eq!( + std::fs::read(&index_b).unwrap(), + b"after\n", + "new patch applied; stdout={stdout}; stderr={stderr}" + ); + assert_eq!( + std::fs::read(&index_a).unwrap(), + b"after\n", + "recorded patch re-applied; stdout={stdout}; stderr={stderr}" + ); + assert!(stdout.contains("Patches to apply:"), "{stdout:?}"); + assert!( + stdout.contains(&format!( + "[re-apply] {MINIMIST} (already recorded: 11111111)" + )), + "{stdout:?}" + ); +} + +/// `--dry-run` stays a non-mutating preview: an already-recorded +/// selection is neither downloaded nor re-applied, and the message says a +/// wet run re-applies it. +#[tokio::test] +async fn scan_human_dry_run_previews_reapply_of_a_recorded_patch() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_manifest(tmp.path(), &[(MINIMIST, UUID)]); + let manifest_before = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains(&format!("[skip] {MINIMIST} (already recorded: 11111111)")), + "{stdout:?}" + ); + assert!( + stdout.contains("a run without --dry-run re-applies them"), + "{stdout:?}" + ); + assert!(!stdout.contains("Patches to apply:"), "{stdout:?}"); + assert_eq!( + view_gets(&recorded(&mock).await), + 0, + "nothing is downloaded" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"x\n" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + manifest_before + ); +} + +/// A report-only `scan --prune --dry-run` never applies, so it must not +/// say that dropping `--dry-run` re-applies a recorded patch. +#[tokio::test] +async fn scan_report_only_dry_run_points_recorded_patch_at_apply() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_manifest(tmp.path(), &[(MINIMIST, UUID)]); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains(&format!("[skip] {MINIMIST} (already recorded: 11111111)")), + "{stdout:?}" + ); + assert!( + !stdout.contains("a run without --dry-run re-applies them"), + "{stdout:?}" + ); + assert!(!stdout.contains("[re-apply]"), "{stdout:?}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"x\n" + ); +} + +/// Human `--mode vendored --dry-run` names what the wet run would refuse +/// with `[would-refuse]` lines, like `get --mode vendored --dry-run`: both +/// commands share the printer. `--silent` keeps the preview quiet. +#[tokio::test] +async fn scan_human_vendored_dry_run_names_would_refuse_records() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + write_bun_v1_workspace_lock(tmp.path()); + + let (code, stdout, stderr) = run_scan_api( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run"], + ); + assert_eq!( + code, 0, + "a preview never flips the exit; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). No changes made."), + "the count line stays; got {stdout:?}" + ); + let refusal = stdout + .lines() + .find(|l| { + l.starts_with(&format!( + " [would-refuse] {MINIMIST} (vendor_bun_workspace_unsupported): " + )) + }) + .unwrap_or_else(|| panic!("the preview must name the refusal; got {stdout:?}")); + assert!( + refusal.contains("--mode hosted"), + "the engine's detail (with its remedy) rides along: {refusal:?}" + ); + // Nothing written: no manifest, no blobs, no vendor ledger. + assert!( + !tmp.path().join(".socket").exists(), + "a dry run writes nothing" + ); + + let (code, stdout, _) = run_scan_api( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run", "--silent"], + ); + assert_eq!(code, 0); + assert!( + stdout.trim().is_empty(), + "silent dry run prints nothing:\n{stdout}" + ); +} + +// --------------------------------------------------------------------------- +// Report-only runs, agent-mode applies, GC and wiring warnings +// --------------------------------------------------------------------------- + +/// One installed package, a batch response with no patches: the result is +/// said once, on stdout; the status line ends without repeating it. +#[tokio::test] +async fn scan_human_empty_batch_reports_no_patches_once() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.5", b"module.exports = {};\n"); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert_eq!(stdout, "\nNo patches available for installed packages.\n"); + assert!(!stderr.contains("No patches"), "stderr={stderr:?}"); + assert!( + stderr.lines().any(|l| l == "Found 1 package (1 npm)"), + "stderr={stderr:?}" + ); + assert_eq!(batch_bodies(&recorded(&mock).await).len(), 1); +} + +/// An empty discovery stops every human mode at the same result line with +/// exit 0 and no `.socket/`: vendored mode does not fall through to its +/// vendor step, and hosted mode does not enter its engine. +#[tokio::test] +async fn scan_human_empty_discovery_stops_in_every_mode() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + for flags in [ + &[][..], + &["--mode", "vendored"][..], + &["--mode", "hosted"][..], + &["--mode", "agent"][..], + ] { + let tmp = minimist_project(b"x\n"); + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert!( + stdout + .lines() + .any(|l| l == "No patches available for installed packages."), + "flags={flags:?}: got {stdout:?}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "flags={flags:?}: an empty discovery must create nothing" + ); + } + assert_eq!(reference_posts(&recorded(&mock).await), 0); +} + +/// `--prune` with no mode: the discovery, table and per-patch preview +/// print (the report IS the value), then the run stops with the +/// agent-mode hint, writing nothing and downloading nothing. +#[tokio::test] +async fn scan_prune_without_a_mode_is_report_only() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"x\n").await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--prune"]); + assert_eq!( + code, 0, + "report-only is a success; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("Patches to apply:") && stdout.contains(MINIMIST), + "the per-patch preview still prints; got {stdout:?}" + ); + assert!( + stdout.contains("To apply these patches in place, run:") + && stdout.contains("socket-patch scan --mode agent"), + "the agent-mode hint must print; got {stdout:?}" + ); + assert!( + !stderr.contains("Non-interactive mode detected"), + "scan never prompts; got {stderr:?}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "a report-only scan must not create .socket/" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"x\n", + "a report-only scan must not patch the installed file" + ); + assert_eq!( + view_gets(&recorded(&mock).await), + 0, + "a report-only scan must not download the patch" + ); +} + +/// #464: a report-only global scan hints at commands that keep the global +/// scope. Run verbatim without `--global-prefix`, the hint would scan the +/// cwd project and leave the global copy unpatched. +#[tokio::test] +async fn scan_global_report_only_hint_keeps_the_global_scope() { + let mock = MockServer::start().await; + mount_one_patch_api(&mock, MINIMIST, b"x\n").await; + + let tmp = tempfile::tempdir().unwrap(); + // A prefix with a space: the hint must quote it to stay runnable. + let prefix = tmp.path().join("global lib").join("node_modules"); + let pkg_dir = prefix.join("minimist"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "minimist", "version": "1.2.2" }"#, + ) + .unwrap(); + std::fs::write(pkg_dir.join("index.js"), b"x\n").unwrap(); + let cwd = tmp.path().join("elsewhere"); + std::fs::create_dir_all(&cwd).unwrap(); + + let prefix_arg = prefix.to_str().unwrap(); + let (code, stdout, stderr) = run_scan_api(&cwd, &mock.uri(), &["--global-prefix", prefix_arg]); + assert_eq!( + code, 0, + "report-only is a success; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("Patches to apply:") && stdout.contains(MINIMIST), + "the global scan must find the patch; got {stdout:?}" + ); + let quoted = if cfg!(windows) { + format!("\"{prefix_arg}\"") + } else { + format!("'{prefix_arg}'") + }; + for command in [ + format!(" socket-patch scan --mode agent --global-prefix {quoted}"), + format!(" socket-patch get --global-prefix {quoted} "), + ] { + assert!( + stdout.lines().any(|line| line == command), + "the hint must keep the global scope ({command:?}); got {stdout:?}" + ); + } + assert_eq!( + std::fs::read(pkg_dir.join("index.js")).unwrap(), + b"x\n", + "a report-only scan must not patch the global copy" + ); +} + +/// Each spelling that selects `--mode agent` applies without prompting. +#[tokio::test] +async fn scan_human_agent_mode_applies_without_prompting() { + for flags in [&["--mode", "agent"][..], &["--sync"][..]] { + let mock = MockServer::start().await; + let before = b"before\n"; + mount_one_patch_api(&mock, SILENT_TARGET, before).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert!( + !stderr.contains("Non-interactive mode detected"), + "flags={flags:?}: scan never prompts; got {stderr:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "flags={flags:?}: the apply must proceed" + ); + assert_eq!( + read_manifest(tmp.path())["patches"][SILENT_TARGET]["uuid"], + UUID, + "flags={flags:?}" + ); + } +} + +/// The post-apply GC line, both pluralization arms, for each spelling of +/// apply-and-collect (`--sync`, `--mode agent --prune`). The pruned +/// manifest and swept blobs are pinned in `scan_failure_paths.rs`. +#[tokio::test] +async fn scan_human_gc_line_pluralizes() { + let one: &[Orphan] = &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')]; + let two: &[Orphan] = &[ + ("pkg:npm/gone@1.0.0", OLD_UUID, 'c'), + ( + "pkg:npm/also-gone@2.0.0", + "88888888-8888-4888-8888-888888888888", + 'd', + ), + ]; + for (orphans, flags, line) in [ + ( + one, + &["--sync", "--yes"][..], + "GC: pruned 1 manifest entry and removed 1 orphan file (", + ), + ( + two, + &["--sync", "--yes"][..], + "GC: pruned 2 manifest entries and removed 2 orphan files (", + ), + ( + one, + &["--mode", "agent", "--prune"][..], + "GC: pruned 1 manifest entry and removed 1 orphan file (", + ), + ] { + let mock = MockServer::start().await; + let (code, stdout, stderr, tmp) = run_apply_with_orphans(&mock, orphans, flags).await; + assert_eq!(code, 0, "{flags:?}: stdout={stdout}; stderr={stderr}"); + assert!( + stdout.lines().any(|l| l.starts_with(line)), + "{flags:?}: the GC line must print {line:?}; got {stdout:?}" + ); + assert!( + read_manifest(tmp.path())["patches"]["pkg:npm/gone@1.0.0"].is_null(), + "{flags:?}: the line reports real work" + ); + } +} + +/// `scan --prune` with nothing to apply still garbage-collects, like the +/// JSON path, and a `--dry-run` previews it without touching the manifest. +#[tokio::test] +async fn scan_human_prune_runs_gc_even_when_no_patches_are_available() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.5", b"x\n"); + seed_manifest(tmp.path(), &[("pkg:npm/gone@1.0.0", OLD_UUID)]); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("[dry-run] GC would prune 1 manifest entry and remove 0 orphan files"), + "the dry run previews the GC; got {stdout:?}" + ); + assert!( + !read_manifest(tmp.path())["patches"]["pkg:npm/gone@1.0.0"].is_null(), + "a preview must not prune" + ); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--prune"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("GC: pruned 1 manifest entry and removed 0 orphan files"), + "the GC must run on the early exit; got {stdout:?}" + ); + assert!(read_manifest(tmp.path())["patches"]["pkg:npm/gone@1.0.0"].is_null()); +} + +/// #1062: a `--dry-run --prune` prints the GC preview line exactly once in +/// agent, report-only and vendored mode alike, and writes nothing. +#[tokio::test] +async fn scan_dry_run_prune_previews_gc_once() { + let stale = "pkg:npm/left-pad@1.3.0"; + for mode in [ + &["--mode", "agent"][..], + &[][..], + &["--mode", "vendored"][..], + ] { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + seed_manifest(tmp.path(), &[(stale, OLD_UUID)]); + let manifest = tmp.path().join(".socket/manifest.json"); + let before = std::fs::read(&manifest).unwrap(); + let mut extra: Vec<&str> = mode.to_vec(); + extra.extend(["--prune", "--dry-run", "--yes"]); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &extra); + assert_eq!(code, 0, "{extra:?}: stdout={stdout}; stderr={stderr}"); + assert_eq!( + stdout + .lines() + .filter(|l| l.starts_with("[dry-run] GC would prune 1 manifest entry")) + .count(), + 1, + "{extra:?}: the human dry run previews the GC once; stdout={stdout}" + ); + assert_eq!(std::fs::read(&manifest).unwrap(), before, "{extra:?}"); + } +} + +/// After an in-place apply over LIVE hosted wiring (a lockfile still +/// resolving the purl to the hosted patch server), the human path warns +/// that the conversion did not complete, naming the package. +#[tokio::test] +async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { + let mock = MockServer::start().await; + let before = b"before\n"; + mount_one_patch_api(&mock, SILENT_TARGET, before).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + write_live_hosted_lock(tmp.path()); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let warning = stderr + .lines() + .find(|l| l.starts_with("Warning: agent-mode scan left the hosted wiring live")) + .unwrap_or_else(|| panic!("the retained-wiring warning must reach stderr; got {stderr:?}")); + assert!( + stderr.contains(SILENT_TARGET), + "the warning must name the package; got {warning:?}\n{stderr:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "the in-place apply proceeds despite the warning" + ); +} + +// --------------------------------------------------------------------------- +// VEX lines +// --------------------------------------------------------------------------- + +/// `scan --dry-run --vex`: the embedded VEX is skipped with the same note +/// apply, vendor and hosted scan print, and no document is written. +#[tokio::test] +async fn scan_human_dry_run_vex_prints_the_shared_skip_line() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + let tmp = minimist_project(b"x\n"); + + let (code, stdout, stderr) = run_scan_agent( + tmp.path(), + &mock.uri(), + &["--dry-run", "--yes", "--vex", "out.vex.json"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout + .lines() + .any(|l| l == "Skipping VEX generation (--dry-run: nothing was applied)."), + "got {stdout:?}" + ); + assert!(!tmp.path().join("out.vex.json").exists()); +} + +/// The human `--vex` success line names the statement count and the +/// output path. With `--vex-no-verify` the manifest is trusted, so the +/// empty-crawl early return still writes the document (no network at all). +#[test] +fn scan_human_vex_success_prints_wrote_line() { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + // Manifest with one vulnerable record; nothing installed. + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "patches": { + "pkg:npm/vuln-pkg@1.0.0": { + "uuid": UUID, + "exportedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + "GHSA-aaaa-bbbb-cccc": { + "cves": ["CVE-2024-0001"], + "summary": "s", + "severity": "high", + "description": "d" + } + }, + "description": "seed", + "license": "MIT", + "tier": "free", + } + } + })) + .unwrap(), + ) + .unwrap(); + let vex_path = tmp.path().join("scan.vex.json"); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &[ + "--vex", + vex_path.to_str().unwrap(), + "--vex-no-verify", + "--vex-product", + "pkg:npm/my-app@1.0.0", + ], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let wrote = stdout + .lines() + .find(|l| l.starts_with("Wrote OpenVEX document with 1 statement to")) + .unwrap_or_else(|| panic!("the human VEX success line must print; got {stdout:?}")); + assert!( + wrote.contains(vex_path.to_str().unwrap()), + "the success line must name the output path; got {wrote:?}" + ); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).expect("VEX doc written")) + .expect("valid OpenVEX JSON"); + assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0"); + assert_eq!(doc["statements"].as_array().map(|s| s.len()), Some(1)); +} + +// --------------------------------------------------------------------------- +// Hosted mode, layout refusals and corrupt lockfiles +// --------------------------------------------------------------------------- + +/// The hosted human arm shares the table/update block with the +/// agent/vendored arms, then runs the engine without prompting (scan never +/// prompts), dry run or not. +#[tokio::test] +async fn scan_hosted_human_prints_table_updates_and_redirects() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &["CVE-2024-0001"], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + mount_forbidden_reference(&mock, MINIMIST).await; + let tmp = minimist_project(b"x\n"); + // The lock pins an OLDER hosted patch: the shared update detection must + // flag the newer offer in hosted mode too. + seed_hosted_pin(tmp.path(), MINIMIST, OLD_UUID); + + // v5: a bare scan is hosted. + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("VULNERABILITIES") && stdout.contains("CVE-2024-0001"), + "the results table must print in hosted mode; got {stdout:?}" + ); + assert!( + stdout.contains("Summary: 1 package with 1 free patch"), + "the summary must print in hosted mode; got {stdout:?}" + ); + assert!( + stdout.contains("[UPDATE]"), + "update detection must run in hosted mode; got {stdout:?}" + ); + assert!( + !stderr.contains("Non-interactive mode detected"), + "scan never prompts; got {stderr:?}" + ); + assert_eq!( + reference_posts(&recorded(&mock).await), + 1, + "the engine resolved the reference" + ); + + // `--dry-run` previews without confirming (the engine honors it itself). + let (code, _stdout, stderr) = + run_scan_api(tmp.path(), &mock.uri(), &["--mode", "hosted", "--dry-run"]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + !stderr.contains("Non-interactive mode detected"), + "a hosted dry run never prompts; got {stderr:?}" + ); +} + +/// Hosted PATHs name project directories: each runs as its own scan (its +/// own discovery and redirect), under a `== ==` header. +#[tokio::test] +async fn scan_hosted_paths_run_once_per_project_directory() { + let mock = MockServer::start().await; + mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await; + mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await; + mount_forbidden_reference(&mock, MINIMIST).await; + + let tmp = tempfile::tempdir().unwrap(); + for app in ["apps/a", "apps/b"] { + let dir = tmp.path().join(app); + std::fs::create_dir_all(&dir).unwrap(); + write_root_package_json(&dir); + write_npm_package(&dir, "minimist", "1.2.2", b"x\n"); + } + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["apps/*"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + // glob rebuilds matches with the native separator (`apps\a` on Windows). + for app in ["a", "b"] { + let header = format!("== {} ==", Path::new("apps").join(app).display()); + assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); + } + assert_eq!( + stdout + .matches("Switched 0 packages to hosted patches") + .count(), + 2, + "{stdout}" + ); + let reqs = recorded(&mock).await; + assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); + assert_eq!(reference_posts(&reqs), 2, "one redirect per directory"); +} + +/// The PnP layout refusal rides a NON-empty human scan (polyglot repo): +/// another ecosystem's discovery does not silently bless the structurally +/// invisible npm half. +#[tokio::test] +async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + + let tmp = tempfile::tempdir().unwrap(); + // yarn-berry PnP shape: yarn.lock + .pnp.cjs, no node_modules. + write_root_package_json(tmp.path()); + std::fs::write(tmp.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + std::fs::write(tmp.path().join(".pnp.cjs"), "// stub PnP loader\n").unwrap(); + // …plus one installed gem so the scan is NON-empty. + std::fs::create_dir_all( + tmp.path() + .join("vendor/bundle/ruby/3.0.0/gems/rack-2.2.0/lib"), + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &[]); + assert_eq!( + code, 0, + "refusals never flip the exit; stdout={stdout}; stderr={stderr}" + ); + assert!( + stderr.contains("Found 1 package ("), + "the gem must be discovered (non-empty path); got {stderr:?}" + ); + assert!( + stderr + .lines() + .any(|l| l.starts_with("Warning: ") && l.contains("Plug'n'Play")), + "the PnP refusal must print on the non-empty path, naming the layout; got {stderr:?}" + ); + + // The scan genuinely queried for the gem: the other ecosystem's + // discovery ran while the npm half was refused. + let bodies = batch_bodies(&recorded(&mock).await); + assert_eq!(bodies.len(), 1, "one batch POST for the gem"); + assert!( + bodies[0].contains("pkg:gem/rack@2.2.0"), + "the batch body must carry the crawled gem purl; got {}", + bodies[0] + ); +} + +/// A corrupt `bun.lockb` is a stderr warning naming the file, exit 0, on +/// the empty default scan (where the generic "No packages found" hint +/// still prints) and on a non-empty hosted scan before the driver runs. +/// The `bun_lockb_invalid` code is pinned in `scan_failure_paths.rs`. +#[tokio::test] +async fn scan_human_invalid_bun_lockb_warns_on_stderr() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + for installed in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + write_invalid_bun_lockb_project(tmp.path()); + let (code, stdout, stderr) = if installed { + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + run_scan_api(tmp.path(), &mock.uri(), &["--mode", "hosted"]) + } else { + run_scan(tmp.path(), &[]) + }; + assert_eq!( + code, 0, + "installed={installed}: stdout={stdout}; stderr={stderr}" + ); + assert!( + stderr + .lines() + .any(|l| l.starts_with("Warning: ") && l.contains("bun.lockb")), + "installed={installed}: the warning must name the file; got {stderr:?}" + ); + if !installed { + assert!(stdout.contains("No packages found"), "{stdout:?}"); + } + } +} diff --git a/crates/socket-patch-cli/tests/scan/scan_tty.rs b/crates/socket-patch-cli/tests/scan/scan_tty.rs new file mode 100644 index 000000000..7cbbe0476 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan/scan_tty.rs @@ -0,0 +1,343 @@ +//! `scan` on a real terminal (#1089): it never prompts, never opens the +//! select menu under `--json`, and renders the live status line cleanly. +//! Unix only (`portable_pty`); the built binary runs in the hermetic +//! environment. + +use std::path::Path; +use std::time::Duration; + +use portable_pty::{native_pty_system, PtySize}; +use wiremock::MockServer; + +use crate::common; +use crate::scan_fixture::*; + +/// Spawn the binary in a PTY, send `input`, and collect all output +/// until exit (the `interactive_prompts_e2e.rs` harness pattern: +/// reader thread + kill-after-timeout watchdog, no polling). +fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { + run_in_pty_with(args, cwd, &[], "", input, timeout) +} + +/// [`run_in_pty`] with extra child `env`, first writing `typeahead` +/// straight after spawn — keystrokes a user types while the scan is +/// still running, before any prompt is on screen. +fn run_in_pty_with( + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], + typeahead: &str, + input: &str, + timeout: Duration, +) -> (i32, String) { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + // The hermetic environment (`tests/common/hermetic.rs`), which also + // forces the update notifier off: this child gets a REAL terminal, + // so the stderr-TTY guard does not protect it. + let mut cmd = common::hermetic::pty_binary_command(); + for a in args { + cmd.arg(a); + } + cmd.cwd(cwd); + // These tests assert VT rendering, even when the parent has TERM=dumb. + cmd.env("TERM", "xterm-256color"); + for (k, v) in env { + cmd.env(k, v); + } + + let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); + drop(pair.slave); + + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); + + let mut killer = child.clone_killer(); + std::thread::spawn(move || { + std::thread::sleep(timeout); + let _ = killer.kill(); + }); + + let mut writer = pair.master.take_writer().expect("take writer"); + if !typeahead.is_empty() { + use std::io::Write as _; + let _ = writer.write_all(typeahead.as_bytes()); + let _ = writer.flush(); + } + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); + drop(writer); + + let status = child.wait().expect("child.wait"); + drop(pair.master); + + let output = reader_handle.finish(); + ( + status.exit_code() as i32, + String::from_utf8_lossy(&output).to_string(), + ) +} + +/// v5 scan never prompts, even on a TTY: agent mode applies with no +/// input at all. +#[tokio::test(flavor = "multi_thread")] +async fn scan_on_a_tty_applies_without_prompting() { + let mock = MockServer::start().await; + let purl = "pkg:npm/silent-target@1.0.0"; + let before = b"before\n"; + mount_one_patch_api(&mock, purl, before).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--mode", + "agent", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + + assert_eq!(code, 0, "output:\n{output}"); + assert!( + !output.contains("[Y/n]") && !output.contains("Download and apply"), + "scan must not prompt; got:\n{output}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "the apply must proceed unattended" + ); +} + +/// `scan --json` on a real terminal must never open the interactive +/// "Select one" menu over its machine-read output: with several free +/// patches for one package it picks the top-ranked one, like a non-TTY +/// run, and finishes on its own. +#[tokio::test(flavor = "multi_thread")] +async fn scan_json_on_a_tty_never_opens_the_select_menu() { + let mock = MockServer::start().await; + let purl = MINIMIST; + let second = UUID_B; + let offer = |uuid: &str, cve: &str, title: &str| { + serde_json::json!({ "uuid": uuid, "purl": purl, "tier": "free", "cveIds": [cve], + "ghsaIds": [], "severity": "high", "title": title }) + }; + let packages = serde_json::json!([{ + "purl": purl, + "patches": [offer(UUID, "CVE-2024-1", "a"), offer(second, "CVE-2024-2", "b")], + }]); + mount_batch(&mock, packages, false).await; + let patch = |uuid: &str, published: &str| { + serde_json::json!({ + "uuid": uuid, "purl": purl, "publishedAt": published, + "description": "d", "license": "MIT", "tier": "free", + "vulnerabilities": {}, + }) + }; + let details = serde_json::json!({ + "patches": [patch(UUID, "2024-01-01T00:00:00Z"), patch(second, "2025-01-01T00:00:00Z")], + "canAccessPaidPatches": false, + }); + mount_by_package_body(&mock, purl, details).await; + + let tmp = minimist_project(b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--json", + "--mode", + "agent", + "--dry-run", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "", + Duration::from_secs(30), + ) + }) + .await + .expect("spawn_blocking join"); + + assert!( + !output.contains("Select one") && !output.contains("Multiple patches available"), + "no interactive menu under --json; got:\n{output}" + ); + assert_eq!(code, 0, "the run must finish on its own; output:\n{output}"); + // The pty merges stdout and stderr; the envelope is the only `{…}`. + let text = output.replace("\r\n", "\n"); + let json_text = + &text[text.find('{').expect("JSON envelope")..=text.rfind('}').expect("JSON envelope end")]; + let json: serde_json::Value = serde_json::from_str(json_text) + .unwrap_or_else(|e| panic!("envelope must parse ({e}); got:\n{output}")); + assert_eq!(json["status"], "success", "{json}"); + let planned = json["events"] + .as_array() + .unwrap_or_else(|| panic!("events must be an array: {json}")); + assert_eq!(planned.len(), 1, "exactly one patch selected: {json}"); + assert_eq!( + planned[0]["uuid"], second, + "the top-ranked (newer) patch is picked, not the first listed: {json}" + ); +} + +/// The live status line on a real terminal: every progress message is +/// replaced (never overwritten in place), so what the user sees is the +/// result lines only — no "Found 1 patch for 1 packagesatch 1/1)" +/// residue from the longer "Querying API ... (batch 1/1)" line. +#[tokio::test(flavor = "multi_thread")] +async fn scan_progress_on_a_tty_renders_without_stale_tail() { + let mock = MockServer::start().await; + let purl = MINIMIST; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = minimist_project(b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + assert_eq!(code, 0, "output:\n{output}"); + + // The raw stream really used the live line (so this test would + // catch a regression to bare `\r` rewrites)... + assert!( + output.contains("\r\x1b[2KQuerying API for patches... (batch 1/1)"), + "expected a live status update; raw={output:?}" + ); + // ...and the screen shows only clean result lines. + let screen = crate::pty_io::render(output.as_bytes()); + assert!( + screen.iter().any(|l| l == "Found 1 package (1 npm)"), + "screen:\n{}", + screen.join("\n") + ); + assert!( + screen.iter().any(|l| l == "Found 1 patch for 1 package"), + "screen:\n{}", + screen.join("\n") + ); + for transient in [ + "Scanning packages", + "Querying API", + "Fetching patch details", + ] { + assert!( + !screen.iter().any(|l| l.contains(transient)), + "transient status {transient:?} must not stay on screen:\n{}", + screen.join("\n") + ); + } +} + +/// Under `SOCKET_DEBUG` core prints `[socket-patch debug] ...` lines +/// straight to stderr. The live status line is off then, so those +/// lines never land glued onto the end of a progress message. +#[tokio::test(flavor = "multi_thread")] +async fn scan_debug_mode_on_a_tty_keeps_debug_lines_off_the_status() { + let mock = MockServer::start().await; + let purl = MINIMIST; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = minimist_project(b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty_with( + &[ + "scan", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + &[("SOCKET_DEBUG", "1")], + "", + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + assert_eq!(code, 0, "output:\n{output}"); + + let screen = crate::pty_io::render(output.as_bytes()); + let debug: Vec<&String> = screen + .iter() + .filter(|l| l.contains("[socket-patch debug]")) + .collect(); + assert!( + !debug.is_empty(), + "SOCKET_DEBUG must produce debug lines; screen:\n{}", + screen.join("\n") + ); + for line in debug { + assert!( + line.starts_with("[socket-patch debug]"), + "a debug line was glued onto other output: {line:?}" + ); + } + assert!( + !output.contains("Querying API for patches..."), + "no transient status is drawn in debug mode; raw={output:?}" + ); + assert!( + screen.iter().any(|l| l == "Found 1 patch for 1 package"), + "the result lines still print; screen:\n{}", + screen.join("\n") + ); +} diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index aae641969..2be739456 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -26,7 +26,8 @@ hermetic CLI suites that need no Bun binary (`tests/vendor/in_process_vendor_bun.rs`, `tests/in_process_vendor_bun_takeover.rs`, the bun cases of `tests/in_process_redirect.rs`, `tests/covgap_commands_scan_hosted.rs`, -`tests/covgap_commands_scan_mod.rs`, `tests/rollback/rollback_failure_paths.rs`, +`tests/scan/scan_failure_paths.rs`, `tests/scan/scan_render.rs`, +`tests/rollback/rollback_failure_paths.rs`, `tests/rollback/rollback_render.rs`, `tests/scan_vendor_e2e.rs`, `tests/get/get_modes_e2e.rs`, `tests/repair/repair_vendor_flavors_e2e.rs`). The diff --git a/scripts/ci-test-durations.json b/scripts/ci-test-durations.json index 2de7b2585..cf6c66333 100644 --- a/scripts/ci-test-durations.json +++ b/scripts/ci-test-durations.json @@ -39,7 +39,6 @@ "coverage_fix_scan_hosted_dryrun_vendored": 1.61, "covgap_api_blob_fetcher": 4.04, "covgap_commands_scan_hosted": 1.46, - "covgap_commands_scan_mod": 1.81, "covgap_commands_vex": 0.2, "covgap_crawlers_composer_crawler": 0.01, "covgap_crawlers_npm_crawler": 0.04, @@ -218,7 +217,7 @@ "repair": 10.03, "rollback": 6.61, "rollback_new_file_e2e": 0.01, - "scan": 9.08, + "scan": 10.89, "scan_api_retry_e2e": 2.42, "scan_pnpm_relocated_store_cwd_e2e": 0.07, "scan_requirements_lock_only": 2.68, From cffeede350f546aba34581842e8af30346458b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:17:39 +0000 Subject: [PATCH 3/3] CI: tighten scan test review findings - Drop the moved mode_fold tests: cli_parse_scan.rs already runs resolve_mode_flags on --mode hosted/vendored --sync, and they mutated process env inside the shared scan binary. cli_parse_scan.rs now also requires the error to name the mode. - The vendored-skip JSON test requires the one skipped event coded vendored, and the human retained-wiring check reads the warning line. - The global-scope usage error checks the envelope invariants. - Reuse vlt_hosted_common::warning_detail, share the mock-API flags between the subprocess and PTY runners, and drop the PTY runner's unused typeahead path. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/tests/cli_parse_scan.rs | 5 +- .../tests/scan/scan_failure_paths.rs | 106 +++--------------- .../tests/scan/scan_fixture.rs | 13 ++- .../tests/scan/scan_render.rs | 4 +- .../socket-patch-cli/tests/scan/scan_tty.rs | 61 ++-------- 5 files changed, 42 insertions(+), 147 deletions(-) diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index ab0ace2eb..49d2e8d7c 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -622,7 +622,10 @@ fn mode_hosted_with_sync_errors() { for mode in ["hosted", "vendored"] { let mut args = parse_scan(&["--mode", mode, "--sync"]); let err = resolve_mode_flags(&mut args).expect_err("cross-mode --sync"); - assert!(err.contains("cannot be used with --sync"), "{err}"); + assert!( + err.contains(&format!("--mode {mode} cannot be used with --sync")), + "the error names the mode through cli_name(): {err}" + ); } } diff --git a/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs index ff0c38642..f7e1c9a4a 100644 --- a/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs +++ b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs @@ -5,7 +5,7 @@ //! purl, a file). The human output of the same paths is pinned in //! `scan_render.rs`. //! -//! 1. `resolve_mode_flags`' cross-mode conflicts and the early errors; +//! 1. the early errors (`--offline`, usage errors); //! 2. hosted `--prune` and the paid-tier counters; //! 3. detail-fetch failures (total and partial) and empty detail answers; //! 4. vendor-owned purls, report-only runs and the `--sync` GC; @@ -25,19 +25,6 @@ use crate::common::envelope::{ use crate::scan_fixture::*; use crate::{vlt_hosted_common as hosted, vlt_vendored}; -/// The first `warnings[]` entry with `code`; panics with the envelope when -/// there is none. -fn warning<'a>(v: &'a serde_json::Value, code: &str) -> &'a serde_json::Value { - v["warnings"] - .as_array() - .and_then(|ws| ws.iter().find(|w| w["code"] == code)) - .unwrap_or_else(|| panic!("warnings[] must carry {code}: {v:#}")) -} - -fn warning_detail<'a>(v: &'a serde_json::Value, code: &str) -> &'a str { - warning(v, code)["detail"].as_str().unwrap_or_default() -} - /// `scan --json` against the mock API; returns the exit code and the parsed /// envelope (after checking the shared envelope invariants). fn run_json(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, serde_json::Value) { @@ -53,67 +40,6 @@ fn run_json(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, serde_json::Valu (code, v) } -// --------------------------------------------------------------------------- -// resolve_mode_flags: the `--sync` cross-mode conflict -// --------------------------------------------------------------------------- -// `--sync` means `--mode agent --prune`, so it contradicts any other -// `--mode`. Clap parses each combination fine (no value-dependent conflict -// is expressible); the fold is what rejects them, naming both flags. - -mod mode_fold { - use clap::Parser; - use socket_patch_cli::commands::scan::{resolve_mode_flags, ScanArgs}; - use socket_patch_cli::{Cli, Commands}; - - /// Every env var that could reroute the parse or the fold (superset: - /// the shared scrub lists from `args.rs`). - fn with_clean_env(f: impl FnOnce() -> T) -> T { - let vars: Vec<&str> = socket_patch_cli::args::GLOBAL_ARG_ENV_VARS - .iter() - .chain(socket_patch_cli::args::LOCAL_ARG_ENV_VARS.iter()) - .copied() - .collect(); - let saved: Vec<(&str, Option)> = - vars.iter().map(|k| (*k, std::env::var(k).ok())).collect(); - for k in &vars { - std::env::remove_var(k); - } - let result = f(); - for (k, orig) in saved { - match orig { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } - result - } - - fn fold_err(extra: &[&str]) -> String { - let mut argv = vec!["socket-patch", "scan"]; - argv.extend_from_slice(extra); - let cli = with_clean_env(|| Cli::try_parse_from(&argv)).expect("parse"); - let mut args: ScanArgs = match cli.command { - Commands::Scan(a) => a, - _ => panic!("expected Scan"), - }; - resolve_mode_flags(&mut args).expect_err("cross-mode contradiction must error") - } - - /// Each `--mode` against `--sync` errors, naming the mode through - /// `cli_name()` and the conflicting boolean. - #[test] - #[serial_test::serial] - fn mode_with_sync_boolean_errors_naming_both_flags() { - for mode in ["hosted", "vendored"] { - let err = fold_err(&["--mode", mode, "--sync"]); - assert!( - err.contains(&format!("--mode {mode}")) && err.contains("--sync"), - "the error must name --mode {mode} and --sync: {err}" - ); - } - } -} - /// `--offline` and usage errors print the full envelope too. #[test] fn scan_json_early_errors_print_full_envelopes() { @@ -145,6 +71,7 @@ fn scan_hosted_global_json_is_global_scope_unsupported() { let (code, stdout, _) = run_scan(tmp.path(), &["--mode", "hosted", "--global", "--json"]); assert_eq!(code, 2, "{stdout}"); let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "scan"); assert_eq!(v["status"], "error", "{v}"); assert_eq!( envelope_error_code(&v), @@ -179,7 +106,7 @@ async fn scan_hosted_prune_zero_package_json_carries_the_warning() { assert_eq!(v["redirect"]["mode"], "hosted"); assert_eq!(v["summary"]["applied"], 0, "nothing pinned: {v}"); assert!( - warning_detail(&v, "redirect_prune_ignored").contains("--prune"), + hosted::warning_detail(&v, "redirect_prune_ignored").contains("--prune"), "the warning must name the ignored flag: {v:#}" ); assert!( @@ -372,7 +299,7 @@ async fn scan_partial_failure_with_empty_results_exits_alike() { let tmp = project(); let (json, v) = run_json(tmp.path(), &mock.uri(), &extra); assert!( - warning_detail(&v, "patch_details_failed").contains(bad), + hosted::warning_detail(&v, "patch_details_failed").contains(bad), "{v:#}" ); assert_eq!(human, json, "human and --json exit alike: {stderr}\n{v:#}"); @@ -382,8 +309,8 @@ async fn scan_partial_failure_with_empty_results_exits_alike() { // Vendor-owned purls, report-only runs and the GC // --------------------------------------------------------------------------- -/// A purl the vendored ledger owns is skipped by an agent-mode scan: no -/// download, no manifest, and no apply event for it. +/// A purl the vendored ledger owns is skipped by an agent-mode scan: one +/// `skipped` event coded `vendored`, no download and no manifest. #[tokio::test] async fn scan_agent_json_skips_vendored_purls_without_downloading() { let mock = MockServer::start().await; @@ -394,11 +321,14 @@ async fn scan_agent_json_skips_vendored_purls_without_downloading() { let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--yes"]); assert_eq!(code, 0, "{v:#}"); - assert!( - !event_triples(&v) - .iter() - .any(|(purl, action, _)| purl == MINIMIST && action != "skipped"), - "a vendor-owned purl is only ever skipped: {v:#}" + assert_eq!( + event_triples(&v) + .into_iter() + .filter(|(purl, _, _)| purl == MINIMIST) + .map(|(_, action, code)| (action, code)) + .collect::>(), + [("skipped".to_string(), "vendored".to_string())], + "a vendor-owned purl is only skipped, with the `vendored` code: {v:#}" ); assert_eq!(v["summary"]["applied"], 0, "{v:#}"); assert_eq!(view_gets(&recorded(&mock).await), 0, "{v:#}"); @@ -646,7 +576,7 @@ async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); assert_eq!(code, 0, "{v:#}"); assert_eq!(v["summary"]["applied"], 1, "{v}"); - let detail = warning_detail(&v, "content_mismatch_overwritten"); + let detail = hosted::warning_detail(&v, "content_mismatch_overwritten"); assert!( detail.contains(purl) && detail.contains("package/index.js"), "the overwrite must name the purl and the file: {v:#}" @@ -674,7 +604,7 @@ async fn scan_agent_json_over_live_hosted_wiring_warns_retained() { let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent", "--yes"]); assert_eq!(code, 0, "{v:#}"); assert!( - warning_detail(&v, "hosted_wiring_retained").contains(SILENT_TARGET), + hosted::warning_detail(&v, "hosted_wiring_retained").contains(SILENT_TARGET), "the warning names the package: {v:#}" ); assert_eq!(event_purls(&v, "applied"), [SILENT_TARGET], "{v:#}"); @@ -756,7 +686,7 @@ fn scan_invalid_bun_lockb_warns_instead_of_silent_success() { assert_eq!(v["scannedPackages"], 0, "mode={mode:?}: {v}"); assert_eq!(v["lockfileOnlyPackages"], 0, "mode={mode:?}: {v}"); assert!( - warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), + hosted::warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), "mode={mode:?}: the format error must name its file: {v:#}" ); assert!( @@ -786,7 +716,7 @@ async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); assert!( - warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), + hosted::warning_detail(&v, "bun_lockb_invalid").contains("bun.lockb"), "mode={mode:?}: {v:#}" ); } diff --git a/crates/socket-patch-cli/tests/scan/scan_fixture.rs b/crates/socket-patch-cli/tests/scan/scan_fixture.rs index 03423e7eb..f0a097f38 100644 --- a/crates/socket-patch-cli/tests/scan/scan_fixture.rs +++ b/crates/socket-patch-cli/tests/scan/scan_fixture.rs @@ -68,16 +68,21 @@ pub fn run_scan(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// [`run_scan`] against a mock API (human output unless `extra` has /// `--json`). pub fn run_scan_api(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { - let mut args = vec![ + let mut args = api_args(api_url); + args.extend_from_slice(extra); + run_scan(cwd, &args) +} + +/// The flags pointing a run at the mock API with a fake token and org. +pub fn api_args(api_url: &str) -> Vec<&str> { + vec![ "--api-url", api_url, "--api-token", "fake-token-for-test", "--org", ORG_SLUG, - ]; - args.extend_from_slice(extra); - run_scan(cwd, &args) + ] } /// [`run_scan_api`] in agent mode: v5's bare scan is hosted, and these diff --git a/crates/socket-patch-cli/tests/scan/scan_render.rs b/crates/socket-patch-cli/tests/scan/scan_render.rs index a60115b4c..a996d2ec9 100644 --- a/crates/socket-patch-cli/tests/scan/scan_render.rs +++ b/crates/socket-patch-cli/tests/scan/scan_render.rs @@ -985,8 +985,8 @@ async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { .find(|l| l.starts_with("Warning: agent-mode scan left the hosted wiring live")) .unwrap_or_else(|| panic!("the retained-wiring warning must reach stderr; got {stderr:?}")); assert!( - stderr.contains(SILENT_TARGET), - "the warning must name the package; got {warning:?}\n{stderr:?}" + warning.contains(SILENT_TARGET), + "the warning must name the package; got {warning:?}" ); assert_eq!( std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), diff --git a/crates/socket-patch-cli/tests/scan/scan_tty.rs b/crates/socket-patch-cli/tests/scan/scan_tty.rs index 7cbbe0476..dbaf88e97 100644 --- a/crates/socket-patch-cli/tests/scan/scan_tty.rs +++ b/crates/socket-patch-cli/tests/scan/scan_tty.rs @@ -16,17 +16,14 @@ use crate::scan_fixture::*; /// until exit (the `interactive_prompts_e2e.rs` harness pattern: /// reader thread + kill-after-timeout watchdog, no polling). fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { - run_in_pty_with(args, cwd, &[], "", input, timeout) + run_in_pty_with(args, cwd, &[], input, timeout) } -/// [`run_in_pty`] with extra child `env`, first writing `typeahead` -/// straight after spawn — keystrokes a user types while the scan is -/// still running, before any prompt is on screen. +/// [`run_in_pty`] with extra child `env`. fn run_in_pty_with( args: &[&str], cwd: &Path, env: &[(&str, &str)], - typeahead: &str, input: &str, timeout: Duration, ) -> (i32, String) { @@ -67,11 +64,6 @@ fn run_in_pty_with( }); let mut writer = pair.master.take_writer().expect("take writer"); - if !typeahead.is_empty() { - use std::io::Write as _; - let _ = writer.write_all(typeahead.as_bytes()); - let _ = writer.flush(); - } crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); @@ -102,17 +94,7 @@ async fn scan_on_a_tty_applies_without_prompting() { let cwd = tmp.path().to_path_buf(); let (code, output) = tokio::task::spawn_blocking(move || { run_in_pty( - &[ - "scan", - "--mode", - "agent", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], + &[&["scan", "--mode", "agent"][..], &api_args(&uri)].concat(), &cwd, "", Duration::from_secs(60), @@ -171,18 +153,10 @@ async fn scan_json_on_a_tty_never_opens_the_select_menu() { let (code, output) = tokio::task::spawn_blocking(move || { run_in_pty( &[ - "scan", - "--json", - "--mode", - "agent", - "--dry-run", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], + &["scan", "--json", "--mode", "agent", "--dry-run"][..], + &api_args(&uri), + ] + .concat(), &cwd, "", Duration::from_secs(30), @@ -230,15 +204,7 @@ async fn scan_progress_on_a_tty_renders_without_stale_tail() { let cwd = tmp.path().to_path_buf(); let (code, output) = tokio::task::spawn_blocking(move || { run_in_pty( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], + &[&["scan"][..], &api_args(&uri)].concat(), &cwd, "n\n", Duration::from_secs(60), @@ -295,18 +261,9 @@ async fn scan_debug_mode_on_a_tty_keeps_debug_lines_off_the_status() { let cwd = tmp.path().to_path_buf(); let (code, output) = tokio::task::spawn_blocking(move || { run_in_pty_with( - &[ - "scan", - "--api-url", - &uri, - "--api-token", - "fake-token-for-test", - "--org", - ORG_SLUG, - ], + &[&["scan"][..], &api_args(&uri)].concat(), &cwd, &[("SOCKET_DEBUG", "1")], - "", "n\n", Duration::from_secs(60), )