d53c2e87
Cover issue-side close/reopen convergence and release --json
a73x 2026-08-12 17:11
Commit message
tests/issue_reopen_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,281 @@ | |||
| 1 | //! Issue-side close/reopen convergence across two clones. | ||
| 2 | //! | ||
| 3 | //! `close` moves an object's ref between the active and the archive namespace | ||
| 4 | //! while its id stays the same, so one clone that closed and another that | ||
| 5 | //! reopened publish the *same* object under two different sync prefixes. Sync | ||
| 6 | //! has to recognise those as one object and reconcile them into one DAG; | ||
| 7 | //! reconciling only the active prefix adopts them as two divergent histories | ||
| 8 | //! for one id, which no later sync can reunite. See `64194908` and | ||
| 9 | //! `state::existing_events_ref`. | ||
| 10 | //! | ||
| 11 | //! `tests/patch_reopen_test.rs` covers the patch side of the same code path. | ||
| 12 | //! This is its issue-side twin — issues are the more common object and | ||
| 13 | //! `issue reopen` is much older than `patch reopen`, so this is the side more | ||
| 14 | //! likely to have been hit in practice. | ||
| 15 | //! | ||
| 16 | //! Unlike the patch-side test, both cases below pin *which* side wins rather | ||
| 17 | //! than only asserting the two clones agree. The fold is ordered by | ||
| 18 | //! `(clock, oid)` and a clock is `max_clock(tip) + 1`, so giving the winning | ||
| 19 | //! clone one extra local event before its status event puts its status event a | ||
| 20 | //! clock ahead — no tie, no dependence on how two oids happen to sort. | ||
| 21 | |||
| 22 | mod common; | ||
| 23 | |||
| 24 | use std::process::Command; | ||
| 25 | |||
| 26 | use serde_json::Value; | ||
| 27 | use tempfile::TempDir; | ||
| 28 | |||
| 29 | use common::TestRepo; | ||
| 30 | |||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | // Harness | ||
| 33 | // --------------------------------------------------------------------------- | ||
| 34 | |||
| 35 | fn repo_with_origin() -> (TestRepo, TempDir) { | ||
| 36 | let bare = TempDir::new().unwrap(); | ||
| 37 | let status = Command::new("git") | ||
| 38 | .args(["init", "--bare", "-b", "main"]) | ||
| 39 | .arg(bare.path()) | ||
| 40 | .status() | ||
| 41 | .unwrap(); | ||
| 42 | assert!(status.success()); | ||
| 43 | |||
| 44 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 45 | repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]); | ||
| 46 | repo.git(&["push", "-u", "origin", "main"]); | ||
| 47 | repo.run_ok(&["init"]); | ||
| 48 | (repo, bare) | ||
| 49 | } | ||
| 50 | |||
| 51 | fn git_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String { | ||
| 52 | let mut command = Command::new("git"); | ||
| 53 | env_from.apply_env(&mut command); | ||
| 54 | let output = command.args(args).current_dir(dir).output().unwrap(); | ||
| 55 | assert!( | ||
| 56 | output.status.success(), | ||
| 57 | "git {:?} failed: {}", | ||
| 58 | args, | ||
| 59 | String::from_utf8_lossy(&output.stderr) | ||
| 60 | ); | ||
| 61 | String::from_utf8(output.stdout).unwrap() | ||
| 62 | } | ||
| 63 | |||
| 64 | fn collab_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String { | ||
| 65 | let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab")); | ||
| 66 | env_from.apply_env(&mut command); | ||
| 67 | let output = command.args(args).current_dir(dir).output().unwrap(); | ||
| 68 | assert!( | ||
| 69 | output.status.success(), | ||
| 70 | "git-collab {:?} failed:\nstdout: {}\nstderr: {}", | ||
| 71 | args, | ||
| 72 | String::from_utf8_lossy(&output.stdout), | ||
| 73 | String::from_utf8_lossy(&output.stderr) | ||
| 74 | ); | ||
| 75 | String::from_utf8(output.stdout).unwrap() | ||
| 76 | } | ||
| 77 | |||
| 78 | /// A second clone of `bare`, configured as Bob and initialised for collab. | ||
| 79 | /// Returns the tempdir keeping it alive and the path to the clone itself. | ||
| 80 | fn second_clone(alice: &TestRepo, bare: &TempDir) -> (TempDir, std::path::PathBuf) { | ||
| 81 | let root = TempDir::new().unwrap(); | ||
| 82 | let dir = root.path().join("clone"); | ||
| 83 | git_in( | ||
| 84 | alice, | ||
| 85 | root.path(), | ||
| 86 | &["clone", bare.path().to_str().unwrap(), "clone"], | ||
| 87 | ); | ||
| 88 | git_in(alice, &dir, &["config", "user.name", "Bob"]); | ||
| 89 | git_in(alice, &dir, &["config", "user.email", "bob@example.com"]); | ||
| 90 | git_in(alice, &dir, &["config", "collab.autoSync", "false"]); | ||
| 91 | collab_in(alice, &dir, &["init"]); | ||
| 92 | collab_in(alice, &dir, &["sync"]); | ||
| 93 | (root, dir) | ||
| 94 | } | ||
| 95 | |||
| 96 | fn show_json(repo: &TestRepo, id: &str) -> Value { | ||
| 97 | serde_json::from_str(&repo.run_ok(&["issue", "show", id, "--json"])).unwrap() | ||
| 98 | } | ||
| 99 | |||
| 100 | fn show_json_in(env_from: &TestRepo, dir: &std::path::Path, id: &str) -> Value { | ||
| 101 | serde_json::from_str(&collab_in(env_from, dir, &["issue", "show", id, "--json"])).unwrap() | ||
| 102 | } | ||
| 103 | |||
| 104 | /// The events ref for `short` in a `for-each-ref` listing. | ||
| 105 | /// | ||
| 106 | /// Each clone is asked for its own. Which namespace a clone files the object | ||
| 107 | /// in is a local decision — only the clone that ran `close` moves the ref, so | ||
| 108 | /// a close that merely *arrives* by sync leaves it where it was — and neither | ||
| 109 | /// filing is wrong. The DAG tip and the folded status are the facts that have | ||
| 110 | /// to match. | ||
| 111 | fn issue_events_ref_in(listing: &str, short: &str) -> String { | ||
| 112 | listing | ||
| 113 | .lines() | ||
| 114 | .find(|r| r.contains(short)) | ||
| 115 | .unwrap_or_else(|| panic!("no events ref for {} in {}", short, listing)) | ||
| 116 | .to_string() | ||
| 117 | } | ||
| 118 | |||
| 119 | fn issue_events_ref(repo: &TestRepo, short: &str) -> String { | ||
| 120 | let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]); | ||
| 121 | issue_events_ref_in(&out, short) | ||
| 122 | } | ||
| 123 | |||
| 124 | fn issue_events_ref_at(env_from: &TestRepo, dir: &std::path::Path, short: &str) -> String { | ||
| 125 | let out = git_in( | ||
| 126 | env_from, | ||
| 127 | dir, | ||
| 128 | &["for-each-ref", "--format=%(refname)", "refs/collab/"], | ||
| 129 | ); | ||
| 130 | issue_events_ref_in(&out, short) | ||
| 131 | } | ||
| 132 | |||
| 133 | /// Assert both clones ended on one DAG — the property the whole fix exists for. | ||
| 134 | /// Divergence is exactly "two tips for one id", so comparing tips is what | ||
| 135 | /// catches it; the folded status can coincide even when the histories have not | ||
| 136 | /// been reunited. | ||
| 137 | fn assert_same_tip(alice: &TestRepo, bob: &std::path::Path, short: &str) { | ||
| 138 | let alice_ref = issue_events_ref(alice, short); | ||
| 139 | let bob_ref = issue_events_ref_at(alice, bob, short); | ||
| 140 | assert_eq!( | ||
| 141 | alice.git(&["rev-parse", &alice_ref]).trim(), | ||
| 142 | git_in(alice, bob, &["rev-parse", &bob_ref]).trim(), | ||
| 143 | "both clones must end at the same DAG tip ({} vs {})", | ||
| 144 | alice_ref, | ||
| 145 | bob_ref | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | // --------------------------------------------------------------------------- | ||
| 150 | // Convergence: one clone closes, another reopens, neither having seen the other | ||
| 151 | // --------------------------------------------------------------------------- | ||
| 152 | |||
| 153 | #[test] | ||
| 154 | fn a_concurrent_close_and_reopen_converge_on_the_later_close() { | ||
| 155 | // Alice's close is the later event, so `closed` is the answer both clones | ||
| 156 | // owe. Alice is also the clone whose ref moves into the archive namespace, | ||
| 157 | // which is the half that a sync globbing only the active prefix never sees. | ||
| 158 | let (alice, bare) = repo_with_origin(); | ||
| 159 | let short = alice.issue_open("A bug"); | ||
| 160 | alice.run_ok(&["sync"]); | ||
| 161 | |||
| 162 | let (_bob_root, bob) = second_clone(&alice, &bare); | ||
| 163 | |||
| 164 | // Alice takes an extra clock tick before closing, so her close sits at a | ||
| 165 | // strictly higher clock than Bob's reopen and the winner is not decided by | ||
| 166 | // whichever oid happens to sort higher this run. | ||
| 167 | alice.run_ok(&["issue", "comment", &short, "-b", "cannot reproduce"]); | ||
| 168 | alice.run_ok(&["issue", "close", &short]); | ||
| 169 | collab_in(&alice, &bob, &["issue", "reopen", &short]); | ||
| 170 | |||
| 171 | // Alice publishes first, so Bob's sync is the one that has to reconcile a | ||
| 172 | // genuinely divergent DAG arriving under the *other* namespace. | ||
| 173 | alice.run_ok(&["sync"]); | ||
| 174 | collab_in(&alice, &bob, &["sync"]); | ||
| 175 | alice.run_ok(&["sync"]); | ||
| 176 | |||
| 177 | assert_eq!( | ||
| 178 | show_json(&alice, &short)["status"], | ||
| 179 | "closed", | ||
| 180 | "the later close wins on the clone that wrote it" | ||
| 181 | ); | ||
| 182 | assert_eq!( | ||
| 183 | show_json_in(&alice, &bob, &short)["status"], | ||
| 184 | "closed", | ||
| 185 | "and the clone that reopened has to accept it once it has seen it" | ||
| 186 | ); | ||
| 187 | assert_same_tip(&alice, &bob, &short); | ||
| 188 | } | ||
| 189 | |||
| 190 | #[test] | ||
| 191 | fn a_concurrent_close_and_reopen_converge_on_the_later_reopen() { | ||
| 192 | // The mirror image, and the direction that also exercises | ||
| 193 | // `unarchive_if_reopened`: Alice filed the issue away under `close`, and | ||
| 194 | // the reconciled DAG says it is open again, so it has to come back out of | ||
| 195 | // her archive or it is open and unfindable on her clone. | ||
| 196 | let (alice, bare) = repo_with_origin(); | ||
| 197 | let short = alice.issue_open("A bug"); | ||
| 198 | alice.run_ok(&["sync"]); | ||
| 199 | |||
| 200 | let (_bob_root, bob) = second_clone(&alice, &bare); | ||
| 201 | |||
| 202 | alice.run_ok(&["issue", "close", &short]); | ||
| 203 | // Bob's extra tick, so his reopen is the strictly later event. | ||
| 204 | collab_in(&alice, &bob, &["issue", "comment", &short, "-b", "still here"]); | ||
| 205 | collab_in(&alice, &bob, &["issue", "reopen", &short]); | ||
| 206 | |||
| 207 | alice.run_ok(&["sync"]); | ||
| 208 | collab_in(&alice, &bob, &["sync"]); | ||
| 209 | alice.run_ok(&["sync"]); | ||
| 210 | |||
| 211 | assert_eq!( | ||
| 212 | show_json_in(&alice, &bob, &short)["status"], | ||
| 213 | "open", | ||
| 214 | "the later reopen wins on the clone that wrote it" | ||
| 215 | ); | ||
| 216 | let alice_view = show_json(&alice, &short); | ||
| 217 | assert_eq!( | ||
| 218 | alice_view["status"], "open", | ||
| 219 | "and the clone that closed has to accept it once it has seen it" | ||
| 220 | ); | ||
| 221 | assert!( | ||
| 222 | alice_view["close_reason"].is_null(), | ||
| 223 | "an open issue must not still carry the close it denies: {}", | ||
| 224 | alice_view | ||
| 225 | ); | ||
| 226 | assert_same_tip(&alice, &bob, &short); | ||
| 227 | |||
| 228 | // The user-visible half: nothing enumerates the archive namespace, so an | ||
| 229 | // issue reopened elsewhere that stayed filed away would be open and | ||
| 230 | // invisible here. | ||
| 231 | assert!( | ||
| 232 | issue_events_ref(&alice, &short).starts_with("refs/collab/issues/"), | ||
| 233 | "a reopen arriving by sync has to bring the issue back out of the archive, got {}", | ||
| 234 | issue_events_ref(&alice, &short) | ||
| 235 | ); | ||
| 236 | let listed = alice.run_ok(&["issue", "list"]); | ||
| 237 | assert!( | ||
| 238 | listed.contains(&short), | ||
| 239 | "and back into the default list: {}", | ||
| 240 | listed | ||
| 241 | ); | ||
| 242 | } | ||
| 243 | |||
| 244 | #[test] | ||
| 245 | fn both_clones_keep_every_concurrent_event() { | ||
| 246 | // Convergence on one tip is not enough if the merge dropped events on the | ||
| 247 | // way: the comment each clone wrote while offline has to survive on both, | ||
| 248 | // which is what proves the two histories were reunited rather than one of | ||
| 249 | // them being adopted wholesale. | ||
| 250 | let (alice, bare) = repo_with_origin(); | ||
| 251 | let short = alice.issue_open("A bug"); | ||
| 252 | alice.run_ok(&["sync"]); | ||
| 253 | |||
| 254 | let (_bob_root, bob) = second_clone(&alice, &bare); | ||
| 255 | |||
| 256 | alice.run_ok(&["issue", "comment", &short, "-b", "from alice"]); | ||
| 257 | alice.run_ok(&["issue", "close", &short]); | ||
| 258 | collab_in(&alice, &bob, &["issue", "comment", &short, "-b", "from bob"]); | ||
| 259 | collab_in(&alice, &bob, &["issue", "reopen", &short]); | ||
| 260 | |||
| 261 | alice.run_ok(&["sync"]); | ||
| 262 | collab_in(&alice, &bob, &["sync"]); | ||
| 263 | alice.run_ok(&["sync"]); | ||
| 264 | |||
| 265 | for view in [ | ||
| 266 | show_json(&alice, &short), | ||
| 267 | show_json_in(&alice, &bob, &short), | ||
| 268 | ] { | ||
| 269 | let bodies: Vec<String> = view["comments"] | ||
| 270 | .as_array() | ||
| 271 | .unwrap_or_else(|| panic!("no comments array: {}", view)) | ||
| 272 | .iter() | ||
| 273 | .map(|c| c["body"].as_str().unwrap_or("").to_string()) | ||
| 274 | .collect(); | ||
| 275 | assert!( | ||
| 276 | bodies.iter().any(|b| b == "from alice") && bodies.iter().any(|b| b == "from bob"), | ||
| 277 | "both clones' offline comments must survive the merge, got {:?}", | ||
| 278 | bodies | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | } | ||
tests/release_cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -282,3 +282,291 @@ fn multi_file_publish_reports_partial_failure() { | |||
| 282 | stderr | 282 | stderr |
| 283 | ); | 283 | ); |
| 284 | } | 284 | } |
| 285 | |||
| 286 | // --------------------------------------------------------------------------- | ||
| 287 | // `--json` on the two release commands that write | ||
| 288 | // --------------------------------------------------------------------------- | ||
| 289 | // | ||
| 290 | // `release list --json` has been covered since it existed, because it is a | ||
| 291 | // pass-through of the server's own index. `publish` and `delete` build their | ||
| 292 | // object client-side and were never exercised against a live server at all. | ||
| 293 | // Three rules make up the contract, and all three are asserted below: | ||
| 294 | // | ||
| 295 | // - stdout is exactly one JSON value and nothing else (parsing the *whole* of | ||
| 296 | // stdout is the assertion — serde_json rejects trailing content), so the | ||
| 297 | // per-file prose `publish` otherwise prints has to be held back; | ||
| 298 | // - every identifier is the full one. For a release that is the sha256, which | ||
| 299 | // must be the whole 64-character digest of the bytes that landed, never an | ||
| 300 | // abbreviation; | ||
| 301 | // - a failure prints `{"error": ...}` on stdout and exits 1, per e049a2bb — a | ||
| 302 | // caller that asked for JSON never has to read stderr, and that is the half | ||
| 303 | // of the contract a script actually depends on. | ||
| 304 | |||
| 305 | /// Parse the whole of a successful command's stdout as one JSON value. | ||
| 306 | fn json_ok(output: &Output, what: &str) -> serde_json::Value { | ||
| 307 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 308 | assert!( | ||
| 309 | output.status.success(), | ||
| 310 | "{} failed: {}{}", | ||
| 311 | what, | ||
| 312 | stdout, | ||
| 313 | String::from_utf8_lossy(&output.stderr) | ||
| 314 | ); | ||
| 315 | serde_json::from_str(&stdout).unwrap_or_else(|e| { | ||
| 316 | panic!( | ||
| 317 | "{} did not print exactly one JSON value: {}\nstdout was:\n{}", | ||
| 318 | what, e, stdout | ||
| 319 | ) | ||
| 320 | }) | ||
| 321 | } | ||
| 322 | |||
| 323 | /// Parse a failed command's stdout as the one error object it owes, and return | ||
| 324 | /// the message it carried. | ||
| 325 | fn json_err(output: &Output, what: &str) -> String { | ||
| 326 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 327 | assert!( | ||
| 328 | !output.status.success(), | ||
| 329 | "{} was expected to fail but succeeded: {}", | ||
| 330 | what, | ||
| 331 | stdout | ||
| 332 | ); | ||
| 333 | assert_eq!( | ||
| 334 | output.status.code(), | ||
| 335 | Some(1), | ||
| 336 | "{} must exit 1, not {:?}", | ||
| 337 | what, | ||
| 338 | output.status.code() | ||
| 339 | ); | ||
| 340 | let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { | ||
| 341 | panic!( | ||
| 342 | "{} did not print exactly one JSON value on stdout: {}\nstdout was:\n{}", | ||
| 343 | what, e, stdout | ||
| 344 | ) | ||
| 345 | }); | ||
| 346 | assert!( | ||
| 347 | json.get("error").is_some(), | ||
| 348 | "{} must report the failure under \"error\": {}", | ||
| 349 | what, | ||
| 350 | json | ||
| 351 | ); | ||
| 352 | // A failure reports nothing else: no half-written result a caller could | ||
| 353 | // mistake for success. | ||
| 354 | assert!( | ||
| 355 | json.get("action").is_none() && json.get("files").is_none(), | ||
| 356 | "{} must not report a result alongside the error: {}", | ||
| 357 | what, | ||
| 358 | json | ||
| 359 | ); | ||
| 360 | // Still on stderr as well, for the human running the same command by hand. | ||
| 361 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 362 | assert!( | ||
| 363 | stderr.contains("error:"), | ||
| 364 | "{} must still say so on stderr: {}", | ||
| 365 | what, | ||
| 366 | stderr | ||
| 367 | ); | ||
| 368 | json["error"].as_str().unwrap().to_string() | ||
| 369 | } | ||
| 370 | |||
| 371 | fn sha256_hex(bytes: &[u8]) -> String { | ||
| 372 | use sha2::Digest; | ||
| 373 | sha2::Sha256::digest(bytes) | ||
| 374 | .iter() | ||
| 375 | .map(|b| format!("{:02x}", b)) | ||
| 376 | .collect() | ||
| 377 | } | ||
| 378 | |||
| 379 | fn assert_full_sha256(value: &serde_json::Value, expected: &str) { | ||
| 380 | let s = value | ||
| 381 | .as_str() | ||
| 382 | .unwrap_or_else(|| panic!("sha256 is not a string: {}", value)); | ||
| 383 | assert_eq!( | ||
| 384 | s.len(), | ||
| 385 | 64, | ||
| 386 | "sha256 must be the full digest, got {:?} ({} chars)", | ||
| 387 | s, | ||
| 388 | s.len() | ||
| 389 | ); | ||
| 390 | assert!( | ||
| 391 | s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()), | ||
| 392 | "sha256 must be lowercase hex, got {:?}", | ||
| 393 | s | ||
| 394 | ); | ||
| 395 | assert_eq!(s, expected, "sha256 must be the digest of what was sent"); | ||
| 396 | } | ||
| 397 | |||
| 398 | #[test] | ||
| 399 | fn publish_json_is_one_object_naming_every_file_by_full_checksum() { | ||
| 400 | let harness = setup("cli-publish-json"); | ||
| 401 | let dir = harness.work_repo().dir.path(); | ||
| 402 | let first = dir.join("first.tar.gz"); | ||
| 403 | let second = dir.join("second.tar.gz"); | ||
| 404 | std::fs::write(&first, b"first release bytes").unwrap(); | ||
| 405 | std::fs::write(&second, b"second release bytes").unwrap(); | ||
| 406 | |||
| 407 | let output = release_cmd( | ||
| 408 | &harness, | ||
| 409 | &[ | ||
| 410 | "publish", | ||
| 411 | "v2.0.0", | ||
| 412 | first.to_str().unwrap(), | ||
| 413 | second.to_str().unwrap(), | ||
| 414 | "--remote", | ||
| 415 | "srv", | ||
| 416 | "--json", | ||
| 417 | ], | ||
| 418 | ); | ||
| 419 | let json = json_ok(&output, "release publish --json"); | ||
| 420 | |||
| 421 | assert_eq!(json["action"], "release.publish"); | ||
| 422 | assert_eq!(json["version"], "v2.0.0"); | ||
| 423 | let files = json["files"].as_array().expect("files is not an array"); | ||
| 424 | assert_eq!(files.len(), 2, "every file published belongs in the object"); | ||
| 425 | assert_eq!(files[0]["name"], "first.tar.gz"); | ||
| 426 | assert_full_sha256(&files[0]["sha256"], &sha256_hex(b"first release bytes")); | ||
| 427 | assert_eq!(files[1]["name"], "second.tar.gz"); | ||
| 428 | assert_full_sha256(&files[1]["sha256"], &sha256_hex(b"second release bytes")); | ||
| 429 | |||
| 430 | // The prose the non-JSON path prints per file must not be mixed in. The | ||
| 431 | // parse above already proves it, but this says which rule was broken. | ||
| 432 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 433 | assert!( | ||
| 434 | !stdout.contains("Published"), | ||
| 435 | "the per-file prose has to be held back under --json: {}", | ||
| 436 | stdout | ||
| 437 | ); | ||
| 438 | |||
| 439 | // And the checksums are the ones the server acknowledged, not a hopeful | ||
| 440 | // client-side guess: what `list` reports for the same files has to agree. | ||
| 441 | let index = json_ok( | ||
| 442 | &release_cmd(&harness, &["list", "--json", "--remote", "srv"]), | ||
| 443 | "release list --json", | ||
| 444 | ); | ||
| 445 | // Matched by name, not position: `list` sorts and `publish` reports in the | ||
| 446 | // order it was given, and this is a claim about the checksums. | ||
| 447 | let listed = index["versions"][0]["files"].as_array().unwrap().clone(); | ||
| 448 | for published in files { | ||
| 449 | let same = listed | ||
| 450 | .iter() | ||
| 451 | .find(|f| f["name"] == published["name"]) | ||
| 452 | .unwrap_or_else(|| panic!("{} is not in the index", published["name"])); | ||
| 453 | assert_eq!(same["sha256"], published["sha256"]); | ||
| 454 | } | ||
| 455 | } | ||
| 456 | |||
| 457 | #[test] | ||
| 458 | fn delete_json_distinguishes_one_file_from_the_whole_version() { | ||
| 459 | let harness = setup("cli-delete-json"); | ||
| 460 | let dir = harness.work_repo().dir.path(); | ||
| 461 | let doomed = dir.join("doomed.tar.gz"); | ||
| 462 | let keeper = dir.join("keeper.tar.gz"); | ||
| 463 | std::fs::write(&doomed, b"doomed").unwrap(); | ||
| 464 | std::fs::write(&keeper, b"keeper").unwrap(); | ||
| 465 | assert!(release_cmd( | ||
| 466 | &harness, | ||
| 467 | &[ | ||
| 468 | "publish", | ||
| 469 | "v3", | ||
| 470 | doomed.to_str().unwrap(), | ||
| 471 | keeper.to_str().unwrap(), | ||
| 472 | "--remote", | ||
| 473 | "srv", | ||
| 474 | ] | ||
| 475 | ) | ||
| 476 | .status | ||
| 477 | .success()); | ||
| 478 | |||
| 479 | let one = json_ok( | ||
| 480 | &release_cmd( | ||
| 481 | &harness, | ||
| 482 | &["delete", "v3", "doomed.tar.gz", "--remote", "srv", "--json"], | ||
| 483 | ), | ||
| 484 | "release delete <file> --json", | ||
| 485 | ); | ||
| 486 | assert_eq!(one["action"], "release.delete"); | ||
| 487 | assert_eq!(one["version"], "v3"); | ||
| 488 | assert_eq!(one["file"], "doomed.tar.gz"); | ||
| 489 | |||
| 490 | let after = json_ok( | ||
| 491 | &release_cmd(&harness, &["list", "--json", "--remote", "srv"]), | ||
| 492 | "release list --json", | ||
| 493 | ); | ||
| 494 | let remaining = after["versions"][0]["files"].as_array().unwrap(); | ||
| 495 | assert_eq!(remaining.len(), 1, "only the named file went: {}", after); | ||
| 496 | assert_eq!(remaining[0]["name"], "keeper.tar.gz"); | ||
| 497 | |||
| 498 | let whole = json_ok( | ||
| 499 | &release_cmd(&harness, &["delete", "v3", "--remote", "srv", "--json"]), | ||
| 500 | "release delete --json", | ||
| 501 | ); | ||
| 502 | assert_eq!(whole["action"], "release.delete"); | ||
| 503 | assert_eq!(whole["version"], "v3"); | ||
| 504 | assert!( | ||
| 505 | whole["file"].is_null(), | ||
| 506 | "null is how the object says the whole version went, not a file with no name: {}", | ||
| 507 | whole | ||
| 508 | ); | ||
| 509 | |||
| 510 | let after = json_ok( | ||
| 511 | &release_cmd(&harness, &["list", "--json", "--remote", "srv"]), | ||
| 512 | "release list --json", | ||
| 513 | ); | ||
| 514 | assert_eq!(after["versions"].as_array().unwrap().len(), 0); | ||
| 515 | } | ||
| 516 | |||
| 517 | #[test] | ||
| 518 | fn a_failing_publish_prints_the_error_object_on_stdout() { | ||
| 519 | // The server rejects a duplicate without --force. That error is born on | ||
| 520 | // the far end of an ssh pipe, which is the path most likely to leak onto | ||
| 521 | // stdout as prose or arrive glued to ssh's own chatter. | ||
| 522 | let harness = setup("cli-publish-json-fail"); | ||
| 523 | let tarball = harness.work_repo().dir.path().join("dup.tar.gz"); | ||
| 524 | std::fs::write(&tarball, b"one").unwrap(); | ||
| 525 | let path = tarball.to_str().unwrap(); | ||
| 526 | |||
| 527 | assert!( | ||
| 528 | release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]) | ||
| 529 | .status | ||
| 530 | .success() | ||
| 531 | ); | ||
| 532 | |||
| 533 | let output = release_cmd( | ||
| 534 | &harness, | ||
| 535 | &["publish", "v1", path, "--remote", "srv", "--json"], | ||
| 536 | ); | ||
| 537 | let message = json_err(&output, "a duplicate release publish --json"); | ||
| 538 | assert!( | ||
| 539 | message.contains("already exists"), | ||
| 540 | "the server's reason has to survive into the object: {:?}", | ||
| 541 | message | ||
| 542 | ); | ||
| 543 | assert!( | ||
| 544 | !message.contains("Permanently added"), | ||
| 545 | "ssh's host-key banner must not be glued into the message: {:?}", | ||
| 546 | message | ||
| 547 | ); | ||
| 548 | } | ||
| 549 | |||
| 550 | #[test] | ||
| 551 | fn a_failing_delete_prints_the_error_object_on_stdout() { | ||
| 552 | let harness = setup("cli-delete-json-fail"); | ||
| 553 | |||
| 554 | // Rejected by the server: there is no such version to delete. | ||
| 555 | let missing = release_cmd(&harness, &["delete", "v9", "--remote", "srv", "--json"]); | ||
| 556 | let message = json_err(&missing, "deleting a missing release with --json"); | ||
| 557 | assert!( | ||
| 558 | message.contains("not found"), | ||
| 559 | "the server's reason has to survive into the object: {:?}", | ||
| 560 | message | ||
| 561 | ); | ||
| 562 | |||
| 563 | // Rejected client-side, before any network round trip: the same contract | ||
| 564 | // has to hold on the path that never reaches the server. | ||
| 565 | let bad = release_cmd(&harness, &["delete", "../evil", "--remote", "srv", "--json"]); | ||
| 566 | let message = json_err(&bad, "deleting an invalid version with --json"); | ||
| 567 | assert!( | ||
| 568 | message.contains("invalid version"), | ||
| 569 | "expected an invalid-version message, got {:?}", | ||
| 570 | message | ||
| 571 | ); | ||
| 572 | } | ||