diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 651d3ae..fa0a844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,9 @@ jobs: test: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -62,9 +63,38 @@ jobs: key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ matrix.os }}-cargo- + - name: Build + run: cargo build --workspace --all-features + - name: Run tests run: cargo test --workspace --all-features + test-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable + with: + toolchain: stable + + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ubuntu-latest-cargo-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ubuntu-latest-cargo-release- + + - name: Run tests (release) + run: cargo test --workspace --all-features --release + dispatch-tests: runs-on: ubuntu-latest steps: diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md new file mode 100644 index 0000000..df3bdd9 --- /dev/null +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -0,0 +1,266 @@ +# socket-patch CLI contract + +This document defines the **public surface** of the `socket-patch` binary. Anything listed here is part of the user-visible contract: third-party scripts, CI pipelines, and the npm/pypi/cargo wrappers depend on it. Changes are governed by the semver policy at the bottom of this file. + +> **Why this exists.** Until late 2026 the CLI crate had zero unit tests under `src/` — only network-dependent `tests/e2e_*.rs` suites that run with `--ignored`. A flag rename, a default-value change, or a JSON key rename could land green and break every shipped wrapper silently. The contract below is now backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. + +## Subcommands + +| Name | Visible alias(es) | Notes | +|---|---|---| +| `apply` | — | Apply patches from the local manifest | +| `rollback` | — | Restore original files; takes optional positional `identifier` | +| `get` | `download` | Fetch + apply patch; requires positional `identifier` | +| `scan` | — | Crawl installed packages for available patches | +| `list` | — | Print patches in the local manifest | +| `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | +| `setup` | — | Configure package.json postinstall scripts | +| `repair` | `gc` | Download missing blobs + clean up unused ones | + +**Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). + +## Flags — long and short forms + +Every flag below is part of the contract. The default values are pinned by parser tests. + +### `apply` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--dry-run` | `-d` | `false` | bool | +| `--silent` | `-s` | `false` | bool | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--offline` | — | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--ecosystems` | — | (none) | CSV → `Vec` | +| `--force` | `-f` | `false` | bool | +| `--json` | — | `false` | bool | +| `--verbose` | `-v` | `false` | bool | +| `--download-mode` | — | **`diff`** | string | + +### `rollback` + +Same as `apply` plus: `--one-off` (bool), `--org` (string), `--api-url` (string), `--api-token` (string). Positional `identifier` is **optional** (omit to rollback everything). + +### `get` + +Required positional `identifier`. Flags: + +| Long | Short | Alias | Default | Type | +|---|---|---|---|---| +| `--org` | — | — | (none) | string | +| `--cwd` | — | — | `.` | path | +| `--id` | — | — | `false` | bool | +| `--cve` | — | — | `false` | bool | +| `--ghsa` | — | — | `false` | bool | +| `--package` | `-p` | — | `false` | bool | +| `--yes` | `-y` | — | `false` | bool | +| `--api-url` | — | — | (none) | string | +| `--api-token` | — | — | (none) | string | +| `--save-only` | — | **`--no-apply`** | `false` | bool | +| `--global` | `-g` | — | `false` | bool | +| `--global-prefix` | — | — | (none) | path | +| `--one-off` | — | — | `false` | bool | +| `--json` | — | — | `false` | bool | +| `--download-mode` | — | — | **`diff`** | string | + +The hidden alias `--no-apply` on `--save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. + +### `scan` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--org` | — | (none) | string | +| `--json` | — | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--batch-size` | — | **`100`** | usize | +| `--api-url` | — | (none) | string | +| `--api-token` | — | (none) | string | +| `--ecosystems` | — | (none) | CSV → `Vec` | +| `--download-mode` | — | **`diff`** | string | + +### `list` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--json` | — | `false` | bool | + +### `remove` + +Required positional `identifier`. Flags: + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--skip-rollback` | — | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--global` | `-g` | `false` | bool | +| `--global-prefix` | — | (none) | path | +| `--json` | — | `false` | bool | + +### `setup` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--dry-run` | `-d` | `false` | bool | +| `--yes` | `-y` | `false` | bool | +| `--json` | — | `false` | bool | + +### `repair` + +| Long | Short | Default | Type | +|---|---|---|---| +| `--cwd` | — | `.` | path | +| `--manifest-path` | `-m` | `.socket/manifest.json` | string | +| `--dry-run` | `-d` | `false` | bool | +| `--offline` | — | `false` | bool | +| `--download-only` | — | `false` | bool | +| `--json` | — | `false` | bool | +| `--download-mode` | — | **`file`** | string | + +**Note:** `repair`'s `--download-mode` default differs from every other command (`file` vs `diff`). This is intentional — repair restores legacy per-file blobs needed to apply any patch. + +## CSV value parsing + +`--ecosystems` on `apply`, `rollback`, and `scan` uses clap's `value_delimiter = ','`. Input `--ecosystems npm,pypi,cargo` becomes `vec!["npm", "pypi", "cargo"]`. Switching to space-separated or dropping the delimiter is a **breaking** change. + +## JSON output shapes + +When `--json` is set, commands print a single JSON object to stdout. The schemas below are stable. + +### Missing-manifest error (`apply`/`list`/`remove`/`repair`/`rollback`) + +```json +{ + "status": "error", + "error": "Manifest not found", + "path": "" +} +``` + +### Invalid-manifest error + +```json +{ "status": "error", "error": "Invalid manifest" } +``` + +### Generic error + +```json +{ "status": "error", "error": "" } +``` + +### `list` success — empty manifest + +```json +{ "status": "success", "patches": [] } +``` + +### `list` success — populated + +```json +{ + "status": "success", + "patches": [ + { + "purl": "pkg:npm/foo@1.2.3", + "uuid": "…", + "exportedAt": "…", + "tier": "free|paid", + "license": "…", + "description": "…", + "files": ["…"], + "vulnerabilities": [ + { "id": "…", "cves": ["…"], "summary": "…", "severity": "…", "description": "…" } + ] + } + ] +} +``` + +### `setup` — no package.json files found + +```json +{ + "status": "no_files", + "updated": 0, + "alreadyConfigured": 0, + "errors": 0, + "files": [] +} +``` + +### `get` — multiple-patch selection required (JSON mode) + +```json +{ + "status": "selection_required", + "error": "Multiple patches available for . Specify --id to select one.", + "purl": "", + "options": [ + { "uuid": "…", "tier": "…", "published_at": "…", "description": "…", "vulnerabilities": [ … ] } + ] +} +``` + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | + +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. + +## Semver policy + +Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to npm, pypi, and cargo wrappers by **`scripts/version-sync.sh `**. + +| Change | Bump | +|---|---| +| Rename or remove a subcommand | **MAJOR** | +| Rename or remove a visible alias (`download`, `gc`) | **MAJOR** | +| Rename or remove a hidden alias (`--no-apply`) | **MAJOR** | +| Rename, remove, or change short form of a flag (`-d`, `-m`, etc.) | **MAJOR** | +| Change a default value (`--download-mode`, `--batch-size`, `--manifest-path`, …) | **MAJOR** | +| Change an exit code's meaning or add a new non-zero code with different semantics | **MAJOR** | +| Rename a JSON output key or change a `status` string | **MAJOR** | +| Remove a JSON output key | **MAJOR** | +| Drop the bare-UUID fallback | **MAJOR** | +| Add a *required* new flag | **MAJOR** | +| Add a new subcommand | **MINOR** | +| Add a new optional flag | **MINOR** | +| Add a new optional JSON output key (additive) | **MINOR** | +| Add a new visible alias to an existing subcommand | **MINOR** | +| Fix a bug without changing any of the above | **PATCH** | + +After bumping `Cargo.toml`, run: + +```bash +scripts/version-sync.sh +``` + +This syncs the workspace package version into: + +- `npm/socket-patch/package.json` (and its `optionalDependencies`) +- every per-platform `npm/socket-patch-*/package.json` +- `pypi/socket-patch/pyproject.toml` + +## How the contract is enforced + +Every item in this document is locked in by at least one of: + +- **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_with_uuid_fallback`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers. +- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. + +If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 8911c79..ed2a651 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true repository.workspace = true readme = "README.md" +[lib] +name = "socket_patch_cli" +path = "src/lib.rs" + [[bin]] name = "socket-patch" path = "src/main.rs" diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 80f4f67..2a690f4 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -6,7 +6,7 @@ use socket_patch_core::api::blob_fetcher::{ use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus, }; @@ -113,11 +113,7 @@ pub async fn run(args: ApplyArgs) -> i32 { let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); // Check if manifest exists - exit successfully if no .socket folder is set up if tokio::fs::metadata(&manifest_path).await.is_err() { @@ -622,3 +618,184 @@ async fn apply_patches_inner( Ok((!has_errors, results, unmatched)) } + +#[cfg(test)] +mod tests { + //! Pure-helper tests for the `apply` subcommand. These pin the JSON + //! key shape produced by `result_to_json` and the lowercase string + //! tags emitted by `verify_status_str` — both part of the public + //! contract documented in `CLI_CONTRACT.md`. + use super::*; + use socket_patch_core::patch::apply::{ + ApplyResult, AppliedVia, VerifyResult, VerifyStatus, + }; + + // ----------------------------------------------------------------- + // verify_status_str — every VerifyStatus variant must map to the + // exact lowercase tag documented in the JSON contract. + // ----------------------------------------------------------------- + + #[test] + fn verify_status_str_ready() { + assert_eq!(verify_status_str(&VerifyStatus::Ready), "ready"); + } + + #[test] + fn verify_status_str_already_patched() { + assert_eq!( + verify_status_str(&VerifyStatus::AlreadyPatched), + "already_patched" + ); + } + + #[test] + fn verify_status_str_hash_mismatch() { + assert_eq!( + verify_status_str(&VerifyStatus::HashMismatch), + "hash_mismatch" + ); + } + + #[test] + fn verify_status_str_not_found() { + assert_eq!(verify_status_str(&VerifyStatus::NotFound), "not_found"); + } + + // ----------------------------------------------------------------- + // result_to_json — top-level keys and filesVerified[0] keys are part + // of the JSON output contract. Wrappers and CI scripts read these. + // ----------------------------------------------------------------- + + /// Build an `ApplyResult` with a single fully-populated VerifyResult + /// so we can exercise every JSON key in one shot. + fn sample_result_with_verify(status: VerifyStatus) -> ApplyResult { + ApplyResult { + package_key: "pkg:npm/minimist@1.2.2".to_string(), + package_path: "/tmp/node_modules/minimist".to_string(), + success: true, + files_verified: vec![VerifyResult { + file: "package/index.js".to_string(), + status, + message: Some("ok".to_string()), + current_hash: Some("aaa".to_string()), + expected_hash: Some("bbb".to_string()), + target_hash: Some("ccc".to_string()), + }], + files_patched: vec!["package/index.js".to_string()], + applied_via: HashMap::new(), + error: None, + } + } + + #[test] + fn result_to_json_top_level_keys() { + let result = sample_result_with_verify(VerifyStatus::Ready); + let v = result_to_json(&result); + let obj = v.as_object().expect("top-level must be a JSON object"); + + // The exact set of top-level keys is contract; any addition or + // rename here is a breaking change for downstream wrappers. + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "appliedVia", + "error", + "filesPatched", + "filesVerified", + "path", + "purl", + "success", + ] + ); + + // Spot-check value mapping for the simple scalar fields. + assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2"); + assert_eq!(v["path"], "/tmp/node_modules/minimist"); + assert_eq!(v["success"], true); + assert_eq!(v["error"], serde_json::Value::Null); + assert_eq!(v["filesPatched"][0], "package/index.js"); + } + + #[test] + fn result_to_json_files_verified_entry_keys() { + let result = sample_result_with_verify(VerifyStatus::Ready); + let v = result_to_json(&result); + let entry = v["filesVerified"][0] + .as_object() + .expect("filesVerified[0] must be a JSON object"); + + let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "currentHash", + "expectedHash", + "file", + "message", + "status", + "targetHash", + ] + ); + + assert_eq!(v["filesVerified"][0]["file"], "package/index.js"); + assert_eq!(v["filesVerified"][0]["status"], "ready"); + assert_eq!(v["filesVerified"][0]["message"], "ok"); + assert_eq!(v["filesVerified"][0]["currentHash"], "aaa"); + assert_eq!(v["filesVerified"][0]["expectedHash"], "bbb"); + assert_eq!(v["filesVerified"][0]["targetHash"], "ccc"); + } + + #[test] + fn result_to_json_hash_mismatch_status_tag() { + // The `hash_mismatch` snake_case tag is the contract value. + // `verify_status_str` produces it; verify it survives the round + // trip through `result_to_json`. + let result = sample_result_with_verify(VerifyStatus::HashMismatch); + let v = result_to_json(&result); + assert_eq!(v["filesVerified"][0]["status"], "hash_mismatch"); + } + + #[test] + fn result_to_json_applied_via_uses_camel_case_key() { + // `appliedVia` must be camelCase in JSON output, not snake_case + // `applied_via`. This is divergent from the Rust struct field + // name and is part of the contract — wrappers parse this key. + let mut applied_via = HashMap::new(); + applied_via.insert("package/index.js".to_string(), AppliedVia::Diff); + applied_via.insert("package/lib/foo.js".to_string(), AppliedVia::Package); + + let result = ApplyResult { + package_key: "pkg:npm/minimist@1.2.2".to_string(), + package_path: "/tmp/node_modules/minimist".to_string(), + success: true, + files_verified: Vec::new(), + files_patched: vec![ + "package/index.js".to_string(), + "package/lib/foo.js".to_string(), + ], + applied_via, + error: None, + }; + let v = result_to_json(&result); + + // Key must be `appliedVia`, not `applied_via`. + assert!(v.get("appliedVia").is_some()); + assert!(v.get("applied_via").is_none()); + + // Value must serialize as a JSON object map (not array). + let map = v["appliedVia"] + .as_object() + .expect("appliedVia must serialize as a JSON object"); + assert_eq!(map.len(), 2); + // The lowercase tags from `AppliedVia::as_tag` are themselves + // contract values (`diff`, `package`, `blob`). + assert_eq!(map.get("package/index.js").and_then(|v| v.as_str()), Some("diff")); + assert_eq!( + map.get("package/lib/foo.js").and_then(|v| v.as_str()), + Some("package"), + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ee2399a..e00c462 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1268,3 +1268,187 @@ fn base64_decode(input: &str) -> Result, String> { Ok(output) } + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::api::types::VulnerabilityResponse; + use std::collections::HashMap; + + // --- detect_identifier_type ------------------------------------------- + + #[test] + fn detect_uuid_lowercase() { + assert_eq!( + detect_identifier_type("80630680-4da6-45f9-bba8-b888e0ffd58c"), + Some(IdentifierType::Uuid) + ); + } + + #[test] + fn detect_uuid_uppercase() { + // Case-insensitive UUID regex per contract. + assert_eq!( + detect_identifier_type("80630680-4DA6-45F9-BBA8-B888E0FFD58C"), + Some(IdentifierType::Uuid) + ); + } + + #[test] + fn detect_cve_uppercase() { + assert_eq!( + detect_identifier_type("CVE-2021-44906"), + Some(IdentifierType::Cve) + ); + } + + #[test] + fn detect_cve_lowercase() { + // Load-bearing: CVE detection must be case-insensitive. + assert_eq!( + detect_identifier_type("cve-2021-44906"), + Some(IdentifierType::Cve) + ); + } + + #[test] + fn detect_ghsa_uppercase() { + assert_eq!( + detect_identifier_type("GHSA-abcd-1234-wxyz"), + Some(IdentifierType::Ghsa) + ); + } + + #[test] + fn detect_ghsa_lowercase() { + // Load-bearing: GHSA detection must be case-insensitive. + assert_eq!( + detect_identifier_type("ghsa-abcd-1234-wxyz"), + Some(IdentifierType::Ghsa) + ); + } + + #[test] + fn detect_purl() { + assert_eq!( + detect_identifier_type("pkg:npm/foo@1.0"), + Some(IdentifierType::Purl) + ); + } + + #[test] + fn detect_package_name_returns_none() { + // Bare package names don't match any pattern; caller treats this as + // Package via the `else` branch in run(). + assert_eq!(detect_identifier_type("minimist"), None); + } + + #[test] + fn detect_malformed_cve_returns_none() { + assert_eq!(detect_identifier_type("CVE-not-a-year"), None); + } + + #[test] + fn detect_empty_string_returns_none() { + assert_eq!(detect_identifier_type(""), None); + } + + // --- select_patches --------------------------------------------------- + + fn mk_patch( + uuid: &str, + purl: &str, + tier: &str, + published_at: &str, + ) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.into(), + purl: purl.into(), + published_at: published_at.into(), + description: format!("desc-{uuid}"), + license: "MIT".into(), + tier: tier.into(), + vulnerabilities: HashMap::::new(), + } + } + + #[test] + fn select_free_user_one_free_patch_returns_it() { + let patches = vec![mk_patch("u1", "pkg:npm/foo@1.0", "free", "2024-01-01")]; + let out = select_patches(&patches, false, false).expect("ok"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "u1"); + } + + #[test] + fn select_paid_user_prefers_paid_over_free_same_purl() { + let patches = vec![ + mk_patch("free1", "pkg:npm/foo@1.0", "free", "2024-06-01"), + mk_patch("paid1", "pkg:npm/foo@1.0", "paid", "2024-01-01"), + ]; + let out = select_patches(&patches, true, false).expect("ok"); + assert_eq!(out.len(), 1); + // Paid wins even if free is more recent. + assert_eq!(out[0].uuid, "paid1"); + assert_eq!(out[0].tier, "paid"); + } + + #[test] + fn select_paid_user_picks_most_recent_paid() { + let patches = vec![ + mk_patch("old", "pkg:npm/foo@1.0", "paid", "2024-01-01"), + mk_patch("new", "pkg:npm/foo@1.0", "paid", "2024-06-01"), + ]; + let out = select_patches(&patches, true, false).expect("ok"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "new"); + } + + #[test] + fn select_paid_user_falls_back_to_most_recent_free_when_no_paid() { + let patches = vec![ + mk_patch("old", "pkg:npm/foo@1.0", "free", "2024-01-01"), + mk_patch("new", "pkg:npm/foo@1.0", "free", "2024-06-01"), + ]; + let out = select_patches(&patches, true, false).expect("ok"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "new"); + } + + #[test] + fn select_free_user_multi_free_json_mode_errors() { + // JSON mode requires explicit selection; multiple free patches in JSON + // mode means the caller must pass --id. + let patches = vec![ + mk_patch("a", "pkg:npm/foo@1.0", "free", "2024-01-01"), + mk_patch("b", "pkg:npm/foo@1.0", "free", "2024-06-01"), + ]; + let err = select_patches(&patches, false, true).expect_err("should fail"); + assert_eq!(err, 1); + } + + #[test] + fn select_empty_input_returns_empty() { + let out = select_patches(&[], false, false).expect("ok"); + assert!(out.is_empty()); + let out = select_patches(&[], true, false).expect("ok"); + assert!(out.is_empty()); + let out = select_patches(&[], false, true).expect("ok"); + assert!(out.is_empty()); + } + + #[test] + fn select_free_user_paid_filtered_out_then_single_free_auto_selects() { + // Free user: paid patch is filtered out before grouping; only the free + // patch survives, and since the group has exactly one entry it + // auto-selects without hitting the interactive path. + let patches = vec![ + mk_patch("paid", "pkg:npm/foo@1.0", "paid", "2024-06-01"), + mk_patch("free", "pkg:npm/foo@1.0", "free", "2024-01-01"), + ]; + let out = select_patches(&patches, false, false).expect("ok"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "free"); + assert_eq!(out[0].tier, "free"); + } +} diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 8dc00a6..9365537 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,7 +1,7 @@ use clap::Args; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::read_manifest; -use std::path::{Path, PathBuf}; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; +use std::path::PathBuf; #[derive(Args)] pub struct ListArgs { @@ -19,11 +19,7 @@ pub struct ListArgs { } pub async fn run(args: ListArgs) -> i32 { - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); // Check if manifest exists if tokio::fs::metadata(&manifest_path).await.is_err() { diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 8acff80..1d5c203 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,6 +1,8 @@ use clap::Args; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::operations::{ + read_manifest, resolve_manifest_path, write_manifest, +}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed}; @@ -49,11 +51,7 @@ pub async fn run(args: RemoveArgs) -> i32 { let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 2197bb1..ff54e07 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{ }; use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::utils::cleanup_blobs::{ cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, @@ -46,11 +46,7 @@ pub struct RepairArgs { } pub async fn run(args: RepairArgs) -> i32 { - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 93bf96f..8bc522d 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{ use socket_patch_core::api::client::get_api_client_from_env; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::CrawlerOptions; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult, VerifyRollbackStatus}; use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed}; @@ -212,11 +212,7 @@ pub async fn run(args: RollbackArgs) -> i32 { return 1; } - let manifest_path = if Path::new(&args.manifest_path).is_absolute() { - PathBuf::from(&args.manifest_path) - } else { - args.cwd.join(&args.manifest_path) - }; + let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path); if tokio::fs::metadata(&manifest_path).await.is_err() { if args.json { @@ -531,3 +527,71 @@ pub async fn rollback_patches( }; rollback_patches_inner(&args, manifest_path).await } + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; + use std::collections::HashMap; + + fn make_record(uuid: &str) -> PatchRecord { + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + } + } + + fn make_manifest() -> PatchManifest { + let mut patches = HashMap::new(); + patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); + patches.insert("pkg:npm/bar@2.0".to_string(), make_record("uuid-bar")); + patches.insert("pkg:pypi/baz@3.0".to_string(), make_record("uuid-baz")); + PatchManifest { patches } + } + + #[test] + fn test_find_patches_to_rollback_none_returns_all() { + let manifest = make_manifest(); + let result = find_patches_to_rollback(&manifest, None); + assert_eq!(result.len(), 3); + } + + #[test] + fn test_find_patches_to_rollback_purl_match() { + let manifest = make_manifest(); + let result = + find_patches_to_rollback(&manifest, Some("pkg:npm/foo@1.0")); + assert_eq!(result.len(), 1); + assert_eq!(result[0].purl, "pkg:npm/foo@1.0"); + } + + #[test] + fn test_find_patches_to_rollback_purl_no_match() { + let manifest = make_manifest(); + let result = + find_patches_to_rollback(&manifest, Some("pkg:npm/nonexistent@1")); + assert!(result.is_empty()); + } + + #[test] + fn test_find_patches_to_rollback_uuid_match() { + let manifest = make_manifest(); + let result = find_patches_to_rollback(&manifest, Some("uuid-bar")); + assert_eq!(result.len(), 1); + assert_eq!(result[0].patch.uuid, "uuid-bar"); + assert_eq!(result[0].purl, "pkg:npm/bar@2.0"); + } + + #[test] + fn test_find_patches_to_rollback_uuid_no_match() { + let manifest = make_manifest(); + let result = + find_patches_to_rollback(&manifest, Some("uuid-does-not-exist")); + assert!(result.is_empty()); + } +} diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 2c499d9..5b14c50 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -704,3 +704,128 @@ pub async fn find_packages_for_rollback( all_packages } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partition_purls_no_filter_single_npm() { + let purls = vec!["pkg:npm/foo@1.0".to_string()]; + let map = partition_purls(&purls, None); + assert_eq!(map.len(), 1); + assert_eq!( + map.get(&Ecosystem::Npm), + Some(&vec!["pkg:npm/foo@1.0".to_string()]) + ); + } + + #[test] + fn partition_purls_no_filter_mixed_ecosystems() { + let purls = vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:pypi/bar@2.0".to_string(), + "pkg:cargo/baz@3.0".to_string(), + ]; + let map = partition_purls(&purls, None); + assert_eq!(map.len(), 3); + assert_eq!( + map.get(&Ecosystem::Npm), + Some(&vec!["pkg:npm/foo@1.0".to_string()]) + ); + assert_eq!( + map.get(&Ecosystem::Pypi), + Some(&vec!["pkg:pypi/bar@2.0".to_string()]) + ); + #[cfg(feature = "cargo")] + assert_eq!( + map.get(&Ecosystem::Cargo), + Some(&vec!["pkg:cargo/baz@3.0".to_string()]) + ); + } + + #[test] + fn partition_purls_no_filter_empty_input() { + let purls: Vec = Vec::new(); + let map = partition_purls(&purls, None); + assert!(map.is_empty()); + } + + #[test] + fn partition_purls_no_filter_duplicate_purls_preserved() { + let purls = vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:npm/foo@1.0".to_string(), + ]; + let map = partition_purls(&purls, None); + assert_eq!(map.len(), 1); + assert_eq!( + map.get(&Ecosystem::Npm), + Some(&vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:npm/foo@1.0".to_string(), + ]) + ); + } + + #[test] + fn partition_purls_no_filter_unknown_ecosystem_dropped() { + let purls = vec!["pkg:weirdo/x@1".to_string()]; + let map = partition_purls(&purls, None); + assert!(map.is_empty()); + } + + #[test] + fn partition_purls_allow_list_excludes_one() { + let purls = vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:pypi/bar@2.0".to_string(), + ]; + let allowed = vec!["npm".to_string()]; + let map = partition_purls(&purls, Some(allowed.as_slice())); + assert_eq!(map.len(), 1); + assert_eq!( + map.get(&Ecosystem::Npm), + Some(&vec!["pkg:npm/foo@1.0".to_string()]) + ); + assert!(map.get(&Ecosystem::Pypi).is_none()); + } + + #[test] + fn partition_purls_allow_list_matches_none() { + let purls = vec!["pkg:npm/foo@1.0".to_string()]; + let allowed = vec!["pypi".to_string()]; + let map = partition_purls(&purls, Some(allowed.as_slice())); + assert!(map.is_empty()); + } + + #[test] + fn partition_purls_allow_list_matches_all() { + let purls = vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:pypi/bar@2.0".to_string(), + ]; + let allowed = vec!["npm".to_string(), "pypi".to_string()]; + let map = partition_purls(&purls, Some(allowed.as_slice())); + assert_eq!(map.len(), 2); + assert_eq!( + map.get(&Ecosystem::Npm), + Some(&vec!["pkg:npm/foo@1.0".to_string()]) + ); + assert_eq!( + map.get(&Ecosystem::Pypi), + Some(&vec!["pkg:pypi/bar@2.0".to_string()]) + ); + } + + #[test] + fn partition_purls_empty_allow_list_matches_nothing() { + let purls = vec![ + "pkg:npm/foo@1.0".to_string(), + "pkg:pypi/bar@2.0".to_string(), + ]; + let allowed: Vec = Vec::new(); + let map = partition_purls(&purls, Some(allowed.as_slice())); + assert!(map.is_empty()); + } +} diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs new file mode 100644 index 0000000..3a0bcf8 --- /dev/null +++ b/crates/socket-patch-cli/src/lib.rs @@ -0,0 +1,255 @@ +//! socket-patch CLI library crate. +//! +//! Exposes the clap parser types so integration tests can verify the public +//! CLI contract without invoking the binary. The `main.rs` binary entry point +//! is a thin wrapper that delegates to [`parse_with_uuid_fallback`] and the +//! `run` function on each command's `Args`. + +pub mod commands; +pub mod ecosystem_dispatch; +pub mod output; + +use clap::{Parser, Subcommand}; + +// CLI contract surface — subcommand names, visible_alias values, flag names, +// defaults, JSON shapes, and exit codes are PUBLIC and SEMVER-SIGNIFICANT. +// Changes here require a MAJOR bump + `scripts/version-sync.sh`. +// See crates/socket-patch-cli/CLI_CONTRACT.md. +#[derive(Parser)] +#[command( + name = "socket-patch", + about = "CLI tool for applying security patches to dependencies", + version, + propagate_version = true +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Apply security patches to dependencies + Apply(commands::apply::ApplyArgs), + + /// Rollback patches to restore original files + Rollback(commands::rollback::RollbackArgs), + + /// Get security patches from Socket API and apply them + #[command(visible_alias = "download")] + Get(commands::get::GetArgs), + + /// Scan installed packages for available security patches + Scan(commands::scan::ScanArgs), + + /// List all patches in the local manifest + List(commands::list::ListArgs), + + /// Remove a patch from the manifest by PURL or UUID (rolls back files first) + Remove(commands::remove::RemoveArgs), + + /// Configure package.json postinstall scripts to apply patches + Setup(commands::setup::SetupArgs), + + /// Download missing blobs and clean up unused blobs + #[command(visible_alias = "gc")] + Repair(commands::repair::RepairArgs), +} + +/// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). +/// +/// Used by [`parse_with_uuid_fallback`] to detect the convenience form +/// `socket-patch ` and rewrite it to `socket-patch get `. +pub fn looks_like_uuid(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 5 { + return false; + } + let expected = [8, 4, 4, 4, 12]; + parts + .iter() + .zip(expected.iter()) + .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit())) +} + +/// Parse a full argv vector, falling back to `get ` when the user +/// invoked `socket-patch [...]` directly. Returns the original clap +/// error if the fallback also fails or if the first arg isn't a UUID. +/// +/// Pulled out of `main.rs` so the fallback path is unit-testable. +pub fn parse_with_uuid_fallback(argv: Vec) -> Result { + match Cli::try_parse_from(&argv) { + Ok(cli) => Ok(cli), + Err(err) => { + if argv.len() >= 2 && looks_like_uuid(&argv[1]) { + let mut new_args = vec![argv[0].clone(), "get".into()]; + new_args.extend_from_slice(&argv[1..]); + match Cli::try_parse_from(&new_args) { + Ok(cli) => Ok(cli), + Err(_) => Err(err), + } + } else { + Err(err) + } + } + } +} + +#[cfg(test)] +mod tests { + //! Unit tests for the bare-UUID fallback. These tests lock in the + //! `socket-patch ` rewrite shortcut and the shape predicate it + //! uses — both of which are part of the CLI contract (see + //! `CLI_CONTRACT.md`). + use super::*; + + // ---------- looks_like_uuid ---------- + + #[test] + fn looks_like_uuid_accepts_canonical_lowercase() { + assert!(looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c")); + } + + #[test] + fn looks_like_uuid_accepts_uppercase() { + // `is_ascii_hexdigit` accepts A-F as well as a-f, so all-uppercase + // UUIDs must still pass the shape check. + assert!(looks_like_uuid("80630680-4DA6-45F9-BBA8-B888E0FFD58C")); + } + + #[test] + fn looks_like_uuid_accepts_mixed_case() { + assert!(looks_like_uuid("80630680-4Da6-45F9-bBa8-B888e0FfD58c")); + } + + #[test] + fn looks_like_uuid_rejects_four_groups() { + // 8-4-4-4 — missing the final 12-char group. + assert!(!looks_like_uuid("80630680-4da6-45f9-bba8")); + } + + #[test] + fn looks_like_uuid_rejects_six_groups() { + // One too many groups — the split count must be exactly 5. + assert!(!looks_like_uuid( + "80630680-4da6-45f9-bba8-b888e0ffd58c-extra" + )); + } + + #[test] + fn looks_like_uuid_rejects_8_4_4_4_13_group_lengths() { + // Final group has 13 chars instead of 12. + assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58cc")); + } + + #[test] + fn looks_like_uuid_rejects_7_4_4_4_12_group_lengths() { + // First group has 7 chars instead of 8. + assert!(!looks_like_uuid("8063068-4da6-45f9-bba8-b888e0ffd58c0")); + } + + #[test] + fn looks_like_uuid_rejects_non_hex_chars() { + // `g` is not a hex digit — must fail even though the shape is right. + assert!(!looks_like_uuid("g0630680-4da6-45f9-bba8-b888e0ffd58c")); + assert!(!looks_like_uuid("80630680-4dz6-45f9-bba8-b888e0ffd58c")); + assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58z")); + } + + #[test] + fn looks_like_uuid_rejects_empty_string() { + assert!(!looks_like_uuid("")); + } + + #[test] + fn looks_like_uuid_rejects_string_with_no_dashes() { + // 32 hex chars, no dashes — close to a UUID but not the right shape. + assert!(!looks_like_uuid("806306804da645f9bba8b888e0ffd58c")); + } + + #[test] + fn looks_like_uuid_rejects_bare_dashes() { + // Five empty groups — split count is right, group lengths aren't. + assert!(!looks_like_uuid("----")); + } + + // ---------- parse_with_uuid_fallback ---------- + + const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; + + fn argv(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn fallback_rewrites_bare_uuid_to_get() { + let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID])).unwrap(); + match cli.command { + Commands::Get(args) => assert_eq!(args.identifier, UUID), + _ => panic!("expected Commands::Get"), + } + } + + #[test] + fn fallback_preserves_trailing_flags() { + // Flags after the UUID must be forwarded to the synthesized `get`. + let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--json"])).unwrap(); + match cli.command { + Commands::Get(args) => { + assert_eq!(args.identifier, UUID); + assert!(args.json, "--json should be forwarded to get"); + } + _ => panic!("expected Commands::Get"), + } + } + + #[test] + fn fallback_returns_original_error_when_first_arg_is_not_uuid() { + // No rewrite should happen; the original clap error must surface. + // `Cli` doesn't derive `Debug`, so `unwrap_err()` doesn't compile — + // pull the error out via `match` instead. + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "not-a-uuid"])) { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); + } + + #[test] + fn fallback_is_skipped_when_normal_parse_succeeds() { + // `list` parses normally — fallback should not engage. + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "list"])).unwrap(); + assert!(matches!(cli.command, Commands::List(_))); + } + + #[test] + fn fallback_does_not_double_rewrite_explicit_get() { + // `socket-patch get ` already parses; fallback never runs. + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "get", UUID])).unwrap(); + match cli.command { + Commands::Get(args) => assert_eq!(args.identifier, UUID), + _ => panic!("expected Commands::Get"), + } + } + + #[test] + fn fallback_surfaces_original_error_when_rewrite_also_fails() { + // UUID is valid-shaped so a rewrite is attempted, but `get` doesn't + // accept this flag — the rewrite parse fails and we must return the + // ORIGINAL error (the one from the un-rewritten parse), not the + // rewrite's error. + let err = match parse_with_uuid_fallback(argv(&[ + "socket-patch", + UUID, + "--invalid-flag-that-get-does-not-accept", + ])) { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + }; + // The original parse failed because `` isn't a known + // subcommand, so the surfaced error must be InvalidSubcommand — + // NOT UnknownArgument (which is what the rewrite parse would have + // produced). + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); + } +} diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index e278400..ffdbf6e 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -1,82 +1,11 @@ -mod commands; -mod ecosystem_dispatch; -mod output; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - name = "socket-patch", - about = "CLI tool for applying security patches to dependencies", - version, - propagate_version = true -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Apply security patches to dependencies - Apply(commands::apply::ApplyArgs), - - /// Rollback patches to restore original files - Rollback(commands::rollback::RollbackArgs), - - /// Get security patches from Socket API and apply them - #[command(visible_alias = "download")] - Get(commands::get::GetArgs), - - /// Scan installed packages for available security patches - Scan(commands::scan::ScanArgs), - - /// List all patches in the local manifest - List(commands::list::ListArgs), - - /// Remove a patch from the manifest by PURL or UUID (rolls back files first) - Remove(commands::remove::RemoveArgs), - - /// Configure package.json postinstall scripts to apply patches - Setup(commands::setup::SetupArgs), - - /// Download missing blobs and clean up unused blobs - #[command(visible_alias = "gc")] - Repair(commands::repair::RepairArgs), -} - -/// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). -fn looks_like_uuid(s: &str) -> bool { - let parts: Vec<&str> = s.split('-').collect(); - if parts.len() != 5 { - return false; - } - let expected = [8, 4, 4, 4, 12]; - parts - .iter() - .zip(expected.iter()) - .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit())) -} +use socket_patch_cli::{commands, parse_with_uuid_fallback, Commands}; #[tokio::main] async fn main() { - let cli = match Cli::try_parse() { + let argv: Vec = std::env::args().collect(); + let cli = match parse_with_uuid_fallback(argv) { Ok(cli) => cli, - Err(err) => { - // If parsing failed, check whether the user passed a bare UUID - // (e.g. `socket-patch 80630680-...`) and retry as `get ...`. - let args: Vec = std::env::args().collect(); - if args.len() >= 2 && looks_like_uuid(&args[1]) { - let mut new_args = vec![args[0].clone(), "get".into()]; - new_args.extend_from_slice(&args[1..]); - match Cli::try_parse_from(&new_args) { - Ok(cli) => cli, - Err(_) => err.exit(), - } - } else { - err.exit() - } - } + Err(err) => err.exit(), }; let exit_code = match cli.command { diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs index c92f1ae..b770e6c 100644 --- a/crates/socket-patch-cli/src/output.rs +++ b/crates/socket-patch-cli/src/output.rs @@ -95,3 +95,162 @@ pub fn select_one(prompt: &str, options: &[String], is_json: bool) -> Result Err(SelectError::Cancelled), } } + +#[cfg(test)] +mod tests { + use super::*; + + // ---- format_severity ---- + + #[test] + fn format_severity_critical_with_color() { + let out = format_severity("critical", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("critical"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("31"), "expected red code 31: {out:?}"); + } + + #[test] + fn format_severity_high_with_color() { + let out = format_severity("high", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("high"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("91"), "expected bright-red code 91: {out:?}"); + } + + #[test] + fn format_severity_medium_with_color() { + let out = format_severity("medium", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("medium"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("33"), "expected yellow code 33: {out:?}"); + } + + #[test] + fn format_severity_low_with_color() { + let out = format_severity("low", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("low"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("36"), "expected cyan code 36: {out:?}"); + } + + #[test] + fn format_severity_case_insensitive_critical_uppercase() { + let out = format_severity("CRITICAL", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("CRITICAL"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("31"), "expected red code 31: {out:?}"); + } + + #[test] + fn format_severity_case_insensitive_critical_titlecase() { + let out = format_severity("Critical", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("Critical"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("31"), "expected red code 31: {out:?}"); + } + + #[test] + fn format_severity_case_insensitive_high_lowercase() { + let out = format_severity("high", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("high"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + } + + #[test] + fn format_severity_case_insensitive_high_uppercase() { + let out = format_severity("HIGH", true); + assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); + assert!(out.contains("HIGH"), "expected input verbatim: {out:?}"); + assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); + assert!(out.contains("91"), "expected bright-red code 91: {out:?}"); + } + + #[test] + fn format_severity_unknown_passes_through_with_color() { + let out = format_severity("unknown", true); + assert_eq!(out, "unknown"); + } + + #[test] + fn format_severity_critical_no_color() { + assert_eq!(format_severity("critical", false), "critical"); + } + + #[test] + fn format_severity_high_no_color() { + assert_eq!(format_severity("high", false), "high"); + } + + #[test] + fn format_severity_medium_no_color() { + assert_eq!(format_severity("medium", false), "medium"); + } + + #[test] + fn format_severity_low_no_color() { + assert_eq!(format_severity("low", false), "low"); + } + + #[test] + fn format_severity_unknown_no_color() { + assert_eq!(format_severity("unknown", false), "unknown"); + } + + #[test] + fn format_severity_empty_with_color_passes_through() { + let out = format_severity("", true); + assert_eq!(out, ""); + } + + // ---- color ---- + + #[test] + fn color_with_color_on() { + assert_eq!(color("hi", "31", true), "\x1b[31mhi\x1b[0m"); + } + + #[test] + fn color_with_color_off() { + assert_eq!(color("hi", "31", false), "hi"); + } + + #[test] + fn color_with_empty_text_and_color_on() { + assert_eq!(color("", "1;32", true), "\x1b[1;32m\x1b[0m"); + } + + // ---- confirm ---- + + #[test] + fn confirm_skip_prompt_returns_default_yes_true() { + assert!(confirm("?", true, true, false)); + } + + #[test] + fn confirm_skip_prompt_returns_default_yes_false() { + assert!(!confirm("?", false, true, false)); + } + + #[test] + fn confirm_is_json_returns_default_yes_true() { + assert!(confirm("?", true, false, true)); + } + + #[test] + fn confirm_is_json_returns_default_yes_false() { + assert!(!confirm("?", false, false, true)); + } + + #[test] + fn confirm_skip_prompt_and_is_json_both_set_returns_default_yes() { + assert!(confirm("?", true, true, true)); + } +} diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs new file mode 100644 index 0000000..096a855 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -0,0 +1,216 @@ +//! Parser snapshot tests for the `apply` subcommand. +//! +//! These tests pin **every flag name, short form, and default value** +//! listed in `crates/socket-patch-cli/CLI_CONTRACT.md` for `apply`. A +//! rename, dropped short form, or default-value drift fails here loudly +//! instead of silently breaking the npm/pypi/cargo wrappers and CI +//! scripts that depend on the surface. + +use std::path::PathBuf; + +use clap::Parser; +use socket_patch_cli::commands::apply::ApplyArgs; +use socket_patch_cli::{Cli, Commands}; + +/// Parse `socket-patch apply ` and return the inner `ApplyArgs`. +/// Panics if parsing fails or yields a non-`Apply` subcommand — tests for +/// the failure path call `Cli::try_parse_from` directly. +fn parse_apply(extra: &[&str]) -> ApplyArgs { + let mut argv: Vec<&str> = vec!["socket-patch", "apply"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Apply(a) => a, + _ => panic!("expected Apply"), + } +} + +// --------------------------------------------------------------------------- +// Defaults — every default value from the contract table is pinned here. +// --------------------------------------------------------------------------- + +#[test] +fn defaults_match_contract() { + let a = parse_apply(&[]); + assert_eq!(a.cwd, PathBuf::from(".")); + assert!(!a.dry_run); + assert!(!a.silent); + assert_eq!(a.manifest_path, ".socket/manifest.json"); + assert!(!a.offline); + assert!(!a.global); + assert_eq!(a.global_prefix, None); + assert_eq!(a.ecosystems, None); + assert!(!a.force); + assert!(!a.json); + assert!(!a.verbose); + assert_eq!(a.download_mode, "diff"); +} + +/// The `download_mode` default is pinned separately — it's the one +/// field whose default value diverges across subcommands historically, +/// so we assert it explicitly to catch drift. +#[test] +fn default_download_mode_is_diff() { + assert_eq!(parse_apply(&[]).download_mode, "diff"); +} + +/// The `manifest_path` default is contract — many scripts hard-code +/// `.socket/manifest.json` as the canonical location. +#[test] +fn default_manifest_path_is_dot_socket_manifest_json() { + assert_eq!(parse_apply(&[]).manifest_path, ".socket/manifest.json"); +} + +// --------------------------------------------------------------------------- +// Boolean flags — long form, then short form (where applicable). +// --------------------------------------------------------------------------- + +#[test] +fn dry_run_long() { + assert!(parse_apply(&["--dry-run"]).dry_run); +} + +#[test] +fn dry_run_short() { + assert!(parse_apply(&["-d"]).dry_run); +} + +#[test] +fn silent_long() { + assert!(parse_apply(&["--silent"]).silent); +} + +#[test] +fn silent_short() { + assert!(parse_apply(&["-s"]).silent); +} + +#[test] +fn global_long() { + assert!(parse_apply(&["--global"]).global); +} + +#[test] +fn global_short() { + assert!(parse_apply(&["-g"]).global); +} + +#[test] +fn force_long() { + assert!(parse_apply(&["--force"]).force); +} + +#[test] +fn force_short() { + assert!(parse_apply(&["-f"]).force); +} + +#[test] +fn verbose_long() { + assert!(parse_apply(&["--verbose"]).verbose); +} + +#[test] +fn verbose_short() { + assert!(parse_apply(&["-v"]).verbose); +} + +#[test] +fn offline_long() { + assert!(parse_apply(&["--offline"]).offline); +} + +#[test] +fn json_long() { + assert!(parse_apply(&["--json"]).json); +} + +// --------------------------------------------------------------------------- +// Value flags — long form, then short form (where applicable). +// --------------------------------------------------------------------------- + +#[test] +fn cwd_long() { + assert_eq!(parse_apply(&["--cwd", "/tmp/x"]).cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn manifest_path_long() { + assert_eq!( + parse_apply(&["--manifest-path", "custom.json"]).manifest_path, + "custom.json" + ); +} + +#[test] +fn manifest_path_short() { + assert_eq!(parse_apply(&["-m", "custom.json"]).manifest_path, "custom.json"); +} + +#[test] +fn global_prefix_long() { + assert_eq!( + parse_apply(&["--global-prefix", "/foo"]).global_prefix, + Some(PathBuf::from("/foo")) + ); +} + +// --------------------------------------------------------------------------- +// --ecosystems CSV split — the contract is that a comma-delimited value +// expands into a Vec. Wrappers rely on this single-flag form. +// --------------------------------------------------------------------------- + +#[test] +fn ecosystems_csv_splits_into_vec() { + assert_eq!( + parse_apply(&["--ecosystems", "npm,pypi,cargo"]).ecosystems, + Some(vec!["npm".to_string(), "pypi".to_string(), "cargo".to_string()]) + ); +} + +#[test] +fn ecosystems_single_value() { + assert_eq!( + parse_apply(&["--ecosystems", "npm"]).ecosystems, + Some(vec!["npm".to_string()]) + ); +} + +// --------------------------------------------------------------------------- +// --download-mode — accepted token values are documented contract. +// --------------------------------------------------------------------------- + +#[test] +fn download_mode_diff() { + assert_eq!(parse_apply(&["--download-mode", "diff"]).download_mode, "diff"); +} + +#[test] +fn download_mode_package() { + assert_eq!( + parse_apply(&["--download-mode", "package"]).download_mode, + "package" + ); +} + +#[test] +fn download_mode_file() { + assert_eq!(parse_apply(&["--download-mode", "file"]).download_mode, "file"); +} + +// --------------------------------------------------------------------------- +// Failure path — unknown flags must produce a clap UnknownArgument error. +// This guards against accidentally accepting a typo via positional fallback. +// --------------------------------------------------------------------------- + +#[test] +fn unknown_flag_fails_with_unknown_argument() { + // `Cli` doesn't implement `Debug`, so we can't use `.expect_err()` — + // match the Result by hand. + match Cli::try_parse_from(["socket-patch", "apply", "--unknown-flag"]) { + Ok(_) => panic!("--unknown-flag must be rejected"), + Err(err) => { + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + } +} diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs new file mode 100644 index 0000000..902dfb8 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -0,0 +1,232 @@ +//! Clap parser snapshot tests for the `get` subcommand. +//! +//! These tests pin the public CLI contract for `socket-patch get`: every +//! flag, every alias (including the hidden `--no-apply` and the visible +//! `download` alias), and every default. Changing any assertion here is a +//! breaking change to the CLI surface — see +//! `crates/socket-patch-cli/CLI_CONTRACT.md`. + +use clap::Parser; +use socket_patch_cli::commands::get::GetArgs; +use socket_patch_cli::{Cli, Commands}; +use std::path::PathBuf; + +/// Parse `socket-patch get ` and return the `GetArgs`. +fn parse_get(extra: &[&str]) -> GetArgs { + let mut argv = vec!["socket-patch", "get"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Get(a) => a, + _ => panic!("expected Get"), + } +} + +// --- Defaults ---------------------------------------------------------------- + +#[test] +fn defaults_with_only_required_identifier() { + let a = parse_get(&["some-id"]); + assert_eq!(a.identifier, "some-id"); + assert_eq!(a.org, None); + assert_eq!(a.cwd, PathBuf::from(".")); + assert!(!a.id); + assert!(!a.cve); + assert!(!a.ghsa); + assert!(!a.package); + assert!(!a.yes); + assert_eq!(a.api_url, None); + assert_eq!(a.api_token, None); + assert!(!a.save_only); + assert!(!a.global); + assert_eq!(a.global_prefix, None); + assert!(!a.one_off); + assert!(!a.json); + assert_eq!(a.download_mode, "diff"); +} + +#[test] +fn default_download_mode_is_diff() { + let a = parse_get(&["some-id"]); + assert_eq!(a.download_mode, "diff"); +} + +// --- Positional -------------------------------------------------------------- + +#[test] +fn positional_identifier_stored() { + let a = parse_get(&["pkg:npm/foo@1.0"]); + assert_eq!(a.identifier, "pkg:npm/foo@1.0"); +} + +// --- Short flags ------------------------------------------------------------- + +#[test] +fn short_p_sets_package() { + let a = parse_get(&["some-id", "-p"]); + assert!(a.package); +} + +#[test] +fn long_package_sets_package() { + let a = parse_get(&["some-id", "--package"]); + assert!(a.package); +} + +#[test] +fn short_y_sets_yes() { + let a = parse_get(&["some-id", "-y"]); + assert!(a.yes); +} + +#[test] +fn long_yes_sets_yes() { + let a = parse_get(&["some-id", "--yes"]); + assert!(a.yes); +} + +#[test] +fn short_g_sets_global() { + let a = parse_get(&["some-id", "-g"]); + assert!(a.global); +} + +#[test] +fn long_global_sets_global() { + let a = parse_get(&["some-id", "--global"]); + assert!(a.global); +} + +// --- Long-only flags --------------------------------------------------------- + +#[test] +fn cwd_flag_sets_cwd() { + let a = parse_get(&["some-id", "--cwd", "/tmp/project"]); + assert_eq!(a.cwd, PathBuf::from("/tmp/project")); +} + +#[test] +fn org_flag_sets_org() { + let a = parse_get(&["some-id", "--org", "acme"]); + assert_eq!(a.org.as_deref(), Some("acme")); +} + +#[test] +fn id_flag_sets_id() { + let a = parse_get(&["some-id", "--id"]); + assert!(a.id); +} + +#[test] +fn cve_flag_sets_cve() { + let a = parse_get(&["some-id", "--cve"]); + assert!(a.cve); +} + +#[test] +fn ghsa_flag_sets_ghsa() { + let a = parse_get(&["some-id", "--ghsa"]); + assert!(a.ghsa); +} + +#[test] +fn api_url_flag_sets_api_url() { + let a = parse_get(&["some-id", "--api-url", "https://api.example.com"]); + assert_eq!(a.api_url.as_deref(), Some("https://api.example.com")); +} + +#[test] +fn api_token_flag_sets_api_token() { + let a = parse_get(&["some-id", "--api-token", "sktsec_abc"]); + assert_eq!(a.api_token.as_deref(), Some("sktsec_abc")); +} + +#[test] +fn global_prefix_flag_sets_global_prefix() { + let a = parse_get(&["some-id", "--global-prefix", "/usr/local/lib"]); + assert_eq!(a.global_prefix, Some(PathBuf::from("/usr/local/lib"))); +} + +#[test] +fn one_off_flag_sets_one_off() { + let a = parse_get(&["some-id", "--one-off"]); + assert!(a.one_off); +} + +#[test] +fn json_flag_sets_json() { + let a = parse_get(&["some-id", "--json"]); + assert!(a.json); +} + +// --- save-only / --no-apply alias ------------------------------------------- + +#[test] +fn save_only_flag_sets_save_only() { + let a = parse_get(&["some-id", "--save-only"]); + assert!(a.save_only); +} + +#[test] +fn no_apply_hidden_alias_sets_save_only() { + // `--no-apply` is a hidden alias for `--save-only`. It does not appear in + // `--help` but is widely used in existing scripts — this is part of the + // CLI contract. + let a = parse_get(&["some-id", "--no-apply"]); + assert!(a.save_only); +} + +// --- download-mode ----------------------------------------------------------- + +#[test] +fn download_mode_package() { + let a = parse_get(&["some-id", "--download-mode", "package"]); + assert_eq!(a.download_mode, "package"); +} + +#[test] +fn download_mode_diff() { + let a = parse_get(&["some-id", "--download-mode", "diff"]); + assert_eq!(a.download_mode, "diff"); +} + +#[test] +fn download_mode_file() { + let a = parse_get(&["some-id", "--download-mode", "file"]); + assert_eq!(a.download_mode, "file"); +} + +// --- `download` visible alias for `get` ------------------------------------- + +#[test] +fn download_visible_alias_routes_to_get() { + let cli = + Cli::try_parse_from(["socket-patch", "download", "some-id"]).expect("parse"); + match cli.command { + Commands::Get(a) => { + assert_eq!(a.identifier, "some-id"); + } + _ => panic!("expected Get from `download` alias"), + } +} + +// --- Error paths ------------------------------------------------------------- + +#[test] +fn missing_required_identifier_errors() { + let err = match Cli::try_parse_from(["socket-patch", "get"]) { + Err(e) => e, + Ok(_) => panic!("expected parse error for missing required positional"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn unknown_flag_errors() { + let err = match Cli::try_parse_from(["socket-patch", "get", "some-id", "--bogus"]) + { + Err(e) => e, + Ok(_) => panic!("expected parse error for unknown flag"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs new file mode 100644 index 0000000..d7c93a3 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -0,0 +1,289 @@ +//! Parser + `run()` contract tests for `socket-patch list`. +//! +//! These tests pin the public CLI surface of the `list` subcommand: +//! - clap parser tests assert flag long/short forms, defaults, and unknown-flag rejection +//! - async `run()` tests cover the no-network execution paths (missing manifest -> 1, +//! empty manifest -> 0, populated manifest -> 0, absolute manifest path wins) +//! - one subprocess test against the compiled binary locks the JSON `status` shape for +//! the missing-manifest error path, since `run()` writes directly to stdout/stderr +//! and cannot be intercepted in-process. +//! +//! See `crates/socket-patch-cli/CLI_CONTRACT.md` for the surface these tests pin. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +use clap::Parser; +use socket_patch_cli::commands::list::{ListArgs, run}; +use socket_patch_cli::{Cli, Commands}; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, +}; + +// --------------------------------------------------------------------------- +// Parser helpers +// --------------------------------------------------------------------------- + +fn parse_list(extra: &[&str]) -> ListArgs { + let mut argv = vec!["socket-patch", "list"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::List(a) => a, + _ => panic!("expected List"), + } +} + +// --------------------------------------------------------------------------- +// Parser tests +// --------------------------------------------------------------------------- + +#[test] +fn defaults_match_contract() { + let args = parse_list(&[]); + assert_eq!(args.cwd, PathBuf::from(".")); + assert_eq!(args.manifest_path, ".socket/manifest.json"); + assert!(!args.json); +} + +#[test] +fn manifest_path_short_form() { + let args = parse_list(&["-m", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn manifest_path_long_form() { + let args = parse_list(&["--manifest-path", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn cwd_long_form() { + let args = parse_list(&["--cwd", "/tmp/x"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn json_flag_sets_true() { + let args = parse_list(&["--json"]); + assert!(args.json); +} + +#[test] +fn unknown_flag_is_rejected() { + let err = match Cli::try_parse_from(["socket-patch", "list", "--nope"]) { + Ok(_) => panic!("unknown flag must fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} + +// --------------------------------------------------------------------------- +// run() integration tests — no-network paths +// --------------------------------------------------------------------------- + +fn populated_manifest() -> PatchManifest { + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111" + .to_string(), + after_hash: + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111" + .to_string(), + }, + ); + + let mut vulnerabilities = HashMap::new(); + vulnerabilities.insert( + "GHSA-test-test-test".to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2024-0001".to_string()], + summary: "test vuln".to_string(), + severity: "high".to_string(), + description: "test description".to_string(), + }, + ); + + let mut patches = HashMap::new(); + patches.insert( + "pkg:npm/test-pkg@1.0.0".to_string(), + PatchRecord { + uuid: "11111111-1111-4111-8111-111111111111".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities, + description: "Test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + PatchManifest { patches } +} + +#[tokio::test] +async fn missing_manifest_returns_1_plain() { + let tmp = tempfile::tempdir().unwrap(); + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: false, + }; + assert_eq!(run(args).await, 1); +} + +#[tokio::test] +async fn missing_manifest_returns_1_json() { + let tmp = tempfile::tempdir().unwrap(); + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: true, + }; + assert_eq!(run(args).await, 1); +} + +#[tokio::test] +async fn empty_manifest_returns_0_plain() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket_dir).await.unwrap(); + let manifest = PatchManifest::new(); + let path = socket_dir.join("manifest.json"); + tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .unwrap(); + + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: false, + }; + assert_eq!(run(args).await, 0); +} + +#[tokio::test] +async fn empty_manifest_returns_0_json() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket_dir).await.unwrap(); + let manifest = PatchManifest::new(); + let path = socket_dir.join("manifest.json"); + tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .unwrap(); + + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: true, + }; + assert_eq!(run(args).await, 0); +} + +#[tokio::test] +async fn populated_manifest_returns_0_plain() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket_dir).await.unwrap(); + let manifest = populated_manifest(); + let path = socket_dir.join("manifest.json"); + tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .unwrap(); + + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: false, + }; + assert_eq!(run(args).await, 0); +} + +#[tokio::test] +async fn populated_manifest_returns_0_json() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + tokio::fs::create_dir_all(&socket_dir).await.unwrap(); + let manifest = populated_manifest(); + let path = socket_dir.join("manifest.json"); + tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .unwrap(); + + let args = ListArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".into(), + json: true, + }; + assert_eq!(run(args).await, 0); +} + +#[tokio::test] +async fn absolute_manifest_path_wins_over_cwd() { + // Manifest lives in tmp_manifest_dir, cwd points elsewhere. + // resolve_manifest_path() must prefer the absolute path. + let tmp_manifest_dir = tempfile::tempdir().unwrap(); + let tmp_cwd = tempfile::tempdir().unwrap(); + + let manifest = PatchManifest::new(); + let abs_path = tmp_manifest_dir.path().join("abs.json"); + tokio::fs::write(&abs_path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .unwrap(); + + let args = ListArgs { + cwd: tmp_cwd.path().to_path_buf(), + manifest_path: abs_path.to_string_lossy().into_owned(), + json: false, + }; + assert_eq!(run(args).await, 0); +} + +// --------------------------------------------------------------------------- +// Subprocess test — locks the JSON `status` shape for missing-manifest error +// --------------------------------------------------------------------------- + +#[test] +fn missing_manifest_json_status_is_error_via_binary() { + let tmp = tempfile::tempdir().unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_socket-patch")) + .args([ + "list", + "--cwd", + tmp.path().to_str().unwrap(), + "--json", + ]) + .output() + .expect("failed to execute socket-patch binary"); + + assert_eq!( + out.status.code(), + Some(1), + "missing manifest must exit 1, stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + assert_eq!( + parsed.get("status").and_then(|v| v.as_str()), + Some("error"), + "status must be \"error\", got {parsed}" + ); + assert_eq!( + parsed.get("error").and_then(|v| v.as_str()), + Some("Manifest not found"), + "error message must be exact, got {parsed}" + ); + assert!( + parsed.get("path").and_then(|v| v.as_str()).is_some(), + "missing-manifest JSON must include `path` key, got {parsed}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_main.rs b/crates/socket-patch-cli/tests/cli_parse_main.rs new file mode 100644 index 0000000..cea8a73 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_main.rs @@ -0,0 +1,138 @@ +//! Top-level `Cli::try_parse_from` behavior tests. +//! +//! These tests cover the parser surface that doesn't fit in +//! `src/lib.rs::tests` — clap's auto-generated help/version handling, the +//! "no subcommand" error kind, every subcommand name, and the +//! visible_alias values (`download` for `get`, `gc` for `repair`). +//! +//! Each subcommand name and alias here is part of the CLI contract +//! defined in `crates/socket-patch-cli/CLI_CONTRACT.md`. + +use clap::Parser; +use socket_patch_cli::{Cli, Commands}; + +fn parse(argv: &[&str]) -> Result { + Cli::try_parse_from(argv) +} + +/// Pull the error out of a parse result. `Cli` doesn't derive `Debug`, +/// so `Result::unwrap_err` won't compile — this helper sidesteps that. +fn expect_err(result: Result) -> clap::Error { + match result { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + } +} + +// ---------- top-level error kinds ---------- + +#[test] +fn no_subcommand_returns_display_help_on_missing() { + // clap v4 returns `DisplayHelpOnMissingArgumentOrSubcommand` (not + // `MissingSubcommand`) for `socket-patch` with no args when a + // subcommand is required — this is the kind the binary's main.rs + // handler branches on. + let err = expect_err(parse(&["socket-patch"])); + assert_eq!( + err.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ); +} + +#[test] +fn version_flag_triggers_display_version() { + let err = expect_err(parse(&["socket-patch", "--version"])); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion); +} + +#[test] +fn help_flag_triggers_display_help() { + let err = expect_err(parse(&["socket-patch", "--help"])); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); +} + +#[test] +fn unknown_subcommand_returns_invalid_subcommand() { + let err = expect_err(parse(&["socket-patch", "bogus"])); + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); +} + +// ---------- every subcommand name parses ---------- + +#[test] +fn apply_subcommand_parses() { + let cli = parse(&["socket-patch", "apply"]).expect("apply must parse with no positional"); + assert!(matches!(cli.command, Commands::Apply(_))); +} + +#[test] +fn rollback_subcommand_parses_without_identifier() { + // rollback's identifier is optional — bare `rollback` must succeed. + let cli = + parse(&["socket-patch", "rollback"]).expect("rollback must parse with no positional"); + assert!(matches!(cli.command, Commands::Rollback(_))); +} + +#[test] +fn get_subcommand_parses_with_identifier() { + let cli = parse(&["socket-patch", "get", "some-id"]).expect("get must parse with identifier"); + match cli.command { + Commands::Get(args) => assert_eq!(args.identifier, "some-id"), + _ => panic!("expected Commands::Get"), + } +} + +#[test] +fn scan_subcommand_parses() { + let cli = parse(&["socket-patch", "scan"]).expect("scan must parse with no positional"); + assert!(matches!(cli.command, Commands::Scan(_))); +} + +#[test] +fn list_subcommand_parses() { + let cli = parse(&["socket-patch", "list"]).expect("list must parse with no positional"); + assert!(matches!(cli.command, Commands::List(_))); +} + +#[test] +fn remove_subcommand_parses_with_identifier() { + let cli = + parse(&["socket-patch", "remove", "some-id"]).expect("remove must parse with identifier"); + match cli.command { + Commands::Remove(args) => assert_eq!(args.identifier, "some-id"), + _ => panic!("expected Commands::Remove"), + } +} + +#[test] +fn setup_subcommand_parses() { + let cli = parse(&["socket-patch", "setup"]).expect("setup must parse with no positional"); + assert!(matches!(cli.command, Commands::Setup(_))); +} + +#[test] +fn repair_subcommand_parses() { + let cli = parse(&["socket-patch", "repair"]).expect("repair must parse with no positional"); + assert!(matches!(cli.command, Commands::Repair(_))); +} + +// ---------- visible aliases ---------- + +#[test] +fn download_alias_parses_as_get() { + // `download` is the visible_alias for `get` — wrappers in the wild + // call this name directly, so it has to keep working. + let cli = parse(&["socket-patch", "download", "some-id"]) + .expect("`download` alias must parse as Get"); + match cli.command { + Commands::Get(args) => assert_eq!(args.identifier, "some-id"), + _ => panic!("expected Commands::Get via `download` alias"), + } +} + +#[test] +fn gc_alias_parses_as_repair() { + // `gc` is the visible_alias for `repair`. + let cli = parse(&["socket-patch", "gc"]).expect("`gc` alias must parse as Repair"); + assert!(matches!(cli.command, Commands::Repair(_))); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_remove.rs b/crates/socket-patch-cli/tests/cli_parse_remove.rs new file mode 100644 index 0000000..cde78c8 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_remove.rs @@ -0,0 +1,203 @@ +//! Parser-level contract tests for `socket-patch remove`. +//! +//! Locks in every flag in the `RemoveArgs` table from +//! `crates/socket-patch-cli/CLI_CONTRACT.md` (long + short forms, defaults) +//! and exercises one no-network `run()` error path (missing manifest → 1). +//! +//! These tests deliberately avoid spawning the binary so they run in the +//! default `cargo test` set (no `--ignored` required) and stay fast. + +use clap::Parser; +use socket_patch_cli::commands::remove::{run, RemoveArgs}; +use socket_patch_cli::{Cli, Commands}; +use std::path::PathBuf; + +fn parse_remove(extra: &[&str]) -> RemoveArgs { + let mut argv = vec!["socket-patch", "remove"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Remove(a) => a, + _ => panic!("expected Remove"), + } +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +#[test] +fn defaults_with_purl_positional() { + let args = parse_remove(&["pkg:npm/foo@1"]); + assert_eq!(args.identifier, "pkg:npm/foo@1"); + assert_eq!(args.cwd, PathBuf::from(".")); + assert_eq!(args.manifest_path, ".socket/manifest.json"); + assert!(!args.skip_rollback); + assert!(!args.yes); + assert!(!args.global); + assert_eq!(args.global_prefix, None); + assert!(!args.json); +} + +#[test] +fn positional_uuid_stored_in_identifier() { + let args = parse_remove(&["80630680-4da6-45f9-bba8-b888e0ffd58c"]); + assert_eq!(args.identifier, "80630680-4da6-45f9-bba8-b888e0ffd58c"); + // Everything else still at default — `remove` does not auto-detect the + // identifier shape at parse time; the runtime branch on `pkg:` happens + // inside `run()`. + assert_eq!(args.cwd, PathBuf::from(".")); + assert_eq!(args.manifest_path, ".socket/manifest.json"); + assert!(!args.skip_rollback); + assert!(!args.yes); + assert!(!args.global); + assert_eq!(args.global_prefix, None); + assert!(!args.json); +} + +// --------------------------------------------------------------------------- +// Flag forms — each one in the contract table must have a test +// --------------------------------------------------------------------------- + +#[test] +fn yes_short_form() { + let args = parse_remove(&["pkg:npm/foo@1", "-y"]); + assert!(args.yes); +} + +#[test] +fn yes_long_form() { + let args = parse_remove(&["pkg:npm/foo@1", "--yes"]); + assert!(args.yes); +} + +#[test] +fn global_short_form() { + let args = parse_remove(&["pkg:npm/foo@1", "-g"]); + assert!(args.global); +} + +#[test] +fn global_long_form() { + let args = parse_remove(&["pkg:npm/foo@1", "--global"]); + assert!(args.global); +} + +#[test] +fn manifest_path_short_form() { + let args = parse_remove(&["pkg:npm/foo@1", "-m", "custom/manifest.json"]); + assert_eq!(args.manifest_path, "custom/manifest.json"); +} + +#[test] +fn manifest_path_long_form() { + let args = parse_remove(&[ + "pkg:npm/foo@1", + "--manifest-path", + "custom/manifest.json", + ]); + assert_eq!(args.manifest_path, "custom/manifest.json"); +} + +#[test] +fn cwd_long_form() { + let args = parse_remove(&["pkg:npm/foo@1", "--cwd", "/tmp/x"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn skip_rollback_long_form() { + let args = parse_remove(&["pkg:npm/foo@1", "--skip-rollback"]); + assert!(args.skip_rollback); +} + +#[test] +fn json_long_form() { + let args = parse_remove(&["pkg:npm/foo@1", "--json"]); + assert!(args.json); +} + +#[test] +fn global_prefix_long_form() { + let args = parse_remove(&[ + "pkg:npm/foo@1", + "--global-prefix", + "/opt/node-global", + ]); + assert_eq!(args.global_prefix, Some(PathBuf::from("/opt/node-global"))); +} + +#[test] +fn all_flags_combined() { + let args = parse_remove(&[ + "pkg:npm/foo@1", + "--cwd", + "/tmp/x", + "-m", + "custom/manifest.json", + "--skip-rollback", + "-y", + "-g", + "--global-prefix", + "/opt/node-global", + "--json", + ]); + assert_eq!(args.identifier, "pkg:npm/foo@1"); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); + assert_eq!(args.manifest_path, "custom/manifest.json"); + assert!(args.skip_rollback); + assert!(args.yes); + assert!(args.global); + assert_eq!(args.global_prefix, Some(PathBuf::from("/opt/node-global"))); + assert!(args.json); +} + +// --------------------------------------------------------------------------- +// Failure paths +// --------------------------------------------------------------------------- + +#[test] +fn missing_required_positional_is_error() { + let result = Cli::try_parse_from(["socket-patch", "remove"]); + let err = match result { + Ok(_) => panic!("remove without identifier must fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); +} + +#[test] +fn unknown_flag_is_error() { + let result = Cli::try_parse_from([ + "socket-patch", + "remove", + "pkg:npm/foo@1", + "--not-a-real-flag", + ]); + let err = match result { + Ok(_) => panic!("unknown flag must fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} + +// --------------------------------------------------------------------------- +// Async run() — no-network error path +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn run_missing_manifest_exits_one() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let args = RemoveArgs { + identifier: "pkg:npm/foo@1".to_string(), + cwd: tempdir.path().to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + skip_rollback: false, + yes: true, + global: false, + global_prefix: None, + json: true, + }; + let exit = run(args).await; + assert_eq!(exit, 1, "missing manifest must exit 1"); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs new file mode 100644 index 0000000..91638c0 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -0,0 +1,155 @@ +//! CLI contract tests for the `repair` subcommand (and its `gc` visible alias). +//! +//! These tests pin the public clap parser surface for `RepairArgs`. The most +//! important invariant guarded here is that `repair`'s `--download-mode` +//! defaults to `"file"` — diverging from every other command (which defaults +//! to `"diff"`). This is intentional: `repair` restores the legacy per-file +//! blobs needed to apply any patch. A silent flip to `"diff"` would be a +//! breaking behavior change with no parser-level signal, so we lock it down +//! here. The `gc` visible alias is also exercised so a refactor that drops +//! it is caught immediately. +//! +//! See `crates/socket-patch-cli/CLI_CONTRACT.md` for the full repair table. + +use std::path::PathBuf; + +use clap::Parser; +use socket_patch_cli::commands::repair::RepairArgs; +use socket_patch_cli::{Cli, Commands}; + +fn parse_repair(extra: &[&str]) -> RepairArgs { + let mut argv = vec!["socket-patch", "repair"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Repair(a) => a, + _ => panic!("expected Repair"), + } +} + +fn parse_gc(extra: &[&str]) -> RepairArgs { + let mut argv = vec!["socket-patch", "gc"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Repair(a) => a, + _ => panic!("expected Repair via gc alias"), + } +} + +#[test] +fn repair_defaults_match_contract() { + let args = parse_repair(&[]); + + // CRITICAL: repair's --download-mode default is "file", not "diff". + // This is the divergent default vs every other command. + assert_eq!( + args.download_mode, "file", + "repair --download-mode default MUST be `file` (legacy per-file blobs); diverges from other commands" + ); + + // Remaining defaults from CLI_CONTRACT.md repair table. + assert_eq!(args.cwd, PathBuf::from(".")); + assert_eq!(args.manifest_path, ".socket/manifest.json"); + assert!(!args.dry_run); + assert!(!args.offline); + assert!(!args.download_only); + assert!(!args.json); +} + +#[test] +fn repair_dry_run_short_flag() { + let args = parse_repair(&["-d"]); + assert!(args.dry_run); +} + +#[test] +fn repair_dry_run_long_flag() { + let args = parse_repair(&["--dry-run"]); + assert!(args.dry_run); +} + +#[test] +fn repair_manifest_path_short_flag() { + let args = parse_repair(&["-m", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn repair_manifest_path_long_flag() { + let args = parse_repair(&["--manifest-path", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn repair_cwd_flag() { + let args = parse_repair(&["--cwd", "/tmp/x"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn repair_offline_flag() { + let args = parse_repair(&["--offline"]); + assert!(args.offline); +} + +#[test] +fn repair_download_only_flag() { + let args = parse_repair(&["--download-only"]); + assert!(args.download_only); +} + +#[test] +fn repair_json_flag() { + let args = parse_repair(&["--json"]); + assert!(args.json); +} + +#[test] +fn repair_download_mode_file() { + let args = parse_repair(&["--download-mode", "file"]); + assert_eq!(args.download_mode, "file"); +} + +#[test] +fn repair_download_mode_diff() { + let args = parse_repair(&["--download-mode", "diff"]); + assert_eq!(args.download_mode, "diff"); +} + +#[test] +fn repair_download_mode_package() { + let args = parse_repair(&["--download-mode", "package"]); + assert_eq!(args.download_mode, "package"); +} + +#[test] +fn repair_gc_alias_defaults_match_repair() { + let via_gc = parse_gc(&[]); + let via_repair = parse_repair(&[]); + + // The whole point of the alias: identical parsing. + assert_eq!(via_gc.download_mode, "file"); + assert_eq!(via_gc.download_mode, via_repair.download_mode); + assert_eq!(via_gc.cwd, via_repair.cwd); + assert_eq!(via_gc.manifest_path, via_repair.manifest_path); + assert_eq!(via_gc.dry_run, via_repair.dry_run); + assert_eq!(via_gc.offline, via_repair.offline); + assert_eq!(via_gc.download_only, via_repair.download_only); + assert_eq!(via_gc.json, via_repair.json); +} + +#[test] +fn repair_gc_alias_accepts_flags() { + let args = parse_gc(&["--dry-run"]); + assert!(args.dry_run); +} + +#[test] +fn repair_unknown_flag_is_unknown_argument_error() { + let err = match Cli::try_parse_from(["socket-patch", "repair", "--nope"]) { + Ok(_) => panic!("unknown flag should fail to parse"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs new file mode 100644 index 0000000..b55ff66 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -0,0 +1,196 @@ +//! Parser snapshot tests for `socket-patch rollback`. +//! +//! Pins the public clap surface of `RollbackArgs` — every flag, every short +//! form, and every default. These tests do not invoke the binary; they parse +//! argv directly through `socket_patch_cli::Cli::try_parse_from`. Any change +//! to a flag name, short form, default, or CSV delimiter that breaks one of +//! these tests is a breaking change and requires a MAJOR bump per +//! `crates/socket-patch-cli/CLI_CONTRACT.md`. + +use clap::Parser; +use socket_patch_cli::commands::rollback::RollbackArgs; +use socket_patch_cli::{Cli, Commands}; +use std::path::PathBuf; + +fn parse_rollback(extra: &[&str]) -> RollbackArgs { + let mut argv = vec!["socket-patch", "rollback"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Rollback(a) => a, + _ => panic!("expected Rollback"), + } +} + +#[test] +fn defaults_no_positional() { + let args = parse_rollback(&[]); + assert_eq!(args.identifier, None); + assert_eq!(args.cwd, PathBuf::from(".")); + assert!(!args.dry_run); + assert!(!args.silent); + assert_eq!(args.manifest_path, ".socket/manifest.json"); + assert!(!args.offline); + assert!(!args.global); + assert_eq!(args.global_prefix, None); + assert!(!args.one_off); + assert_eq!(args.org, None); + assert_eq!(args.api_url, None); + assert_eq!(args.api_token, None); + assert_eq!(args.ecosystems, None); + assert!(!args.json); + assert!(!args.verbose); +} + +#[test] +fn positional_identifier_uuid() { + let args = parse_rollback(&["80630680-4da6-45f9-bba8-b888e0ffd58c"]); + assert_eq!( + args.identifier, + Some("80630680-4da6-45f9-bba8-b888e0ffd58c".to_string()) + ); +} + +#[test] +fn positional_identifier_purl() { + let args = parse_rollback(&["pkg:npm/foo@1"]); + assert_eq!(args.identifier, Some("pkg:npm/foo@1".to_string())); +} + +#[test] +fn dry_run_short() { + let args = parse_rollback(&["-d"]); + assert!(args.dry_run); +} + +#[test] +fn dry_run_long() { + let args = parse_rollback(&["--dry-run"]); + assert!(args.dry_run); +} + +#[test] +fn silent_short() { + let args = parse_rollback(&["-s"]); + assert!(args.silent); +} + +#[test] +fn silent_long() { + let args = parse_rollback(&["--silent"]); + assert!(args.silent); +} + +#[test] +fn manifest_path_short() { + let args = parse_rollback(&["-m", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn manifest_path_long() { + let args = parse_rollback(&["--manifest-path", "custom.json"]); + assert_eq!(args.manifest_path, "custom.json"); +} + +#[test] +fn global_short() { + let args = parse_rollback(&["-g"]); + assert!(args.global); +} + +#[test] +fn global_long() { + let args = parse_rollback(&["--global"]); + assert!(args.global); +} + +#[test] +fn verbose_short() { + let args = parse_rollback(&["-v"]); + assert!(args.verbose); +} + +#[test] +fn verbose_long() { + let args = parse_rollback(&["--verbose"]); + assert!(args.verbose); +} + +#[test] +fn cwd_long() { + let args = parse_rollback(&["--cwd", "/tmp/x"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn offline_long() { + let args = parse_rollback(&["--offline"]); + assert!(args.offline); +} + +#[test] +fn json_long() { + let args = parse_rollback(&["--json"]); + assert!(args.json); +} + +#[test] +fn global_prefix_long() { + let args = parse_rollback(&["--global-prefix", "/foo"]); + assert_eq!(args.global_prefix, Some(PathBuf::from("/foo"))); +} + +#[test] +fn one_off_long() { + let args = parse_rollback(&["--one-off"]); + assert!(args.one_off); +} + +#[test] +fn org_long() { + let args = parse_rollback(&["--org", "myorg"]); + assert_eq!(args.org, Some("myorg".to_string())); +} + +#[test] +fn api_url_long() { + let args = parse_rollback(&["--api-url", "https://api"]); + assert_eq!(args.api_url, Some("https://api".to_string())); +} + +#[test] +fn api_token_long() { + let args = parse_rollback(&["--api-token", "tok"]); + assert_eq!(args.api_token, Some("tok".to_string())); +} + +#[test] +fn ecosystems_csv_split() { + let args = parse_rollback(&["--ecosystems", "npm,pypi"]); + assert_eq!( + args.ecosystems, + Some(vec!["npm".to_string(), "pypi".to_string()]) + ); +} + +#[test] +fn positional_plus_flags() { + let args = parse_rollback(&["pkg:npm/foo@1", "--dry-run", "--json"]); + assert_eq!(args.identifier, Some("pkg:npm/foo@1".to_string())); + assert!(args.dry_run); + assert!(args.json); +} + +#[test] +fn unknown_flag_fails() { + let err = match Cli::try_parse_from([ + "socket-patch", + "rollback", + "--unknown-flag", + ]) { + Ok(_) => panic!("expected parse failure"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs new file mode 100644 index 0000000..2d8ac97 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -0,0 +1,206 @@ +//! Clap parser snapshot tests for `ScanArgs`. +//! +//! These tests lock in the `scan` subcommand's CLI contract — every flag, +//! short form, and default. Changes that flip a default or rename a flag +//! must break these tests so the regression is caught before release. +//! +//! Two defaults are especially load-bearing and explicitly asserted: +//! +//! * `--batch-size` defaults to `100`. Downstream API batching assumes this. +//! * `--download-mode` defaults to `"diff"`. This diverges from `repair`'s +//! default and is a silent-regression risk if flipped. + +use clap::Parser; +use socket_patch_cli::commands::scan::ScanArgs; +use socket_patch_cli::{Cli, Commands}; + +fn parse_scan(extra: &[&str]) -> ScanArgs { + let mut argv = vec!["socket-patch", "scan"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Scan(a) => a, + _ => panic!("expected Scan"), + } +} + +fn try_parse_scan(extra: &[&str]) -> Result { + let mut argv = vec!["socket-patch", "scan"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv)?; + match cli.command { + Commands::Scan(a) => Ok(a), + _ => panic!("expected Scan"), + } +} + +#[test] +fn defaults_match_contract() { + let args = parse_scan(&[]); + + // Critical load-bearing defaults. + assert_eq!(args.batch_size, 100, "--batch-size default is 100"); + assert_eq!( + args.download_mode, "diff", + "--download-mode default is \"diff\"" + ); + + // All other defaults from the scan table. + assert_eq!(args.cwd, std::path::PathBuf::from(".")); + assert_eq!(args.org, None); + assert!(!args.json); + assert!(!args.yes); + assert!(!args.global); + assert_eq!(args.global_prefix, None); + assert_eq!(args.api_url, None); + assert_eq!(args.api_token, None); + assert_eq!(args.ecosystems, None); +} + +#[test] +fn yes_short_flag() { + let args = parse_scan(&["-y"]); + assert!(args.yes); +} + +#[test] +fn yes_long_flag() { + let args = parse_scan(&["--yes"]); + assert!(args.yes); +} + +#[test] +fn global_short_flag() { + let args = parse_scan(&["-g"]); + assert!(args.global); +} + +#[test] +fn global_long_flag() { + let args = parse_scan(&["--global"]); + assert!(args.global); +} + +#[test] +fn cwd_flag() { + let args = parse_scan(&["--cwd", "/tmp/x"]); + assert_eq!(args.cwd, std::path::PathBuf::from("/tmp/x")); +} + +#[test] +fn org_flag() { + let args = parse_scan(&["--org", "myorg"]); + assert_eq!(args.org.as_deref(), Some("myorg")); +} + +#[test] +fn json_flag() { + let args = parse_scan(&["--json"]); + assert!(args.json); +} + +#[test] +fn global_prefix_flag() { + let args = parse_scan(&["--global-prefix", "/foo"]); + assert_eq!(args.global_prefix, Some(std::path::PathBuf::from("/foo"))); +} + +#[test] +fn api_url_flag() { + let args = parse_scan(&["--api-url", "https://api"]); + assert_eq!(args.api_url.as_deref(), Some("https://api")); +} + +#[test] +fn api_token_flag() { + let args = parse_scan(&["--api-token", "tok"]); + assert_eq!(args.api_token.as_deref(), Some("tok")); +} + +#[test] +fn batch_size_500() { + let args = parse_scan(&["--batch-size", "500"]); + assert_eq!(args.batch_size, 500); +} + +#[test] +fn batch_size_1() { + let args = parse_scan(&["--batch-size", "1"]); + assert_eq!(args.batch_size, 1); +} + +#[test] +fn batch_size_0_parses() { + // Clap accepts 0 as a valid usize. Whether 0 is a sensible batch size is + // a command-level concern, not a parser concern. Lock in that the parser + // itself does not reject it. + let args = parse_scan(&["--batch-size", "0"]); + assert_eq!(args.batch_size, 0); +} + +#[test] +fn batch_size_negative_fails() { + // Use `--batch-size=-1` (rather than two separate tokens) so clap parses + // `-1` as the value, not a stray short flag. The value must then fail + // the usize conversion. + let err = match try_parse_scan(&["--batch-size=-1"]) { + Ok(_) => panic!("negative batch-size should fail to parse"), + Err(e) => e, + }; + let kind = err.kind(); + assert!( + matches!( + kind, + clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue + ), + "expected ValueValidation or InvalidValue, got {:?}", + kind + ); +} + +#[test] +fn ecosystems_csv_multi() { + let args = parse_scan(&["--ecosystems", "npm,pypi,cargo,maven"]); + assert_eq!( + args.ecosystems, + Some(vec![ + "npm".to_string(), + "pypi".to_string(), + "cargo".to_string(), + "maven".to_string(), + ]) + ); +} + +#[test] +fn ecosystems_csv_single() { + let args = parse_scan(&["--ecosystems", "npm"]); + assert_eq!(args.ecosystems, Some(vec!["npm".to_string()])); +} + +#[test] +fn download_mode_diff() { + let args = parse_scan(&["--download-mode", "diff"]); + assert_eq!(args.download_mode, "diff"); +} + +#[test] +fn download_mode_package() { + let args = parse_scan(&["--download-mode", "package"]); + assert_eq!(args.download_mode, "package"); +} + +#[test] +fn download_mode_file() { + let args = parse_scan(&["--download-mode", "file"]); + assert_eq!(args.download_mode, "file"); +} + +#[test] +fn unknown_flag_fails() { + let err = match try_parse_scan(&["--not-a-real-flag"]) { + Ok(_) => panic!("unknown flag should fail to parse"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_setup.rs b/crates/socket-patch-cli/tests/cli_parse_setup.rs new file mode 100644 index 0000000..556cc4b --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_parse_setup.rs @@ -0,0 +1,164 @@ +//! Parser-level contract tests for `socket-patch setup`. +//! +//! Locks in every flag in the `SetupArgs` table from +//! `crates/socket-patch-cli/CLI_CONTRACT.md` (long + short forms, defaults) +//! and exercises two no-network `run()` paths: +//! +//! 1. Calling `run()` directly against an empty tempdir → exit 0. +//! 2. Spawning the binary against the same empty tempdir with `--json` and +//! asserting the documented `status: "no_files"` shape. +//! +//! These tests deliberately stay off the network so they run in the default +//! `cargo test` set (no `--ignored` required). + +use clap::Parser; +use socket_patch_cli::commands::setup::{run, SetupArgs}; +use socket_patch_cli::{Cli, Commands}; +use std::path::PathBuf; +use std::process::Command; + +fn parse_setup(extra: &[&str]) -> SetupArgs { + let mut argv = vec!["socket-patch", "setup"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv).expect("parse"); + match cli.command { + Commands::Setup(a) => a, + _ => panic!("expected Setup"), + } +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +#[test] +fn defaults_with_no_flags() { + let args = parse_setup(&[]); + assert_eq!(args.cwd, PathBuf::from(".")); + assert!(!args.dry_run); + assert!(!args.yes); + assert!(!args.json); +} + +// --------------------------------------------------------------------------- +// Flag forms — each one in the contract table must have a test +// --------------------------------------------------------------------------- + +#[test] +fn dry_run_short_form() { + let args = parse_setup(&["-d"]); + assert!(args.dry_run); +} + +#[test] +fn dry_run_long_form() { + let args = parse_setup(&["--dry-run"]); + assert!(args.dry_run); +} + +#[test] +fn yes_short_form() { + let args = parse_setup(&["-y"]); + assert!(args.yes); +} + +#[test] +fn yes_long_form() { + let args = parse_setup(&["--yes"]); + assert!(args.yes); +} + +#[test] +fn cwd_long_form() { + let args = parse_setup(&["--cwd", "/tmp/x"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); +} + +#[test] +fn json_long_form() { + let args = parse_setup(&["--json"]); + assert!(args.json); +} + +#[test] +fn all_flags_combined() { + let args = parse_setup(&["--cwd", "/tmp/x", "-d", "-y", "--json"]); + assert_eq!(args.cwd, PathBuf::from("/tmp/x")); + assert!(args.dry_run); + assert!(args.yes); + assert!(args.json); +} + +// --------------------------------------------------------------------------- +// Failure paths +// --------------------------------------------------------------------------- + +#[test] +fn unknown_flag_is_error() { + let result = Cli::try_parse_from(["socket-patch", "setup", "--not-a-real-flag"]); + let err = match result { + Ok(_) => panic!("unknown flag must fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} + +// --------------------------------------------------------------------------- +// Async run() — empty tempdir, no package.json files → exit 0 +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn run_empty_tempdir_exits_zero() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let args = SetupArgs { + cwd: tempdir.path().to_path_buf(), + dry_run: false, + yes: true, + json: true, + }; + let exit = run(args).await; + assert_eq!( + exit, 0, + "empty tempdir (no package.json) must exit 0 with status 'no_files'" + ); +} + +// --------------------------------------------------------------------------- +// Subprocess: lock the JSON contract shape for `status: no_files`. +// --------------------------------------------------------------------------- + +#[test] +fn subprocess_no_files_json_shape() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let exe = env!("CARGO_BIN_EXE_socket-patch"); + let output = Command::new(exe) + .arg("setup") + .arg("--cwd") + .arg(tempdir.path()) + .arg("--json") + .arg("--yes") + .output() + .expect("spawn socket-patch"); + assert!( + output.status.success(), + "setup against empty tempdir must succeed, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be JSON, got {stdout:?}: {e}"); + }); + assert_eq!( + v["status"], "no_files", + "status must be 'no_files' for empty tempdir; full payload: {v}" + ); + assert_eq!(v["updated"], 0); + assert_eq!(v["alreadyConfigured"], 0); + assert_eq!(v["errors"], 0); + assert!(v["files"].is_array(), "'files' must be an array"); + assert_eq!( + v["files"].as_array().expect("array").len(), + 0, + "'files' must be an empty array for status 'no_files'" + ); +} diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 30fae4a..1417775 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -1,8 +1,19 @@ use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::manifest::schema::PatchManifest; +/// Resolve a manifest path: absolute paths are returned as-is, relative paths +/// are joined to `cwd`. Centralizes the duplicate block previously inlined in +/// apply/rollback/list/remove/repair commands. +pub fn resolve_manifest_path(cwd: &Path, manifest_path: &str) -> PathBuf { + if Path::new(manifest_path).is_absolute() { + PathBuf::from(manifest_path) + } else { + cwd.join(manifest_path) + } +} + /// Get all blob hashes referenced by a manifest (both beforeHash and afterHash). /// Used for garbage collection and validation. pub fn get_referenced_blobs(manifest: &PatchManifest) -> HashSet { @@ -457,4 +468,30 @@ mod tests { let read_back = read_back.unwrap(); assert_eq!(read_back.patches.len(), 2); } + + #[test] + fn test_resolve_manifest_path_relative_joins_cwd() { + let cwd = Path::new("/tmp/proj"); + let resolved = resolve_manifest_path(cwd, ".socket/manifest.json"); + assert_eq!(resolved, PathBuf::from("/tmp/proj/.socket/manifest.json")); + } + + #[test] + fn test_resolve_manifest_path_absolute_unchanged() { + let cwd = Path::new("/tmp/proj"); + let absolute = if cfg!(windows) { + r"C:\custom\manifest.json" + } else { + "/etc/custom/manifest.json" + }; + let resolved = resolve_manifest_path(cwd, absolute); + assert_eq!(resolved, PathBuf::from(absolute)); + } + + #[test] + fn test_resolve_manifest_path_relative_dotted() { + let cwd = Path::new("/tmp/proj"); + let resolved = resolve_manifest_path(cwd, "../manifest.json"); + assert_eq!(resolved, PathBuf::from("/tmp/proj/../manifest.json")); + } } diff --git a/crates/socket-patch-core/src/patch/package.rs b/crates/socket-patch-core/src/patch/package.rs index a4f4b5f..c99d91d 100644 --- a/crates/socket-patch-core/src/patch/package.rs +++ b/crates/socket-patch-core/src/patch/package.rs @@ -94,7 +94,18 @@ pub fn read_archive_to_map(archive_path: &Path) -> Result