Skip to content

Commit 8fdce07

Browse files
mikolalysenkoclaude
andcommitted
feat: add e2e tests for npm and PyPI patch lifecycles
Add full end-to-end tests that exercise the CLI against the public Socket API: - npm: minimist@1.2.2 patch (CVE-2021-44906, prototype pollution) - PyPI: pydantic-ai@0.0.36 patch (CVE-2026-25580, SSRF) Each test covers the complete lifecycle: get → list → rollback → apply → remove, plus a dry-run test per ecosystem. Tests are gated with #[ignore] and run in CI via a dedicated e2e job on ubuntu and macos. Also fixes a bug where patches with no beforeHash (new files added by a patch) were silently dropped from the manifest. The apply and rollback engines now handle empty beforeHash correctly: - apply: creates new files, skips beforeHash verification - rollback: deletes patch-created files instead of restoring from blob - get: includes files in manifest even when beforeHash is absent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 74bee54 commit 8fdce07

8 files changed

Lines changed: 802 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,52 @@ jobs:
3939

4040
- name: Run tests
4141
run: cargo test --workspace
42+
43+
e2e:
44+
needs: test
45+
strategy:
46+
fail-fast: false
47+
matrix:
48+
include:
49+
- os: ubuntu-latest
50+
suite: e2e_npm
51+
- os: ubuntu-latest
52+
suite: e2e_pypi
53+
- os: macos-latest
54+
suite: e2e_npm
55+
- os: macos-latest
56+
suite: e2e_pypi
57+
runs-on: ${{ matrix.os }}
58+
steps:
59+
- name: Checkout
60+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
61+
62+
- name: Install Rust
63+
uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable
64+
with:
65+
toolchain: stable
66+
67+
- name: Cache cargo
68+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
69+
with:
70+
path: |
71+
~/.cargo/registry
72+
~/.cargo/git
73+
target
74+
key: ${{ matrix.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }}
75+
restore-keys: ${{ matrix.os }}-cargo-e2e-
76+
77+
- name: Setup Node.js
78+
if: matrix.suite == 'e2e_npm'
79+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
80+
with:
81+
node-version: 20
82+
83+
- name: Setup Python
84+
if: matrix.suite == 'e2e_pypi'
85+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
86+
with:
87+
python-version: "3.12"
88+
89+
- name: Run e2e tests
90+
run: cargo test -p socket-patch-cli --test ${{ matrix.suite }} -- --ignored

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/socket-patch-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ indicatif = { workspace = true }
2121
uuid = { workspace = true }
2222
regex = { workspace = true }
2323
tempfile = { workspace = true }
24+
25+
[dev-dependencies]
26+
sha2 = { workspace = true }
27+
hex = { workspace = true }

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -550,13 +550,14 @@ async fn save_and_apply_patch(
550550
// Build and save patch record
551551
let mut files = HashMap::new();
552552
for (file_path, file_info) in &patch.files {
553-
if let (Some(ref before), Some(ref after)) =
554-
(&file_info.before_hash, &file_info.after_hash)
555-
{
553+
if let Some(ref after) = file_info.after_hash {
556554
files.insert(
557555
file_path.clone(),
558556
PatchFileInfo {
559-
before_hash: before.clone(),
557+
before_hash: file_info
558+
.before_hash
559+
.clone()
560+
.unwrap_or_default(),
560561
after_hash: after.clone(),
561562
},
562563
);
@@ -570,6 +571,16 @@ async fn save_and_apply_patch(
570571
.ok();
571572
}
572573
}
574+
// Also store beforeHash blob if present (needed for rollback)
575+
if let (Some(ref before_blob), Some(ref before_hash)) =
576+
(&file_info.before_blob_content, &file_info.before_hash)
577+
{
578+
if let Ok(decoded) = base64_decode(before_blob) {
579+
tokio::fs::write(blobs_dir.join(before_hash), &decoded)
580+
.await
581+
.ok();
582+
}
583+
}
573584
}
574585

575586
let vulnerabilities: HashMap<String, VulnerabilityInfo> = patch
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
//! End-to-end tests for the npm patch lifecycle.
2+
//!
3+
//! These tests exercise the full CLI against the real Socket API, using the
4+
//! **minimist@1.2.2** patch (UUID `80630680-4da6-45f9-bba8-b888e0ffd58c`),
5+
//! which fixes CVE-2021-44906 (Prototype Pollution).
6+
//!
7+
//! # Prerequisites
8+
//! - `npm` on PATH
9+
//! - Network access to `patches-api.socket.dev` and `registry.npmjs.org`
10+
//!
11+
//! # Running
12+
//! ```sh
13+
//! cargo test -p socket-patch-cli --test e2e_npm -- --ignored
14+
//! ```
15+
16+
use std::path::{Path, PathBuf};
17+
use std::process::{Command, Output};
18+
19+
use sha2::{Digest, Sha256};
20+
21+
// ---------------------------------------------------------------------------
22+
// Constants
23+
// ---------------------------------------------------------------------------
24+
25+
const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
26+
const NPM_PURL: &str = "pkg:npm/minimist@1.2.2";
27+
28+
/// Git SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2.
29+
const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10";
30+
31+
/// Git SHA-256 of the *patched* `index.js` after the security fix.
32+
const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28";
33+
34+
// ---------------------------------------------------------------------------
35+
// Helpers
36+
// ---------------------------------------------------------------------------
37+
38+
fn binary() -> PathBuf {
39+
env!("CARGO_BIN_EXE_socket-patch").into()
40+
}
41+
42+
fn has_command(cmd: &str) -> bool {
43+
Command::new(cmd)
44+
.arg("--version")
45+
.stdout(std::process::Stdio::null())
46+
.stderr(std::process::Stdio::null())
47+
.status()
48+
.is_ok()
49+
}
50+
51+
/// Compute Git SHA-256: `SHA256("blob <len>\0" ++ content)`.
52+
fn git_sha256(content: &[u8]) -> String {
53+
let header = format!("blob {}\0", content.len());
54+
let mut hasher = Sha256::new();
55+
hasher.update(header.as_bytes());
56+
hasher.update(content);
57+
hex::encode(hasher.finalize())
58+
}
59+
60+
fn git_sha256_file(path: &Path) -> String {
61+
let content = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
62+
git_sha256(&content)
63+
}
64+
65+
/// Run the CLI binary with the given args, setting `cwd` as the working dir.
66+
/// Returns `(exit_code, stdout, stderr)`.
67+
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
68+
let out: Output = Command::new(binary())
69+
.args(args)
70+
.current_dir(cwd)
71+
.env_remove("SOCKET_API_TOKEN") // force public proxy (free-tier)
72+
.output()
73+
.expect("failed to execute socket-patch binary");
74+
75+
let code = out.status.code().unwrap_or(-1);
76+
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
77+
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
78+
(code, stdout, stderr)
79+
}
80+
81+
fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) {
82+
let (code, stdout, stderr) = run(cwd, args);
83+
assert_eq!(
84+
code, 0,
85+
"{context} failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
86+
);
87+
(stdout, stderr)
88+
}
89+
90+
fn npm_run(cwd: &Path, args: &[&str]) {
91+
let out = Command::new("npm")
92+
.args(args)
93+
.current_dir(cwd)
94+
.output()
95+
.expect("failed to run npm");
96+
assert!(
97+
out.status.success(),
98+
"npm {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}",
99+
out.status.code(),
100+
String::from_utf8_lossy(&out.stdout),
101+
String::from_utf8_lossy(&out.stderr),
102+
);
103+
}
104+
105+
/// Write a minimal package.json (avoids `npm init -y` which rejects temp dir
106+
/// names that start with `.` or contain invalid characters).
107+
fn write_package_json(cwd: &Path) {
108+
std::fs::write(
109+
cwd.join("package.json"),
110+
r#"{"name":"e2e-test","version":"0.0.0","private":true}"#,
111+
)
112+
.expect("write package.json");
113+
}
114+
115+
// ---------------------------------------------------------------------------
116+
// Tests
117+
// ---------------------------------------------------------------------------
118+
119+
/// Full lifecycle: get → verify → list → rollback → apply → remove.
120+
#[test]
121+
#[ignore]
122+
fn test_npm_full_lifecycle() {
123+
if !has_command("npm") {
124+
eprintln!("SKIP: npm not found on PATH");
125+
return;
126+
}
127+
128+
let dir = tempfile::tempdir().unwrap();
129+
let cwd = dir.path();
130+
131+
// -- Setup: create a project and install minimist@1.2.2 ----------------
132+
write_package_json(cwd);
133+
npm_run(cwd, &["install", "minimist@1.2.2"]);
134+
135+
let index_js = cwd.join("node_modules/minimist/index.js");
136+
assert!(index_js.exists(), "minimist/index.js must exist after npm install");
137+
138+
// Confirm the original file matches the expected before-hash.
139+
assert_eq!(
140+
git_sha256_file(&index_js),
141+
BEFORE_HASH,
142+
"freshly installed index.js should have the expected beforeHash"
143+
);
144+
145+
// -- GET: download + apply patch ---------------------------------------
146+
assert_run_ok(cwd, &["get", NPM_UUID], "get");
147+
148+
// Manifest should exist and contain the patch.
149+
let manifest_path = cwd.join(".socket/manifest.json");
150+
assert!(manifest_path.exists(), ".socket/manifest.json should exist after get");
151+
152+
let manifest: serde_json::Value =
153+
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
154+
let patch = &manifest["patches"][NPM_PURL];
155+
assert!(patch.is_object(), "manifest should contain {NPM_PURL}");
156+
assert_eq!(patch["uuid"].as_str().unwrap(), NPM_UUID);
157+
158+
// The file should now be patched.
159+
assert_eq!(
160+
git_sha256_file(&index_js),
161+
AFTER_HASH,
162+
"index.js should match afterHash after get"
163+
);
164+
165+
// -- LIST: verify JSON output ------------------------------------------
166+
let (stdout, _) = assert_run_ok(cwd, &["list", "--json"], "list --json");
167+
let list: serde_json::Value = serde_json::from_str(&stdout).unwrap();
168+
let patches = list["patches"].as_array().expect("patches should be an array");
169+
assert_eq!(patches.len(), 1);
170+
assert_eq!(patches[0]["uuid"].as_str().unwrap(), NPM_UUID);
171+
assert_eq!(patches[0]["purl"].as_str().unwrap(), NPM_PURL);
172+
173+
let vulns = patches[0]["vulnerabilities"]
174+
.as_array()
175+
.expect("vulnerabilities array");
176+
assert!(!vulns.is_empty(), "patch should report at least one vulnerability");
177+
178+
// Verify the vulnerability details match CVE-2021-44906
179+
let has_cve = vulns.iter().any(|v| {
180+
v["cves"]
181+
.as_array()
182+
.map_or(false, |cves| cves.iter().any(|c| c == "CVE-2021-44906"))
183+
});
184+
assert!(has_cve, "vulnerability list should include CVE-2021-44906");
185+
186+
// -- ROLLBACK: restore original file -----------------------------------
187+
assert_run_ok(cwd, &["rollback"], "rollback");
188+
189+
assert_eq!(
190+
git_sha256_file(&index_js),
191+
BEFORE_HASH,
192+
"index.js should match beforeHash after rollback"
193+
);
194+
195+
// -- APPLY: re-apply from manifest ------------------------------------
196+
assert_run_ok(cwd, &["apply"], "apply");
197+
198+
assert_eq!(
199+
git_sha256_file(&index_js),
200+
AFTER_HASH,
201+
"index.js should match afterHash after re-apply"
202+
);
203+
204+
// -- REMOVE: rollback + remove from manifest ---------------------------
205+
assert_run_ok(cwd, &["remove", NPM_UUID], "remove");
206+
207+
// File should be back to original.
208+
assert_eq!(
209+
git_sha256_file(&index_js),
210+
BEFORE_HASH,
211+
"index.js should match beforeHash after remove"
212+
);
213+
214+
// Manifest should have no patches left.
215+
let manifest: serde_json::Value =
216+
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
217+
assert!(
218+
manifest["patches"].as_object().unwrap().is_empty(),
219+
"manifest should be empty after remove"
220+
);
221+
}
222+
223+
/// `apply --dry-run` should not modify files on disk.
224+
#[test]
225+
#[ignore]
226+
fn test_npm_dry_run() {
227+
if !has_command("npm") {
228+
eprintln!("SKIP: npm not found on PATH");
229+
return;
230+
}
231+
232+
let dir = tempfile::tempdir().unwrap();
233+
let cwd = dir.path();
234+
235+
write_package_json(cwd);
236+
npm_run(cwd, &["install", "minimist@1.2.2"]);
237+
238+
let index_js = cwd.join("node_modules/minimist/index.js");
239+
assert_eq!(git_sha256_file(&index_js), BEFORE_HASH);
240+
241+
// Download the patch *without* applying.
242+
assert_run_ok(cwd, &["get", NPM_UUID, "--no-apply"], "get --no-apply");
243+
244+
// File should still be original.
245+
assert_eq!(
246+
git_sha256_file(&index_js),
247+
BEFORE_HASH,
248+
"file should not change after get --no-apply"
249+
);
250+
251+
// Dry-run should succeed but leave file untouched.
252+
assert_run_ok(cwd, &["apply", "--dry-run"], "apply --dry-run");
253+
254+
assert_eq!(
255+
git_sha256_file(&index_js),
256+
BEFORE_HASH,
257+
"file should not change after apply --dry-run"
258+
);
259+
260+
// Real apply should work.
261+
assert_run_ok(cwd, &["apply"], "apply");
262+
263+
assert_eq!(
264+
git_sha256_file(&index_js),
265+
AFTER_HASH,
266+
"file should match afterHash after real apply"
267+
);
268+
}

0 commit comments

Comments
 (0)