diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs index 2f9b11738..27719dac2 100644 --- a/crates/socket-patch-cli/tests/apply/apply_fixture.rs +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -206,3 +206,22 @@ pub fn write_gem_patch(root: &Path, uuid: &str, marker: &[u8]) -> PathBuf { stage_blob(root, &git_sha256(&patched), &patched); file } + +/// A root `package.json`, an empty `node_modules/` and a manifest of `n` +/// npm patches (`pkg:npm/miss-@1.0.0`) with no blob, diff or package +/// artifact anywhere under `.socket/`, so staging has no usable source. +pub fn write_sourceless_npm_patches(root: &Path, n: u32) { + write_root_package_json(root); + std::fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); + let patches: serde_json::Map = (0..n) + .map(|i| { + let uuid = format!("{i:08}-0000-4000-8000-{i:012}"); + let body = format!("sourceless {i}\n"); + ( + format!("pkg:npm/miss-{i}@1.0.0"), + patch_record(&uuid, one_file("package/index.js", b"", body.as_bytes())), + ) + }) + .collect(); + write_manifest(root, Value::Object(patches)); +} diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index 052d22914..74d126129 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -7,12 +7,16 @@ //! `Would remove` announcement, "No patches to apply.", the //! no-matching-package error block, the dry-run summary counts, //! `--verbose` per-file labels, the pnpm/bun/vlt layout notes, the -//! yarn-PnP refusal, the gem fallback-home skip warning and the -//! `--dry-run --vex` skip line. +//! yarn-PnP refusal, the gem fallback-home skip warning, the +//! `--dry-run --vex` skip line, and the staging layer's offline +//! no-local-source report and download result line. use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::apply_fixture::*; +use crate::common::git_sha256; // ═══════════ go drift and reconcile reports ═══════════ @@ -429,3 +433,96 @@ fn dry_run_with_vex_announces_the_skip() { "stdout={stdout}" ); } + +// ═══════════ staging: offline report and download result ═══════════ + +/// The offline no-local-source report: the header carries the TOTAL count, +/// at most five purls are listed, the rest is "... and N more", and the +/// `repair` remedy closes it. +#[test] +fn offline_missing_report_caps_purls_and_names_repair() { + let tmp = tempfile::tempdir().unwrap(); + write_sourceless_npm_patches(tmp.path(), 7); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("Error: 7 patches have no local source and --offline is set:"), + "stderr={stderr}" + ); + // Five distinct fixture purls are listed (in no fixed order). + let fixture: Vec = (0..7) + .map(|i| format!(" - pkg:npm/miss-{i}@1.0.0")) + .collect(); + let listed: std::collections::BTreeSet<&str> = + stderr.lines().filter(|l| l.starts_with(" - ")).collect(); + assert_eq!( + listed.len(), + 5, + "the list is capped at five; stderr={stderr}" + ); + assert!( + listed.iter().all(|l| fixture.iter().any(|f| f == l)), + "only the fixture purls are listed; stderr={stderr}" + ); + assert!(stderr.contains(" ... and 2 more"), "stderr={stderr}"); + assert!( + stderr.contains("Run `socket-patch repair` to download missing artifacts."), + "stderr={stderr}" + ); +} + +/// A human-mode online apply with a cold cache: the download progress is +/// transient (nothing permanent on a non-terminal stderr) and only the +/// "Downloaded 1 blob" result line stays, on stderr, never stdout. The +/// fetch itself is pinned through `--json` in `apply_network.rs`. +#[tokio::test] +async fn online_staging_prints_result_line_not_progress() { + const ORG: &str = "test-org"; + let before = b"before\n"; + let after = b"after\n"; + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/blob/{}", + git_sha256(after) + ))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(after.to_vec())) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + let index = install_npm_pkg(tmp.path(), "fallback-test", "1.0.0", Some(before)); + write_manifest( + tmp.path(), + json!({ + "pkg:npm/fallback-test@1.0.0": patch_record( + "11111111-1111-4111-8111-111111111111", + one_file("package/index.js", before, after), + ) + }), + ); + + let uri = mock.uri(); + let (code, stdout, stderr) = run_apply( + tmp.path(), + &[], + &[ + ("SOCKET_API_URL", uri.as_str()), + ("SOCKET_API_TOKEN", "fake-token-for-test"), + ("SOCKET_ORG_SLUG", ORG), + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_eq!(std::fs::read(&index).unwrap(), after); + assert!(stderr.contains("Downloaded 1 blob"), "stderr={stderr}"); + assert!( + !stderr.contains("Downloading missing patch artifacts"), + "progress is transient; stderr={stderr}" + ); + assert!( + !stdout.contains("Downloading") && !stdout.contains("Downloaded"), + "stdout={stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs index f5ebf98ce..ffacb6acf 100644 --- a/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs +++ b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs @@ -21,12 +21,8 @@ //! unlike vendor, which records a `failed` / `apply_failed` event for the //! patch; see `vendor/vendor_silent_errors.rs`). -use std::path::Path; - -use serde_json::json; - -use crate::apply_fixture::{patch_record, run_apply, write_manifest}; -use crate::common::envelope::{json_string, parse_json_envelope}; +use crate::apply_fixture::{run_apply, write_sourceless_npm_patches}; +use crate::common::envelope::{codes_in, json_string, parse_json_envelope}; /// Non-error stderr lines: drop the unconditional core API-token warning /// (both its lead line and its "Got: ... Continuing anyway" continuation) @@ -43,20 +39,6 @@ fn stderr_chatter(stderr: &str) -> Vec { .collect() } -/// Valid manifest with one npm patch entry and NO blob/diff/package -/// artifact anywhere under `.socket/`: staging has no usable source. -fn write_sourceless_manifest(root: &Path) { - write_manifest( - root, - json!({ - "pkg:npm/left-pad@1.3.0": patch_record( - "11111111-1111-4111-8111-111111111111", - json!({ "index.js": { "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) } }), - ) - }), - ); -} - /// A guaranteed-unreachable local endpoint: bind an ephemeral port, then /// release it, so every request fails fast with connection-refused. fn dead_endpoint() -> String { @@ -75,7 +57,7 @@ fn dead_endpoint() -> String { #[test] fn apply_silent_offline_missing_source_keeps_error_output() { let tmp = tempfile::tempdir().unwrap(); - write_sourceless_manifest(tmp.path()); + write_sourceless_npm_patches(tmp.path(), 1); let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"], &[]); assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); @@ -96,7 +78,7 @@ fn apply_silent_offline_missing_source_keeps_error_output() { #[test] fn apply_silent_online_download_failure_keeps_error_output() { let tmp = tempfile::tempdir().unwrap(); - write_sourceless_manifest(tmp.path()); + write_sourceless_npm_patches(tmp.path(), 1); let url = dead_endpoint(); let token = format!("sktsec_{}_api", "x".repeat(44)); @@ -142,7 +124,8 @@ fn apply_silent_online_download_failure_keeps_error_output() { #[test] fn apply_json_offline_missing_source_keeps_stderr_clean() { let tmp = tempfile::tempdir().unwrap(); - write_sourceless_manifest(tmp.path()); + write_sourceless_npm_patches(tmp.path(), 1); + let manifest_before = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); let (code, stdout, stderr) = run_apply(tmp.path(), &["--json", "--offline"], &[]); assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); @@ -152,6 +135,26 @@ fn apply_json_offline_missing_source_keeps_stderr_clean() { Some("partialFailure"), "the pinned envelope shape for the offline bail, got {v}" ); + // The run warning is the envelope's only explanation of the bail. + let warnings = codes_in(&v["warnings"]); + assert!( + warnings.iter().any(|c| c == "offline_missing_sources") + && !warnings.iter().any(|c| c == "sources_download_failed"), + "the offline bail reports offline_missing_sources, got {v}" + ); + assert_eq!(v["summary"]["applied"], 0, "nothing is applied, got {v}"); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + manifest_before, + "the bail must leave the manifest untouched" + ); + assert_eq!( + std::fs::read_dir(tmp.path().join(".socket/blobs")) + .unwrap() + .count(), + 0, + "the bail must not stage anything into .socket/blobs" + ); let chatter = stderr_chatter(&stderr); assert!( chatter.is_empty(), diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index cc5de0306..d2e4dc542 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -90,6 +90,16 @@ fn write_manifest(root: &Path, purl: &str) { /// Run `socket-patch apply --offline --json --ecosystems ` and return /// the exit code + parsed envelope. fn run_apply_for_ecosystem(cwd: &Path, ecosystem: &str) -> (i32, Value) { + run_apply_for_ecosystem_env(cwd, ecosystem, &[]) +} + +/// [`run_apply_for_ecosystem`] with `envs` set after the hermetic scrub +/// (cache locations such as `DENO_DIR`). +fn run_apply_for_ecosystem_env( + cwd: &Path, + ecosystem: &str, + envs: &[(String, String)], +) -> (i32, Value) { let mut cmd = hermetic::command(&binary()); cmd.args([ "apply", @@ -100,6 +110,9 @@ fn run_apply_for_ecosystem(cwd: &Path, ecosystem: &str) -> (i32, Value) { "--silent", ]) .current_dir(cwd); + for (k, v) in envs { + cmd.env(k, v); + } let out = cmd.output().expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let env: Value = serde_json::from_str(stdout.trim()) @@ -283,6 +296,28 @@ fn dispatch_branch_nuget() { assert_apply_dispatched(code, &env, "nuget", &[purl]); } +/// deno is the one ecosystem whose PURL type (`jsr`) differs from its +/// `--ecosystems` token, so this proves `partition_purls` maps `pkg:jsr/` +/// to `Ecosystem::Deno`. The `deno.json` marker lets the local-mode gate in +/// `get_jsr_cache_paths` probe cache paths, and the pinned empty `DENO_DIR` +/// keeps that probe off the real user cache. +#[test] +fn dispatch_branch_deno() { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + std::fs::write(tmp.path().join("deno.json"), "{}").unwrap(); + let deno_dir = tmp.path().join("deno-cache"); + std::fs::create_dir_all(&deno_dir).unwrap(); + let purl = "pkg:jsr/@__dispatch_test__/pkg@1.0.0"; + write_manifest(tmp.path(), purl); + let envs = [( + "DENO_DIR".to_string(), + deno_dir.to_str().unwrap().to_string(), + )]; + let (code, env) = run_apply_for_ecosystem_env(tmp.path(), "deno", &envs); + assert_apply_dispatched(code, &env, "deno", &[purl]); +} + // --------------------------------------------------------------------------- // Multiple ecosystems in one CSV --ecosystems value. Each of the three // branches must fire: all three PURLs must surface as skipped events. @@ -411,20 +446,22 @@ struct RollbackFixture { /// Extra env vars the crawler needs (cache locations such as GOMODCACHE / /// MAVEN_REPO_LOCAL). envs: Vec<(String, String)>, - /// Run the rollback in `--global` mode. Required for ecosystems whose - /// project-local backend is a *redirect* (golang): in local mode the - /// patched bytes live in a project-local copy and the module cache is left - /// pristine, so rollback drops the redirect rather than restoring the cache - /// file in place. Byte-restore — the contract `assert_rollback_restored` - /// verifies — only happens on the global/in-place path (the analog of - /// cargo's `vendor/` in-place layout). Defaults to local mode. - global: bool, + /// Scope flags for the rollback: empty for local mode, `--global` for + /// ecosystems whose project-local backend is a *redirect* (golang), or + /// `--global-prefix ` to point the crawler at a staged cache (deno). + /// For golang, in local mode the patched bytes live in a project-local + /// copy and the module cache is left pristine, so rollback drops the + /// redirect rather than restoring the cache file in place. Byte-restore + /// — the contract `assert_rollback_restored` verifies — only happens on + /// the global/in-place path (the analog of cargo's `vendor/` in-place + /// layout). + scope_args: Vec, } fn run_rollback( cwd: &Path, ecosystem: &str, - global: bool, + scope_args: &[String], envs: &[(String, String)], ) -> (i32, Value) { let mut cmd = hermetic::command(&binary()); @@ -436,9 +473,7 @@ fn run_rollback( ecosystem, "--silent", ]); - if global { - cmd.arg("--global"); - } + cmd.args(scope_args); cmd.current_dir(cwd); // The hermetic command scrubbed BEFORE the fixture envs land, so a // fixture-supplied SOCKET_-prefixed var survives the prefix sweep @@ -456,7 +491,7 @@ fn run_rollback( /// Drive a genuine rollback for `fixture` and assert it discovered the /// package, restored the file, and reported success for the exact PURL. fn assert_rollback_restored(cwd: &Path, ecosystem: &str, fixture: &RollbackFixture) { - let (code, env) = run_rollback(cwd, ecosystem, fixture.global, &fixture.envs); + let (code, env) = run_rollback(cwd, ecosystem, &fixture.scope_args, &fixture.envs); assert_eq!( code, 0, "rollback --ecosystems={ecosystem}: expected exit 0; env={env}" @@ -530,7 +565,7 @@ fn assert_rollback_restored(cwd: &Path, ecosystem: &str, fixture: &RollbackFixtu /// file is left untouched (still PATCHED). Mirrors `assert_apply_not_dispatched` /// for the separate `find_packages_for_rollback` code path. fn assert_rollback_not_dispatched(cwd: &Path, ecosystem: &str, fixture: &RollbackFixture) { - let (code, env) = run_rollback(cwd, ecosystem, fixture.global, &fixture.envs); + let (code, env) = run_rollback(cwd, ecosystem, &fixture.scope_args, &fixture.envs); assert_eq!( code, 0, "rollback --ecosystems={ecosystem}: out-of-scope rollback should be a clean no-op (exit 0); env={env}" @@ -585,7 +620,7 @@ fn fixture_npm(root: &Path) -> RollbackFixture { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], } } @@ -617,7 +652,7 @@ fn fixture_pypi(root: &Path) -> RollbackFixture { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], } } @@ -639,7 +674,7 @@ fn fixture_gem(root: &Path) -> RollbackFixture { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], } } @@ -719,7 +754,7 @@ fn rollback_dispatch_branch_cargo() { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], }; assert_rollback_restored(root, "cargo", &fixture); } @@ -745,7 +780,7 @@ fn rollback_dispatch_branch_golang() { // and leaves the module cache pristine, so it never restores cache // bytes. Drive the global/in-place path to exercise byte-restore — the // go analog of the cargo test's `vendor/` in-place layout. - global: true, + scope_args: vec!["--global".to_string()], }; assert_rollback_restored(root, "golang", &fixture); } @@ -769,7 +804,7 @@ fn rollback_dispatch_branch_maven() { purl: purl.to_string(), verify_file, envs: vec![("MAVEN_REPO_LOCAL".to_string(), repo.display().to_string())], - global: false, + scope_args: vec![], }; assert_rollback_restored(root, "maven", &fixture); } @@ -797,7 +832,7 @@ fn rollback_dispatch_branch_composer() { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], }; assert_rollback_restored(root, "composer", &fixture); } @@ -936,7 +971,38 @@ fn rollback_dispatch_branch_nuget() { purl: purl.to_string(), verify_file, envs: vec![], - global: false, + scope_args: vec![], }; assert_rollback_restored(root, "nuget", &fixture); } + +/// deno: a JSR cache layout (`/@///`, the +/// scope keeps its `@` on disk) reached through `--global-prefix`, which is +/// how users point the deno crawler at a materialized JSR tree +/// (`DenoCrawler::get_jsr_cache_paths` returns the prefix verbatim). +#[test] +fn rollback_dispatch_branch_deno() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let jsr_root = root.join("jsr-root"); + let pkg = jsr_root + .join("@__rollback_dispatch__") + .join("pkg") + .join("1.0.0"); + std::fs::create_dir_all(&pkg).unwrap(); + let verify_file = pkg.join("mod.ts"); + std::fs::write(&verify_file, PATCHED).unwrap(); + let purl = "pkg:jsr/@__rollback_dispatch__/pkg@1.0.0"; + write_rollback_manifest(root, purl, "mod.ts"); + let fixture = RollbackFixture { + purl: purl.to_string(), + verify_file, + envs: vec![], + scope_args: vec![ + "--global-prefix".to_string(), + jsr_root.to_str().unwrap().to_string(), + ], + }; + assert_rollback_restored(root, "deno", &fixture); +} diff --git a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs b/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs deleted file mode 100644 index d64eae552..000000000 --- a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Coverage-gap tests for `commands/fetch_stage.rs` — the user-facing -//! diagnostics and server-shaped failure arms no existing suite drives: -//! -//! - the non-quiet offline "no local source" report: the count header, -//! the 5-PURL cap, the "... and N more" continuation, and the -//! the `repair` remedy hint (`report_offline_missing`); -//! - the online staging result lines (progress is transient) and the -//! diff→per-file-blob fallback; -//! - the vendor mem-stager's per-file failure arms for malformed patch -//! view responses (missing `blobContent`, invalid `afterHash`, -//! undecodable base64) and the non-quiet "could not fetch patch -//! content" stderr block listing the failed PURLs. -//! -//! The `--silent` variants of the offline / download-failure diagnostics -//! (errors only, never nothing) are pinned by -//! `apply/apply_silent_staging_errors.rs`; this suite covers the -//! human-mode (non-quiet) output shape and the mem-stager arms. - -use std::path::Path; - -use crate::common::{binary, git_sha256, hermetic_command}; - -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 BEFORE: &[u8] = b"before\n"; -const AFTER: &[u8] = b"after\n"; - -/// Spawn the built binary in `root` with the hermetic `SOCKET_*` -/// environment (ambient vars scrubbed, telemetry kill-switch forced) and -/// `extra_env` injected last so deliberate seeds survive the scrub. -fn run_cli_env(root: &Path, argv: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = hermetic_command(&binary()); - cmd.args(argv).current_dir(root); - for (k, v) in extra_env { - cmd.env(k, v); - } - let out = cmd.output().expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - ) -} - -// --------------------------------------------------------------------------- -// report_offline_missing: header count, 5-PURL cap, "... and N more", -// repair hint — the full non-quiet body. -// --------------------------------------------------------------------------- - -/// SEVEN sourceless npm patches under `--offline` in human mode: the -/// error header carries the total, at most FIVE PURLs are listed, the -/// remainder is summarized as "... and 2 more", and the `repair` hint -/// closes the report. -#[test] -fn apply_offline_nonquiet_lists_capped_missing_purls_and_repair_hint() { - let tmp = tempfile::tempdir().unwrap(); - // Root project marker + empty node_modules (the same shape - // `apply_invariants.rs::write_project` uses to reach the offline gate). - std::fs::write( - tmp.path().join("package.json"), - r#"{"name":"covgap-offline","version":"0.0.0"}"#, - ) - .unwrap(); - std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); - - // Manifest with 7 patches and NO blobs/diffs/packages dirs anywhere: - // every patch is sourceless. Distinct purls, hashes, and v4 uuids. - let mut patches = serde_json::Map::new(); - for i in 0..7u32 { - let purl = format!("pkg:npm/miss-{i}@1.0.0"); - let uuid = format!("{i}{i}{i}{i}{i}{i}{i}{i}-{i}{i}{i}{i}-4{i}{i}{i}-8{i}{i}{i}-{i}{i}{i}{i}{i}{i}{i}{i}{i}{i}{i}{i}"); - let before_hash = format!("{:064}", i * 2 + 1); - let after_hash = format!("{:064}", i * 2 + 2); - patches.insert( - purl, - serde_json::json!({ - "uuid": uuid, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": before_hash, - "afterHash": after_hash, - } - }, - "vulnerabilities": {}, - "description": "sourceless", - "license": "MIT", - "tier": "free", - }), - ); - } - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(), - ) - .unwrap(); - - let (code, _stdout, stderr) = run_cli_env(tmp.path(), &["apply", "--offline"], &[]); - assert_eq!( - code, 1, - "offline + 7 sourceless patches must fail; stderr={stderr}" - ); - assert!( - stderr.contains("Error: 7 patches have no local source and --offline is set:"), - "the header must carry the TOTAL count, not the listed count; stderr={stderr}" - ); - let listed: Vec<&str> = stderr - .lines() - .filter(|l| l.starts_with(" - pkg:npm/miss-")) - .collect(); - assert_eq!( - listed.len(), - 5, - "exactly five PURLs are listed (the cap); got {listed:?}\nstderr={stderr}" - ); - assert!( - stderr.contains(" ... and 2 more"), - "the 2 unlisted patches are summarized; stderr={stderr}" - ); - assert!( - stderr.contains("Run `socket-patch repair` to download missing artifacts."), - "apply's repair remedy closes the report; stderr={stderr}" - ); -} - -// --------------------------------------------------------------------------- -// Non-quiet online staging: cold-cache blob download. -// --------------------------------------------------------------------------- - -/// A human-mode (no `--json`/`--silent`) online apply with a cold cache: -/// the download progress is a transient status line (nothing permanent on -/// a non-terminal stderr), only the "Downloaded 1 blob" result line -/// persists before the apply, and no request ever goes to a diff archive -/// route (v5 fetches per-file blobs only). `.socket/` stays untouched -/// (downloads land in the overlay tempdir). -#[tokio::test] -async fn apply_online_nonquiet_cold_cache_fetches_only_blobs() { - let before_hash = git_sha256(BEFORE); - let after_hash = git_sha256(AFTER); - - let mock = MockServer::start().await; - // Only the per-file blob endpoint is mounted. - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}" - ))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(AFTER.to_vec())) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - std::fs::write( - tmp.path().join("package.json"), - r#"{"name":"covgap-fallback-root","version":"0.0.0"}"#, - ) - .unwrap(); - let pkg = tmp.path().join("node_modules").join("fallback-test"); - std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write( - pkg.join("package.json"), - r#"{"name":"fallback-test","version":"1.0.0"}"#, - ) - .unwrap(); - std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); - - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "patches": { - "pkg:npm/fallback-test@1.0.0": { - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": before_hash, - "afterHash": after_hash, - } - }, - "vulnerabilities": {}, - "description": "cold cache target", - "license": "MIT", - "tier": "free", - } - } - })) - .unwrap(), - ) - .unwrap(); - - // Point apply at the mock via env (SOCKET_API_URL/TOKEN/ORG_SLUG); the - // equivalent --api-url/--api-token/--org flags work too. - let mock_uri = mock.uri(); - let (code, stdout, stderr) = run_cli_env( - tmp.path(), - &["apply"], - &[ - ("SOCKET_API_URL", mock_uri.as_str()), - ("SOCKET_API_TOKEN", "fake-token-for-test"), - ("SOCKET_ORG_SLUG", ORG_SLUG), - ], - ); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - - // The staging flow's progress is a transient status line (nothing - // on a non-terminal stderr); its result lines stay, on stderr (stdout - // is for results). - assert!( - !stderr.contains("Downloading missing patch artifacts"), - "progress is transient, never a permanent line; stderr={stderr}" - ); - assert!( - !stderr.contains("Failed to download"), - "the blob download succeeded; stderr={stderr}" - ); - assert!( - stderr.contains("Downloaded 1 blob"), - "the blob download's result line is printed; stderr={stderr}" - ); - let requests = mock.received_requests().await.unwrap(); - assert!( - requests.iter().all(|r| !r.url.path().contains("/diff")), - "a cold-cache apply must never request a diff archive; got {:?}", - requests - .iter() - .map(|r| r.url.path().to_string()) - .collect::>() - ); - assert!(!socket.join("diffs").exists()); - assert!( - !stdout.contains("Downloading") && !stdout.contains("Downloaded"), - "no progress on stdout; stdout={stdout}" - ); - - // The download actually applied the patch… - assert_eq!( - std::fs::read(pkg.join("index.js")).unwrap(), - AFTER, - "node_modules must carry the after-content" - ); - // …and the downloaded blob never landed in the persistent cache. - let blobs_dir = socket.join("blobs"); - if blobs_dir.exists() { - let entries: Vec<_> = std::fs::read_dir(&blobs_dir).unwrap().collect(); - assert!( - entries.is_empty(), - "downloads land in the overlay tempdir, never .socket/blobs/; found {entries:?}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs b/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs deleted file mode 100644 index d841311a7..000000000 --- a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Coverage-gap suite for the DENO branch of `ecosystem_dispatch`. -//! -//! `tests/ecosystem_dispatch_e2e.rs` charters itself as exercising "every -//! ecosystem dispatch branch", but deno is absent from both its apply and -//! rollback halves — lcov shows the deno `scan_ecosystem!` invocation has -//! never executed with a `pkg:jsr/` PURL in any test. These two tests close -//! that charter gap using the sibling suite's own oracles (adapted copies): -//! -//! * **Apply branch** — a manifest holding one `pkg:jsr/` PURL, run under -//! `apply --offline --json --ecosystems deno` with nothing installed. The -//! `skipped` / `package_not_installed` event for that exact PURL is the -//! load-bearing proof of dispatch: it appears only when `partition_purls` -//! classified the `pkg:jsr/` type to `Ecosystem::Deno` AND the -//! `--ecosystems deno` token kept it in scope (deno is the one ecosystem -//! whose PURL type, `jsr`, differs from its cli_name). -//! -//! * **Rollback branch** — a real, crawler-discoverable JSR cache layout -//! (`/@///`) staged with PATCHED bytes and -//! reached via `--global-prefix` (exactly how real users point the deno -//! crawler at a materialized JSR tree — `DenoCrawler::get_jsr_cache_paths` -//! returns the prefix verbatim). The rollback must discover the package -//! through `find_packages_for_rollback`'s deno branch, restore the file's -//! ORIGINAL bytes on disk, and report `summary.rolledBack == 1` for the exact PURL. - -use crate::common::binary; - -use std::path::Path; - -use serde_json::Value; -use sha2::{Digest, Sha256}; - -const ORIGINAL: &[u8] = b"original\n"; -const PATCHED: &[u8] = b"patched\n"; - -/// Compute the git-style blob SHA-256 (`sha256("blob \0" + bytes)`) -/// the same way the production hashing code does. -fn git_blob_sha256(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(format!("blob {}\0", bytes.len()).as_bytes()); - hasher.update(bytes); - hex::encode(hasher.finalize()) -} - -fn write_root_package_json(root: &Path) { - std::fs::write( - root.join("package.json"), - r#"{ "name": "covgap-ecosystem-dispatch", "version": "0.0.0" }"#, - ) - .unwrap(); -} - -/// Write a minimal manifest with one (file-less) patch for the given PURL. -fn write_manifest(root: &Path, purl: &str) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let body = format!( - r#"{{ - "patches": {{ - "{purl}": {{ - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{}}, - "vulnerabilities": {{}}, - "description": "dispatch test", - "license": "MIT", - "tier": "free" - }} - }} -}}"# - ); - std::fs::write(socket.join("manifest.json"), body).unwrap(); -} - -/// Write a rollback manifest whose single file's `afterHash` matches the -/// on-disk (patched) bytes and whose `beforeHash` matches the staged -/// ORIGINAL blob. After rollback the file must hold ORIGINAL again. -fn write_rollback_manifest(root: &Path, purl: &str, file_key: &str) { - let before_hash = git_blob_sha256(ORIGINAL); - let after_hash = git_blob_sha256(PATCHED); - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let body = format!( - r#"{{ - "patches": {{ - "{purl}": {{ - "uuid": "44444444-4444-4444-8444-444444444444", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "{file_key}": {{ - "beforeHash": "{before_hash}", - "afterHash": "{after_hash}" - }} - }}, - "vulnerabilities": {{}}, - "description": "x", - "license": "MIT", - "tier": "free" - }} - }} -}}"# - ); - std::fs::write(socket.join("manifest.json"), body).unwrap(); - // Stage the BEFORE blob so rollback can restore it. - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&before_hash), ORIGINAL).unwrap(); -} - -/// Run `socket-patch apply --offline --json --ecosystems deno` in `cwd` and -/// return the exit code + parsed envelope. `DENO_DIR` is pinned to an empty -/// dir under `cwd` so the local-mode cache probe can never reach the real -/// user cache (`~/Library/Caches/deno` on macOS). -fn run_apply_deno(cwd: &Path) -> (i32, Value) { - let mut cmd = crate::common::hermetic_command(&binary()); - cmd.args([ - "apply", - "--offline", - "--json", - "--ecosystems", - "deno", - "--silent", - ]) - .current_dir(cwd); - let deno_dir = cwd.join("deno-cache"); - std::fs::create_dir_all(&deno_dir).unwrap(); - cmd.env("DENO_DIR", &deno_dir); - let out = cmd.output().expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let env: Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("apply envelope must parse ({e}); stdout={stdout}")); - (out.status.code().unwrap_or(-1), env) -} - -/// Apply dispatch branch for deno: the `pkg:jsr/` PURL is classified to -/// `Ecosystem::Deno` and kept in scope by `--ecosystems deno`, so with -/// nothing installed apply must emit exactly one `skipped` / -/// `package_not_installed` event for the exact PURL (a removed or -/// mis-routed deno branch partitions the PURL away → empty events). -#[test] -fn apply_dispatch_branch_deno() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - // Deno project marker so the local-mode gate in `get_jsr_cache_paths` - // passes and the branch actually probes cache paths (the pinned empty - // DENO_DIR keeps that probe hermetic). - std::fs::write(tmp.path().join("deno.json"), "{}").unwrap(); - let purl = "pkg:jsr/@__dispatch_test__/pkg@1.0.0"; - write_manifest(tmp.path(), purl); - - let (code, env) = run_apply_deno(tmp.path()); - - // No package on disk for an in-scope patch => partial failure, exit 1. - assert_eq!( - code, 1, - "apply --ecosystems=deno: expected exit 1 (in-scope patch, nothing installed); env={env}" - ); - assert_eq!(env["command"], "apply", "env={env}"); - assert_eq!(env["status"], "partialFailure", "env={env}"); - assert_eq!( - env["summary"]["skipped"].as_u64(), - Some(1), - "apply --ecosystems=deno: the jsr PURL must be dispatched and skipped; env={env}" - ); - assert_eq!(env["summary"]["failed"].as_u64(), Some(0), "env={env}"); - let events = env["events"] - .as_array() - .unwrap_or_else(|| panic!("apply --ecosystems=deno: events missing; env={env}")); - assert_eq!( - events.len(), - 1, - "apply --ecosystems=deno: expected exactly one dispatch event; env={env}" - ); - assert!( - events.iter().any(|e| { - e["purl"] == purl - && e["action"] == "skipped" - && e["errorCode"] == "package_not_installed" - }), - "apply --ecosystems=deno: missing skipped/package_not_installed event for {purl}; env={env}" - ); -} - -/// Rollback dispatch branch for deno: a staged JSR cache reached via -/// `--global-prefix` must be discovered by `find_packages_for_rollback`'s -/// deno branch and the patched file restored to its ORIGINAL bytes. -#[test] -fn rollback_dispatch_branch_deno() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_root_package_json(root); - - // JSR cache layout: /@/// — the deno - // crawler resolves `pkg:jsr/@scope/name@version` to that dir (the scope - // keeps its leading `@` on disk). - let jsr_root = root.join("jsr-root"); - let pkg_dir = jsr_root - .join("@__rollback_dispatch__") - .join("pkg") - .join("1.0.0"); - std::fs::create_dir_all(&pkg_dir).unwrap(); - let verify_file = pkg_dir.join("mod.ts"); - std::fs::write(&verify_file, PATCHED).unwrap(); - - let purl = "pkg:jsr/@__rollback_dispatch__/pkg@1.0.0"; - write_rollback_manifest(root, purl, "mod.ts"); - - let mut cmd = crate::common::hermetic_command(&binary()); - cmd.args([ - "rollback", - "--offline", - "--json", - "--ecosystems", - "deno", - "--silent", - "--global-prefix", - jsr_root.to_str().unwrap(), - ]) - .current_dir(root); - let out = cmd.output().expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let env: Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("rollback envelope must parse ({e}); stdout={stdout}")); - let code = out.status.code().unwrap_or(-1); - - assert_eq!( - code, 0, - "rollback --ecosystems=deno: expected exit 0; env={env}" - ); - assert_eq!( - env["status"], "success", - "rollback --ecosystems=deno: expected success; env={env}" - ); - assert_eq!( - env["summary"]["rolledBack"].as_u64(), - Some(1), - "rollback --ecosystems=deno: must roll back exactly the one staged jsr package; env={env}" - ); - assert_eq!(env["summary"]["failed"].as_u64(), Some(0), "env={env}"); - assert_eq!( - env["summary"]["skipped"].as_u64(), - Some(0), - "rollback --ecosystems=deno: package was patched, not already-original; env={env}" - ); - let results: Vec<&Value> = env["events"] - .as_array() - .unwrap_or_else(|| panic!("rollback --ecosystems=deno: events missing; env={env}")) - .iter() - // The restore events, not the manifest-removal ones. - .filter(|e| e["details"]["manifest"] != true) - .collect(); - assert_eq!( - results.len(), - 1, - "rollback --ecosystems=deno: exactly one rolled-back package (proves the deno crawler \ - discovered it); env={env}" - ); - assert_eq!( - results[0]["purl"], - Value::from(purl), - "rollback --ecosystems=deno: rolled-back PURL mismatch; env={env}" - ); - assert_eq!(results[0]["action"], "rolledBack", "env={env}"); - assert!( - results[0]["files"] - .as_array() - .is_some_and(|a| !a.is_empty()), - "rollback --ecosystems=deno: must list at least one rolled-back file; env={env}" - ); - - // The decisive check: the on-disk bytes are restored to ORIGINAL. - let restored = std::fs::read(&verify_file).unwrap(); - assert_eq!( - restored, - ORIGINAL, - "rollback --ecosystems=deno: {} was not restored to its original bytes", - verify_file.display() - ); -} diff --git a/crates/socket-patch-cli/tests/scan/main.rs b/crates/socket-patch-cli/tests/scan/main.rs index edd649539..89ec0831f 100644 --- a/crates/socket-patch-cli/tests/scan/main.rs +++ b/crates/socket-patch-cli/tests/scan/main.rs @@ -1,7 +1,8 @@ //! `scan`: batching, ecosystem scope, ordering, paths, sync, hosted refusals, //! the scan and hosted `--json` failure paths and their human render checks, //! the hosted dry run over a vendored project, -//! scan on a real terminal, and the vendor flow. +//! `--prune` over a corrupt vendor ledger, scan on a real terminal, and +//! the vendor flow (previews, lock failures, corrupt legacy manifests). //! //! One test binary per command: each module was its own binary. @@ -18,10 +19,6 @@ mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] mod vlt_vendored; -mod coverage_fix_scan_discovery_corrupt_ledger; -mod covgap_commands_fetch_stage; -mod covgap_commands_scan_vendor_flow; -mod covgap_ecosystem_dispatch; mod hosted_dryrun_vendored; mod hosted_failure_paths; mod hosted_fixture; @@ -39,8 +36,10 @@ mod scan_invariants; mod scan_nested_project_e2e; mod scan_ordered_concurrency_e2e; mod scan_paths_e2e; +mod scan_prune_corrupt_vendor_ledger; mod scan_render; mod scan_sync_e2e; #[cfg(unix)] mod scan_tty; +mod scan_vendor_flow; mod scan_vendor_step_error_e2e; diff --git a/crates/socket-patch-cli/tests/scan/coverage_fix_scan_discovery_corrupt_ledger.rs b/crates/socket-patch-cli/tests/scan/scan_prune_corrupt_vendor_ledger.rs similarity index 98% rename from crates/socket-patch-cli/tests/scan/coverage_fix_scan_discovery_corrupt_ledger.rs rename to crates/socket-patch-cli/tests/scan/scan_prune_corrupt_vendor_ledger.rs index 38fe5475f..46e7cc4b9 100644 --- a/crates/socket-patch-cli/tests/scan/coverage_fix_scan_discovery_corrupt_ledger.rs +++ b/crates/socket-patch-cli/tests/scan/scan_prune_corrupt_vendor_ledger.rs @@ -8,6 +8,7 @@ //! Modeled on `scan_vendor_e2e.rs` (mock API + real fixture through the //! built binary). +use crate::common::envelope::parse_json_envelope; use crate::common::{binary, git_sha256, hermetic_command}; use std::path::Path; @@ -132,7 +133,7 @@ async fn corrupt_ledger_scan_prune_keeps_vendored_manifest_entry() { ], ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = parse_json_envelope(stdout.trim()); // The GC must not report the vendored entry as pruned… let pruned = v["gc"]["prunedManifestEntries"] diff --git a/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs b/crates/socket-patch-cli/tests/scan/scan_vendor_flow.rs similarity index 96% rename from crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs rename to crates/socket-patch-cli/tests/scan/scan_vendor_flow.rs index 0aa90d674..8e70b7f7a 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs +++ b/crates/socket-patch-cli/tests/scan/scan_vendor_flow.rs @@ -1,5 +1,8 @@ -//! Scan vendoring previews, lock failures and corrupt legacy manifests. +//! `scan --vendor`: dry-run previews (already vendored, prune GC), the +//! `--json` lock-held and lock-io errors, and a corrupt legacy manifest +//! that is reported and stepped around. +use crate::common::envelope::parse_json_envelope; use crate::common::{binary, git_sha256, hermetic_command}; use crate::prebuilt_common; @@ -27,17 +30,17 @@ const AFTER_B64: &str = "YWZ0ZXIK"; fn write_fixture(root: &Path) { std::fs::write( root.join("package.json"), - r#"{ "name": "covgap-scan-vendor-flow", "version": "0.0.0" }"#, + r#"{ "name": "scan-vendor-flow", "version": "0.0.0" }"#, ) .unwrap(); let lock = serde_json::json!({ - "name": "covgap-scan-vendor-flow", + "name": "scan-vendor-flow", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "covgap-scan-vendor-flow", + "name": "scan-vendor-flow", "version": "0.0.0", "dependencies": { "left-pad": "^1.3.0" } }, @@ -267,8 +270,7 @@ fn assert_vendor_step_error( expect_code: &str, ) -> serde_json::Value { assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("stdout must be one JSON object ({e}); stdout={stdout}")); + let v = parse_json_envelope(stdout.trim()); assert_eq!(v["status"], "error", "envelope={v}"); assert_eq!(v["error"]["code"], expect_code, "envelope={v}"); assert!( @@ -317,7 +319,7 @@ async fn scan_vendor_dry_run_reports_already_vendored() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = parse_json_envelope(stdout.trim()); assert_eq!(v["dryRun"], true, "envelope={v}"); let patches = v["events"].as_array().expect("vendor preview events"); assert_eq!(patches.len(), 1, "envelope={v}"); @@ -364,7 +366,7 @@ async fn scan_vendor_dry_run_prune_previews_gc_without_mutating() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run", "--prune"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = parse_json_envelope(stdout.trim()); // The vendor dry-run preview ran (empty discovery ⇒ no vendored // preview events). @@ -419,7 +421,7 @@ async fn scan_vendor_dry_run_prune_previews_gc_without_mutating() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run", "--prune"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = parse_json_envelope(stdout.trim()); assert_eq!(v["dryRun"], true, "envelope={v}"); assert_eq!( v["events"][0]["purl"], STALE_PURL, @@ -496,7 +498,7 @@ async fn scan_vendor_corrupt_manifest_is_reported_and_stepped_around() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = parse_json_envelope(stdout.trim()); assert_eq!(v["status"], "success", "envelope={v}"); assert_eq!(v["summary"]["applied"], 1, "envelope={v}"); assert!(