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
);
}