a73x

tests/release_cli_test.rs

Ref:   Size: 19.9 KiB   History

mod common;

use std::process::Output;

use common::ServerHarness;

/// Run `git-collab release …` in the harness work repo against the harness SSH server.
fn release_cmd(harness: &ServerHarness, args: &[&str]) -> Output {
    let mut cmd = harness.work_repo().cli_command();
    cmd.env("GIT_COLLAB_SSH_COMMAND", harness.ssh_command_string());
    cmd.args(["release"]).args(args);
    cmd.output().expect("failed to run git-collab release")
}

fn setup(name: &str) -> ServerHarness {
    let harness = ServerHarness::new(name);
    harness.push_head();
    let url = harness.repo_ssh_url();
    harness.work_repo().git(&["remote", "add", "srv", &url]);
    harness
}

#[test]
fn publish_list_delete_roundtrip() {
    let harness = setup("cli-roundtrip");
    let tarball = harness.work_repo().dir.path().join("app.tar.gz");
    std::fs::write(&tarball, b"cli release bytes").unwrap();

    let publish = release_cmd(
        &harness,
        &[
            "publish",
            "v1.0.0",
            tarball.to_str().unwrap(),
            "--remote",
            "srv",
        ],
    );
    assert!(
        publish.status.success(),
        "publish failed: {}{}",
        String::from_utf8_lossy(&publish.stdout),
        String::from_utf8_lossy(&publish.stderr)
    );
    let out = String::from_utf8_lossy(&publish.stdout);
    assert!(out.contains("Published v1.0.0/app.tar.gz"));

    let list = release_cmd(&harness, &["list", "--remote", "srv"]);
    assert!(list.status.success());
    assert!(String::from_utf8_lossy(&list.stdout).contains("v1.0.0"));

    let list_json = release_cmd(&harness, &["list", "--json", "--remote", "srv"]);
    let index: serde_json::Value =
        serde_json::from_slice(&list_json.stdout).expect("list --json not valid JSON");
    assert_eq!(index["versions"][0]["files"][0]["name"], "app.tar.gz");

    let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]);
    assert!(delete.status.success());
    let after: serde_json::Value = serde_json::from_slice(
        &release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout,
    )
    .unwrap();
    assert_eq!(after["versions"].as_array().unwrap().len(), 0);
}

/// `core.sshCommand` (git's own config for a custom ssh invocation — custom
/// keys, ports, jump hosts) must be honored on its own, without
/// GIT_COLLAB_SSH_COMMAND set, so `git-collab release` agrees with `git push`
/// against the same remote.
#[test]
fn core_ssh_command_alone_is_sufficient() {
    let harness = setup("cli-core-ssh-command");
    harness
        .work_repo()
        .git(&["config", "core.sshCommand", &harness.ssh_command_string()]);

    let mut cmd = harness.work_repo().cli_command();
    cmd.env_remove("GIT_COLLAB_SSH_COMMAND");
    cmd.args(["release", "list", "--remote", "srv"]);
    let output = cmd.output().expect("failed to run git-collab release");

    assert!(
        output.status.success(),
        "list failed: {}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(String::from_utf8_lossy(&output.stdout).contains("No releases."));
}

/// The unit tests for resolve_ssh_command() are purely positional and cannot
/// catch a mis-wired call site (e.g. ssh_command() swapping GIT_SSH_COMMAND
/// and core.sshCommand when constructing SshCommandSources) — every unit
/// test would still pass unchanged since it calls the function directly with
/// literal values. These two tests exercise the real call site instead: only
/// one of GIT_SSH_COMMAND / core.sshCommand is set to a command that actually
/// works, so the test can only pass if the CLI picked the right one.
#[test]
fn git_ssh_command_env_beats_core_ssh_command() {
    let harness = setup("cli-git-env-beats-config");
    // core.sshCommand points at a command that is not ssh at all and will
    // always fail — if it wins, the release command fails.
    harness
        .work_repo()
        .git(&["config", "core.sshCommand", "/bin/false"]);

    let mut cmd = harness.work_repo().cli_command();
    cmd.env_remove("GIT_COLLAB_SSH_COMMAND");
    cmd.env("GIT_SSH_COMMAND", harness.ssh_command_string());
    cmd.args(["release", "list", "--remote", "srv"]);
    let output = cmd.output().expect("failed to run git-collab release");

    assert!(
        output.status.success(),
        "expected GIT_SSH_COMMAND to win over a failing core.sshCommand: {}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

/// Inverse of `git_ssh_command_env_beats_core_ssh_command`: this is the
/// assertion that actually catches a call-site re-swap, since the other
/// direction could pass for the wrong reason if both sources happened to
/// work. Here GIT_SSH_COMMAND is the one that always fails, so the command
/// only succeeds if core.sshCommand wrongly wins.
#[test]
fn core_ssh_command_does_not_beat_git_ssh_command_env() {
    let harness = setup("cli-config-does-not-beat-git-env");
    harness
        .work_repo()
        .git(&["config", "core.sshCommand", &harness.ssh_command_string()]);

    let mut cmd = harness.work_repo().cli_command();
    cmd.env_remove("GIT_COLLAB_SSH_COMMAND");
    cmd.env("GIT_SSH_COMMAND", "/bin/false");
    cmd.args(["release", "list", "--remote", "srv"]);
    let output = cmd.output().expect("failed to run git-collab release");

    assert!(
        !output.status.success(),
        "expected GIT_SSH_COMMAND=/bin/false to win over a working core.sshCommand (i.e. fail), but it succeeded: {}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn duplicate_publish_needs_force_flag() {
    let harness = setup("cli-force");
    let tarball = harness.work_repo().dir.path().join("a.tar.gz");
    std::fs::write(&tarball, b"one").unwrap();
    let path = tarball.to_str().unwrap();

    assert!(
        release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"])
            .status
            .success()
    );

    let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]);
    assert!(!dup.status.success());
    let stderr = String::from_utf8_lossy(&dup.stderr);
    assert!(stderr.contains("already exists"), "stderr: {}", stderr);
    // The server's "error: ..." reply must not be doubled up, and ssh's own
    // host-key banner must not be glued into the message (the reproduced bug).
    assert_eq!(
        stderr.matches("error:").count(),
        1,
        "expected exactly one 'error:' in stderr: {}",
        stderr
    );
    assert!(
        stderr.trim_start().starts_with("error:"),
        "stderr should start with 'error:': {}",
        stderr
    );
    assert!(
        !stderr.contains("Warning: Permanently added"),
        "ssh host-key banner leaked into error message: {}",
        stderr
    );

    std::fs::write(&tarball, b"two").unwrap();
    let forced = release_cmd(
        &harness,
        &["publish", "v1", path, "--force", "--remote", "srv"],
    );
    assert!(forced.status.success());
}

#[test]
fn non_ssh_remote_is_a_clear_error() {
    let harness = setup("cli-bad-remote");
    let tarball = harness.work_repo().dir.path().join("a.tar.gz");
    std::fs::write(&tarball, b"x").unwrap();

    // "origin" is a local filesystem path in the harness
    let output = release_cmd(
        &harness,
        &[
            "publish",
            "v1",
            tarball.to_str().unwrap(),
            "--remote",
            "origin",
        ],
    );
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote"));
}

#[test]
fn invalid_version_rejected_client_side() {
    let harness = setup("cli-bad-version");
    let tarball = harness.work_repo().dir.path().join("a.tar.gz");
    std::fs::write(&tarball, b"x").unwrap();

    let output = release_cmd(
        &harness,
        &[
            "publish",
            "../evil",
            tarball.to_str().unwrap(),
            "--remote",
            "srv",
        ],
    );
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version"));

    // delete() must validate client-side too, mirroring publish() — no network
    // round trip needed to reject an obviously-bad version name.
    let delete = release_cmd(&harness, &["delete", "../evil", "--remote", "srv"]);
    assert!(!delete.status.success());
    assert!(String::from_utf8_lossy(&delete.stderr).contains("invalid version"));
}

/// If one file in a multi-file publish fails (e.g. a duplicate rejected
/// without --force), earlier successes are still reported, the command exits
/// non-zero, and the failing file's error is reported too.
#[test]
fn multi_file_publish_reports_partial_failure() {
    let harness = setup("cli-partial");
    let file_a = harness.work_repo().dir.path().join("file_a.tar.gz");
    let file_b = harness.work_repo().dir.path().join("file_b.tar.gz");
    std::fs::write(&file_a, b"a-bytes").unwrap();
    std::fs::write(&file_b, b"b-bytes").unwrap();

    // Publish file_a first, so a later attempt to republish it without
    // --force is rejected as a duplicate.
    assert!(release_cmd(
        &harness,
        &["publish", "v1", file_a.to_str().unwrap(), "--remote", "srv"]
    )
    .status
    .success());

    // Now publish [file_b, file_a]: file_b is new (succeeds), file_a is a
    // duplicate (fails).
    let output = release_cmd(
        &harness,
        &[
            "publish",
            "v1",
            file_b.to_str().unwrap(),
            file_a.to_str().unwrap(),
            "--remote",
            "srv",
        ],
    );
    assert!(!output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Published v1/file_b.tar.gz"),
        "stdout: {}",
        stdout
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("file_a.tar.gz") && stderr.contains("already exists"),
        "stderr: {}",
        stderr
    );
}

// ---------------------------------------------------------------------------
// `--json` on the two release commands that write
// ---------------------------------------------------------------------------
//
// `release list --json` has been covered since it existed, because it is a
// pass-through of the server's own index. `publish` and `delete` build their
// object client-side and were never exercised against a live server at all.
// Three rules make up the contract, and all three are asserted below:
//
// - stdout is exactly one JSON value and nothing else (parsing the *whole* of
//   stdout is the assertion — serde_json rejects trailing content), so the
//   per-file prose `publish` otherwise prints has to be held back;
// - every identifier is the full one. For a release that is the sha256, which
//   must be the whole 64-character digest of the bytes that landed, never an
//   abbreviation;
// - a failure prints `{"error": ...}` on stdout and exits 1, per e049a2bb — a
//   caller that asked for JSON never has to read stderr, and that is the half
//   of the contract a script actually depends on.

/// Parse the whole of a successful command's stdout as one JSON value.
fn json_ok(output: &Output, what: &str) -> serde_json::Value {
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        output.status.success(),
        "{} failed: {}{}",
        what,
        stdout,
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "{} did not print exactly one JSON value: {}\nstdout was:\n{}",
            what, e, stdout
        )
    })
}

/// Parse a failed command's stdout as the one error object it owes, and return
/// the message it carried.
fn json_err(output: &Output, what: &str) -> String {
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !output.status.success(),
        "{} was expected to fail but succeeded: {}",
        what,
        stdout
    );
    assert_eq!(
        output.status.code(),
        Some(1),
        "{} must exit 1, not {:?}",
        what,
        output.status.code()
    );
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "{} did not print exactly one JSON value on stdout: {}\nstdout was:\n{}",
            what, e, stdout
        )
    });
    assert!(
        json.get("error").is_some(),
        "{} must report the failure under \"error\": {}",
        what,
        json
    );
    // A failure reports nothing else: no half-written result a caller could
    // mistake for success.
    assert!(
        json.get("action").is_none() && json.get("files").is_none(),
        "{} must not report a result alongside the error: {}",
        what,
        json
    );
    // Still on stderr as well, for the human running the same command by hand.
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("error:"),
        "{} must still say so on stderr: {}",
        what,
        stderr
    );
    json["error"].as_str().unwrap().to_string()
}

fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::Digest;
    sha2::Sha256::digest(bytes)
        .iter()
        .map(|b| format!("{:02x}", b))
        .collect()
}

fn assert_full_sha256(value: &serde_json::Value, expected: &str) {
    let s = value
        .as_str()
        .unwrap_or_else(|| panic!("sha256 is not a string: {}", value));
    assert_eq!(
        s.len(),
        64,
        "sha256 must be the full digest, got {:?} ({} chars)",
        s,
        s.len()
    );
    assert!(
        s.chars()
            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
        "sha256 must be lowercase hex, got {:?}",
        s
    );
    assert_eq!(s, expected, "sha256 must be the digest of what was sent");
}

#[test]
fn publish_json_is_one_object_naming_every_file_by_full_checksum() {
    let harness = setup("cli-publish-json");
    let dir = harness.work_repo().dir.path();
    let first = dir.join("first.tar.gz");
    let second = dir.join("second.tar.gz");
    std::fs::write(&first, b"first release bytes").unwrap();
    std::fs::write(&second, b"second release bytes").unwrap();

    let output = release_cmd(
        &harness,
        &[
            "publish",
            "v2.0.0",
            first.to_str().unwrap(),
            second.to_str().unwrap(),
            "--remote",
            "srv",
            "--json",
        ],
    );
    let json = json_ok(&output, "release publish --json");

    assert_eq!(json["action"], "release.publish");
    assert_eq!(json["version"], "v2.0.0");
    let files = json["files"].as_array().expect("files is not an array");
    assert_eq!(files.len(), 2, "every file published belongs in the object");
    assert_eq!(files[0]["name"], "first.tar.gz");
    assert_full_sha256(&files[0]["sha256"], &sha256_hex(b"first release bytes"));
    assert_eq!(files[1]["name"], "second.tar.gz");
    assert_full_sha256(&files[1]["sha256"], &sha256_hex(b"second release bytes"));

    // The prose the non-JSON path prints per file must not be mixed in. The
    // parse above already proves it, but this says which rule was broken.
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !stdout.contains("Published"),
        "the per-file prose has to be held back under --json: {}",
        stdout
    );

    // And the checksums are the ones the server acknowledged, not a hopeful
    // client-side guess: what `list` reports for the same files has to agree.
    let index = json_ok(
        &release_cmd(&harness, &["list", "--json", "--remote", "srv"]),
        "release list --json",
    );
    // Matched by name, not position: `list` sorts and `publish` reports in the
    // order it was given, and this is a claim about the checksums.
    let listed = index["versions"][0]["files"].as_array().unwrap().clone();
    for published in files {
        let same = listed
            .iter()
            .find(|f| f["name"] == published["name"])
            .unwrap_or_else(|| panic!("{} is not in the index", published["name"]));
        assert_eq!(same["sha256"], published["sha256"]);
    }
}

#[test]
fn delete_json_distinguishes_one_file_from_the_whole_version() {
    let harness = setup("cli-delete-json");
    let dir = harness.work_repo().dir.path();
    let doomed = dir.join("doomed.tar.gz");
    let keeper = dir.join("keeper.tar.gz");
    std::fs::write(&doomed, b"doomed").unwrap();
    std::fs::write(&keeper, b"keeper").unwrap();
    assert!(release_cmd(
        &harness,
        &[
            "publish",
            "v3",
            doomed.to_str().unwrap(),
            keeper.to_str().unwrap(),
            "--remote",
            "srv",
        ]
    )
    .status
    .success());

    let one = json_ok(
        &release_cmd(
            &harness,
            &["delete", "v3", "doomed.tar.gz", "--remote", "srv", "--json"],
        ),
        "release delete <file> --json",
    );
    assert_eq!(one["action"], "release.delete");
    assert_eq!(one["version"], "v3");
    assert_eq!(one["file"], "doomed.tar.gz");

    let after = json_ok(
        &release_cmd(&harness, &["list", "--json", "--remote", "srv"]),
        "release list --json",
    );
    let remaining = after["versions"][0]["files"].as_array().unwrap();
    assert_eq!(remaining.len(), 1, "only the named file went: {}", after);
    assert_eq!(remaining[0]["name"], "keeper.tar.gz");

    let whole = json_ok(
        &release_cmd(&harness, &["delete", "v3", "--remote", "srv", "--json"]),
        "release delete --json",
    );
    assert_eq!(whole["action"], "release.delete");
    assert_eq!(whole["version"], "v3");
    assert!(
        whole["file"].is_null(),
        "null is how the object says the whole version went, not a file with no name: {}",
        whole
    );

    let after = json_ok(
        &release_cmd(&harness, &["list", "--json", "--remote", "srv"]),
        "release list --json",
    );
    assert_eq!(after["versions"].as_array().unwrap().len(), 0);
}

#[test]
fn a_failing_publish_prints_the_error_object_on_stdout() {
    // The server rejects a duplicate without --force. That error is born on
    // the far end of an ssh pipe, which is the path most likely to leak onto
    // stdout as prose or arrive glued to ssh's own chatter.
    let harness = setup("cli-publish-json-fail");
    let tarball = harness.work_repo().dir.path().join("dup.tar.gz");
    std::fs::write(&tarball, b"one").unwrap();
    let path = tarball.to_str().unwrap();

    assert!(
        release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"])
            .status
            .success()
    );

    let output = release_cmd(
        &harness,
        &["publish", "v1", path, "--remote", "srv", "--json"],
    );
    let message = json_err(&output, "a duplicate release publish --json");
    assert!(
        message.contains("already exists"),
        "the server's reason has to survive into the object: {:?}",
        message
    );
    assert!(
        !message.contains("Permanently added"),
        "ssh's host-key banner must not be glued into the message: {:?}",
        message
    );
}

#[test]
fn a_failing_delete_prints_the_error_object_on_stdout() {
    let harness = setup("cli-delete-json-fail");

    // Rejected by the server: there is no such version to delete.
    let missing = release_cmd(&harness, &["delete", "v9", "--remote", "srv", "--json"]);
    let message = json_err(&missing, "deleting a missing release with --json");
    assert!(
        message.contains("not found"),
        "the server's reason has to survive into the object: {:?}",
        message
    );

    // Rejected client-side, before any network round trip: the same contract
    // has to hold on the path that never reaches the server.
    let bad = release_cmd(
        &harness,
        &["delete", "../evil", "--remote", "srv", "--json"],
    );
    let message = json_err(&bad, "deleting an invalid version with --json");
    assert!(
        message.contains("invalid version"),
        "expected an invalid-version message, got {:?}",
        message
    );
}