a73x

tests/lease_server_test.rs

Ref:   Size: 13.1 KiB   History

mod common;

use std::process::Output;

use common::ServerHarness;

fn stdout_json(output: &Output) -> serde_json::Value {
    let text = String::from_utf8_lossy(&output.stdout);
    serde_json::from_str(text.trim()).unwrap_or_else(|e| {
        panic!(
            "expected JSON on stdout, got {:?} (stderr: {})\nparse error: {e}",
            text,
            String::from_utf8_lossy(&output.stderr)
        )
    })
}

fn code(output: &Output) -> i32 {
    output.status.code().unwrap_or(-1)
}

/// A harness with one open issue pushed to the server, returning its full id.
/// Leases are keyed on the issue id, and `acquire` resolves it against the
/// server's copy of `refs/collab/*` — so the refs have to be there first.
fn harness_with_issue(name: &str) -> (ServerHarness, String) {
    let harness = ServerHarness::new(name);
    harness.push_head();
    let (_ref_name, id) = common::open_issue(
        &harness.work_repo_git2(),
        &common::alice(),
        "parser chokes on empty input",
    );
    harness.push_collab_refs();
    (harness, id)
}

#[test]
fn acquire_open_issue_succeeds() {
    let (harness, id) = harness_with_issue("lease-acquire");

    let out = harness.ssh_exec(&format!(
        "collab-lease acquire 'lease-acquire.git' '{}'",
        id
    ));
    assert_eq!(
        code(&out),
        0,
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let json = stdout_json(&out);
    assert_eq!(json["status"], "acquired");
    assert_eq!(json["issue"], id);
    assert_eq!(json["token"], 1);
    // No --ttl: an open-ended lease, i.e. a human assignment.
    assert_eq!(json["expires_at"], serde_json::Value::Null);
}

#[test]
fn acquire_with_ttl_reports_expiry() {
    let (harness, id) = harness_with_issue("lease-ttl");

    let out = harness.ssh_exec(&format!(
        "collab-lease acquire 'lease-ttl.git' '{}' --ttl 300",
        id
    ));
    assert_eq!(code(&out), 0);
    let json = stdout_json(&out);
    let expires = json["expires_at"].as_str().expect("expires_at set");
    assert!(
        expires.ends_with('Z') && expires.contains('T'),
        "expected an RFC3339 instant, got {expires}"
    );
}

#[test]
fn acquire_by_issue_id_prefix_reports_full_id() {
    let (harness, id) = harness_with_issue("lease-prefix");

    let out = harness.ssh_exec(&format!(
        "collab-lease acquire 'lease-prefix.git' '{}'",
        &id[..8]
    ));
    assert_eq!(code(&out), 0);
    // The reply carries the full id, so a client that claimed by prefix can
    // renew and release without re-resolving.
    assert_eq!(stdout_json(&out)["issue"], id);
}

#[test]
fn acquire_conflict_reports_holder_with_exit_4() {
    let (harness, id) = harness_with_issue("lease-conflict");
    let first = harness.ssh_client_key();
    let second = harness.second_authorized_key();

    let mine = harness.ssh_exec_as(
        &first,
        &format!(
            "collab-lease acquire 'lease-conflict.git' '{}' --ttl 300",
            id
        ),
        b"",
    );
    assert_eq!(code(&mine), 0);
    let my_holder = stdout_json(&mine)["holder"].as_str().unwrap().to_string();

    let theirs = harness.ssh_exec_as(
        &second,
        &format!(
            "collab-lease acquire 'lease-conflict.git' '{}' --ttl 300",
            id
        ),
        b"",
    );
    assert_eq!(
        code(&theirs),
        4,
        "a lost race must exit 4, not 1: stdout {} stderr {}",
        String::from_utf8_lossy(&theirs.stdout),
        String::from_utf8_lossy(&theirs.stderr)
    );
    let json = stdout_json(&theirs);
    assert_eq!(json["status"], "held");
    assert_eq!(json["holder"], my_holder);
}

#[test]
fn reacquire_by_same_key_is_idempotent() {
    let (harness, id) = harness_with_issue("lease-idem");
    let cmd = format!("collab-lease acquire 'lease-idem.git' '{}' --ttl 300", id);

    let first = harness.ssh_exec(&cmd);
    let second = harness.ssh_exec(&cmd);
    assert_eq!(code(&first), 0);
    assert_eq!(code(&second), 0, "re-acquire by the holder must succeed");
    assert_eq!(stdout_json(&first)["token"], stdout_json(&second)["token"]);
}

#[test]
fn renew_extends_and_wrong_key_exits_4() {
    let (harness, id) = harness_with_issue("lease-renew");
    let first = harness.ssh_client_key();
    let second = harness.second_authorized_key();

    harness.ssh_exec_as(
        &first,
        &format!("collab-lease acquire 'lease-renew.git' '{}' --ttl 60", id),
        b"",
    );

    let mine = harness.ssh_exec_as(
        &first,
        &format!("collab-lease renew 'lease-renew.git' '{}' --ttl 600", id),
        b"",
    );
    assert_eq!(code(&mine), 0);
    assert_eq!(stdout_json(&mine)["status"], "renewed");

    let theirs = harness.ssh_exec_as(
        &second,
        &format!("collab-lease renew 'lease-renew.git' '{}' --ttl 600", id),
        b"",
    );
    assert_eq!(code(&theirs), 4);
    assert_eq!(stdout_json(&theirs)["status"], "not-holder");
}

#[test]
fn release_frees_the_issue_and_bumps_the_next_tenure() {
    let (harness, id) = harness_with_issue("lease-release");
    let first = harness.ssh_client_key();
    let second = harness.second_authorized_key();

    harness.ssh_exec_as(
        &first,
        &format!(
            "collab-lease acquire 'lease-release.git' '{}' --ttl 300",
            id
        ),
        b"",
    );

    // Someone else's live lease is not theirs to drop.
    let refused = harness.ssh_exec_as(
        &second,
        &format!("collab-lease release 'lease-release.git' '{}'", id),
        b"",
    );
    assert_eq!(code(&refused), 4);

    let released = harness.ssh_exec_as(
        &first,
        &format!("collab-lease release 'lease-release.git' '{}'", id),
        b"",
    );
    assert_eq!(code(&released), 0);
    assert_eq!(stdout_json(&released)["status"], "released");

    let next = harness.ssh_exec_as(
        &second,
        &format!(
            "collab-lease acquire 'lease-release.git' '{}' --ttl 300",
            id
        ),
        b"",
    );
    assert_eq!(code(&next), 0);
    // A new tenure, so the fencing token moves on.
    assert_eq!(stdout_json(&next)["token"], 2);
}

#[test]
fn release_without_a_lease_succeeds_and_says_not_held() {
    let (harness, id) = harness_with_issue("lease-release-noop");

    // Idempotent: a client retrying after a dropped connection must not fail.
    let out = harness.ssh_exec(&format!(
        "collab-lease release 'lease-release-noop.git' '{}'",
        id
    ));
    assert_eq!(code(&out), 0);
    // This assertion used to read "released", which is what let the prefix bug
    // of 2026-09-06 pass every test: a release that freed nothing reported the
    // same status as one that freed a lease, so nothing could tell them apart.
    assert_eq!(stdout_json(&out)["status"], "not-held");
}

#[test]
fn list_shows_live_leases() {
    let (harness, id) = harness_with_issue("lease-list");

    let empty = harness.ssh_exec("collab-lease list 'lease-list.git'");
    assert_eq!(code(&empty), 0);
    assert_eq!(stdout_json(&empty)["leases"].as_array().unwrap().len(), 0);

    harness.ssh_exec(&format!(
        "collab-lease acquire 'lease-list.git' '{}' --ttl 300",
        id
    ));

    let out = harness.ssh_exec("collab-lease list 'lease-list.git'");
    let rows = stdout_json(&out)["leases"].as_array().unwrap().clone();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0]["issue"], id);
    assert_eq!(rows[0]["token"], 1);
}

#[test]
fn acquire_unknown_issue_fails() {
    let (harness, _id) = harness_with_issue("lease-unknown");

    let out = harness.ssh_exec("collab-lease acquire 'lease-unknown.git' 'deadbeef'");
    assert_eq!(code(&out), 1);
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("issue not found"),
        "stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}

#[test]
fn acquire_closed_issue_is_refused() {
    let harness = ServerHarness::new("lease-closed");
    harness.push_head();
    let repo = harness.work_repo_git2();
    let (ref_name, id) = common::open_issue(&repo, &common::alice(), "already handled");
    common::close_issue(&repo, &ref_name, &common::alice());
    harness.push_collab_refs();

    let out = harness.ssh_exec(&format!("collab-lease acquire 'lease-closed.git' '{}'", id));
    assert_eq!(code(&out), 1);
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("closed"),
        "stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}

#[test]
fn unknown_repo_and_unauthorized_repo_answer_identically() {
    let (harness, id) = harness_with_issue("lease-policy");

    let unknown = harness.ssh_exec(&format!("collab-lease acquire 'no-such-repo.git' '{}'", id));

    // Readable but not writable: acquiring is a write.
    harness.write_repo_server_policy(
        "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n",
    );
    let unauthorized =
        harness.ssh_exec(&format!("collab-lease acquire 'lease-policy.git' '{}'", id));

    assert_eq!(code(&unknown), 1);
    assert_eq!(code(&unauthorized), 1);
    // The same reply either way, so the error cannot be used to probe which
    // private repos exist.
    assert_eq!(
        String::from_utf8_lossy(&unknown.stdout),
        String::from_utf8_lossy(&unauthorized.stdout),
    );
    assert!(
        String::from_utf8_lossy(&unauthorized.stdout).contains("repository not found"),
        "stdout: {}",
        String::from_utf8_lossy(&unauthorized.stdout)
    );
}

#[test]
fn read_only_principal_may_list_but_not_acquire() {
    let (harness, id) = harness_with_issue("lease-readonly");
    harness.write_repo_server_policy(
        "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n",
    );

    let list = harness.ssh_exec("collab-lease list 'lease-readonly.git'");
    assert_eq!(code(&list), 0, "listing needs only read");

    let acquire = harness.ssh_exec(&format!(
        "collab-lease acquire 'lease-readonly.git' '{}'",
        id
    ));
    assert_eq!(code(&acquire), 1, "acquiring needs write");
}

/// A claim made by prefix must be releasable by that prefix.
///
/// The regression test for the bug two real workers found on 2026-09-06:
/// lease rows are keyed by the full issue id, `acquire` resolved a prefix to
/// one, and `release` took its argument literally — so releasing by prefix
/// updated a key no row used and reported `released` anyway. The lease stayed
/// held until its TTL, blocking every other worker, and the CLI abbreviates
/// ids everywhere, so this was the ordinary path rather than a corner.
#[test]
fn a_claim_made_by_prefix_is_released_by_that_prefix() {
    let (harness, id) = harness_with_issue("lease-prefix-release");
    let first = harness.ssh_client_key();
    let second = harness.second_authorized_key();
    let prefix = &id[..8];

    let claimed = harness.ssh_exec_as(
        &first,
        &format!("collab-lease acquire 'lease-prefix-release.git' '{prefix}' --ttl 300"),
        b"",
    );
    assert_eq!(code(&claimed), 0);

    let released = harness.ssh_exec_as(
        &first,
        &format!("collab-lease release 'lease-prefix-release.git' '{prefix}'"),
        b"",
    );
    assert_eq!(code(&released), 0);
    let json = stdout_json(&released);
    assert_eq!(json["status"], "released", "a held lease must be freed");
    // The reply names the full id, so a caller learns what it actually freed.
    assert_eq!(json["issue"], id);

    // The proof is not the reply: another worker must be able to take it.
    let next = harness.ssh_exec_as(
        &second,
        &format!("collab-lease acquire 'lease-prefix-release.git' '{prefix}' --ttl 300"),
        b"",
    );
    assert_eq!(
        code(&next),
        0,
        "the issue must really be free, not merely reported free: {}",
        String::from_utf8_lossy(&next.stdout)
    );
    assert_eq!(stdout_json(&next)["token"], 2, "a new tenure");
}

/// A prefix that matches two leases is refused rather than picking one.
#[test]
fn an_ambiguous_prefix_is_refused() {
    let harness = ServerHarness::new("lease-ambiguous");
    harness.push_head();
    let repo = harness.work_repo_git2();

    // Issue ids are content-derived, so create enough real issues that two
    // must share their first hex character.
    let mut ids = Vec::new();
    for n in 0..17 {
        let (_r, id) = common::open_issue(&repo, &common::alice(), &format!("Issue {n}"));
        ids.push(id);
    }
    harness.push_collab_refs();

    let (a, b) = ids
        .iter()
        .find_map(|a| {
            ids.iter()
                .find(|b| *b != a && b.as_bytes()[0] == a.as_bytes()[0])
                .map(|b| (a.clone(), b.clone()))
        })
        .expect("17 ids over 16 first hex characters must share one");

    for id in [&a, &b] {
        let out = harness.ssh_exec(&format!(
            "collab-lease acquire 'lease-ambiguous.git' '{id}' --ttl 300"
        ));
        assert_eq!(code(&out), 0);
    }

    let out = harness.ssh_exec(&format!(
        "collab-lease release 'lease-ambiguous.git' '{}'",
        &a[..1]
    ));
    assert_eq!(code(&out), 1, "an ambiguous prefix must not pick one");
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("matches 2 leases"),
        "stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}