socket-patch-cli 3.3.0

CLI binary for socket-patch: apply, rollback, get, scan security patches
Documentation
//! End-to-end tests that drive interactive `dialoguer` prompts via a
//! pseudo-terminal. These exercise the `stdin_is_tty()`-gated
//! confirmation paths in `setup`, `remove`, and `get` that
//! subprocess-with-piped-stdin tests can't reach.
//!
//! PTY support: macOS + Linux. Skipped on Windows.

#![cfg(unix)]

use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;

use portable_pty::{native_pty_system, CommandBuilder, PtySize};

fn binary() -> PathBuf {
    env!("CARGO_BIN_EXE_socket-patch").into()
}

/// Spawn the socket-patch binary inside a PTY, send `input`, and
/// collect all output until the child exits. Returns `(exit_code,
/// output)`. The timeout is enforced via a watchdog thread that
/// kills the child if it doesn't exit in time.
///
/// Three pieces compose:
///   * **Reader thread**: `read_to_end` on the master side.
///     Blocks until EOF, which the kernel sends once both the
///     slave fd (dropped here) and the child's last open fd are
///     closed.
///   * **Watchdog thread**: sleeps `timeout` then sends SIGKILL
///     via a cloned ChildKiller. Detaches; no join needed since
///     the killer is idempotent and the child either exits
///     normally first (kill is a no-op) or is killed (we proceed).
///   * **Main thread**: writes input, closes the writer (sends
///     EOF on the child's stdin), blocks on `child.wait()`, then
///     joins the reader.
///
/// No polling loops, no mpsc channels, no fixed-duration sleeps
/// before sending input — the PTY buffers the input until the
/// child reads it, so timing-coupling isn't needed.
fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) {
    let pty_system = native_pty_system();
    let pair = pty_system
        .openpty(PtySize {
            rows: 24,
            cols: 80,
            pixel_width: 0,
            pixel_height: 0,
        })
        .expect("openpty");

    let mut cmd = CommandBuilder::new(binary());
    for a in args {
        cmd.arg(a);
    }
    cmd.cwd(cwd);
    cmd.env_remove("SOCKET_API_TOKEN");

    let mut child = pair
        .slave
        .spawn_command(cmd)
        .expect("spawn socket-patch in PTY");
    // Drop the slave so the master sees EOF once the child closes its
    // own copy of the slave fd on exit.
    drop(pair.slave);

    // Reader: a single `read_to_end` is sufficient — it blocks until
    // EOF, which arrives when (a) the master is dropped (we do that
    // below) or (b) the child has exited and its end of the slave is
    // closed. The previous design used a chunked read+mpsc loop
    // because it interleaved with a try_wait poll; the simplified
    // design serializes wait → drop master → read_to_end joins.
    let mut reader = pair.master.try_clone_reader().expect("clone reader");
    let reader_handle = std::thread::spawn(move || {
        let mut buf = Vec::new();
        let _ = reader.read_to_end(&mut buf);
        buf
    });

    // Watchdog: detach a thread that kills the child after `timeout`.
    // The cloned ChildKiller is independent of the main `child`
    // handle, so the watchdog can fire without coordinating with the
    // main thread. If the child exits naturally first, the kill is a
    // no-op against a dead pid.
    let mut killer = child.clone_killer();
    std::thread::spawn(move || {
        std::thread::sleep(timeout);
        let _ = killer.kill();
    });

    // Writer: send input then close. PTY buffers absorb the write so
    // no pre-sleep is needed — dialoguer/rustyline will read it when
    // their prompt loop polls stdin.
    let mut writer = pair.master.take_writer().expect("take writer");
    let _ = writer.write_all(input.as_bytes());
    let _ = writer.flush();
    drop(writer);

    // Block until the child exits (watchdog enforces the timeout).
    let status = child.wait().expect("child.wait");
    // Drop the master so the reader's `read_to_end` sees EOF and
    // returns.
    drop(pair.master);

    let output = reader_handle.join().expect("reader thread join");
    let code = status.exit_code() as i32;
    (code, String::from_utf8_lossy(&output).to_string())
}

// ---------------------------------------------------------------------------
// `setup` interactive confirmation
// ---------------------------------------------------------------------------

#[test]
fn setup_interactive_y_proceeds_with_update() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(
        tmp.path().join("package.json"),
        r#"{ "name": "p", "version": "1.0.0" }"#,
    )
    .unwrap();

    // Without --yes, setup prompts "Proceed with these changes? (y/N): ".
    // Sending "y\n" should make it proceed with the update.
    let (code, _output) = run_in_pty(
        &["setup"],
        tmp.path(),
        "y\n",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0, "setup with 'y' must succeed");

    // package.json should have been updated.
    let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
    assert!(
        pkg.contains("socket-patch"),
        "setup must have written postinstall script; got: {pkg}"
    );
}

#[test]
fn setup_interactive_n_aborts_without_update() {
    let tmp = tempfile::tempdir().unwrap();
    let original = r#"{ "name": "p", "version": "1.0.0" }
"#;
    std::fs::write(tmp.path().join("package.json"), original).unwrap();

    let (code, output) = run_in_pty(
        &["setup"],
        tmp.path(),
        "n\n",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0, "setup with 'n' must exit cleanly");
    assert!(
        output.contains("Aborted") || output.contains("aborted"),
        "setup must print abort message; got: {output}"
    );

    // package.json must be unchanged.
    let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
    assert_eq!(pkg, original, "setup 'n' must not modify package.json");
}

#[test]
fn setup_interactive_default_no_aborts() {
    // Pressing just Enter at the prompt defaults to N (abort).
    let tmp = tempfile::tempdir().unwrap();
    let original = r#"{ "name": "p", "version": "1.0.0" }
"#;
    std::fs::write(tmp.path().join("package.json"), original).unwrap();

    let (code, _output) = run_in_pty(
        &["setup"],
        tmp.path(),
        "\n",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0);
    let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
    assert_eq!(pkg, original, "default-N must not modify package.json");
}

// ---------------------------------------------------------------------------
// `remove` interactive confirmation
// ---------------------------------------------------------------------------

const REMOVE_MANIFEST: &str = r#"{
  "patches": {
    "pkg:npm/__interactive_remove__@1.0.0": {
      "uuid": "11111111-1111-4111-8111-111111111111",
      "exportedAt": "2024-01-01T00:00:00Z",
      "files": {},
      "vulnerabilities": {},
      "description": "interactive remove test",
      "license": "MIT",
      "tier": "free"
    }
  }
}"#;

fn write_remove_manifest(root: &Path) {
    let socket = root.join(".socket");
    std::fs::create_dir_all(&socket).unwrap();
    std::fs::write(socket.join("manifest.json"), REMOVE_MANIFEST).unwrap();
}

#[test]
fn remove_interactive_y_proceeds() {
    let tmp = tempfile::tempdir().unwrap();
    write_remove_manifest(tmp.path());

    let (code, _output) = run_in_pty(
        &["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"],
        tmp.path(),
        "y\n",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0);
    // Manifest should be empty now.
    let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap();
    let manifest: serde_json::Value = serde_json::from_str(&body).unwrap();
    assert!(
        manifest["patches"]
            .as_object()
            .map(|p| p.is_empty())
            .unwrap_or(false),
        "remove 'y' must drop the entry; got: {body}"
    );
}

#[test]
fn remove_interactive_n_cancels() {
    let tmp = tempfile::tempdir().unwrap();
    write_remove_manifest(tmp.path());

    let (code, _output) = run_in_pty(
        &["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"],
        tmp.path(),
        "n\n",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0, "remove 'n' must exit cleanly");
    // Manifest must still have the entry.
    let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap();
    let manifest: serde_json::Value = serde_json::from_str(&body).unwrap();
    assert!(
        manifest["patches"]
            .as_object()
            .map(|p| !p.is_empty())
            .unwrap_or(true),
        "remove 'n' must leave manifest intact"
    );
}

// ---------------------------------------------------------------------------
// Apply non-JSON without --yes also exercises confirm() flow,
// even though apply auto-proceeds in non-interactive contexts.
// ---------------------------------------------------------------------------

#[test]
fn apply_in_pty_with_no_manifest_prints_friendly_message() {
    let tmp = tempfile::tempdir().unwrap();
    let (code, output) = run_in_pty(
        &["apply"],
        tmp.path(),
        "",
        Duration::from_secs(15),
    );
    assert_eq!(code, 0);
    assert!(
        output.contains("No .socket folder") || output.contains("skipping"),
        "PTY apply no-manifest must print friendly message; got: {output}"
    );
}