a73x

tests/nested_repo_http_test.rs

Ref:   Size: 11.4 KiB   History

//! Nested repositories over HTTP, end to end through the real server.
//!
//! Governance grants wild repos of the shape `agents/<name>`, and they are
//! created by pushing over SSH. The HTTP surface has to reach them under the
//! same name, or the feature is half-delivered: an agent can create and push
//! to its own repository and then nobody, itself included, can see it.
//!
//! Everything here drives a live `git-collab-server` and talks real HTTP,
//! because the failure being guarded against was invisible to unit tests —
//! discovery, name resolution and URL routing each looked fine on their own.

mod common;

use common::{git_cmd, ServerHarness};
use std::path::Path;
use std::process::{Command, Output};

/// The access rules used by the wild-repo test: one prefix per agent, with
/// the creating key owning what it creates.
///
/// The wild block publishes what it creates, because this file is about
/// *reachability* over HTTP — whether a repository that exists can be found
/// under its name — and a governed repository is closed to HTTP until a rule
/// opens it. `governance_test.rs` owns the exposure model itself.
const ACCESS_CONF: &str = "\
@admins = alex
@agents = claude-a

repo settings
    RW+                    =   @admins

repo governed
    RW+                    =   @admins
    R                      =   @all

repo agents/[a-z-]+
    C                      =   @agents
    RW+                    =   CREATOR
    R                      =   @anonymous
    option listed          =   yes
";

/// Create a bare repository at `repos_dir/<relative>`, making parents as
/// needed, and give it one commit plus a collab ref so there is something to
/// fetch.
fn seed_bare_repo(repos_dir: &Path, relative: &str) {
    let bare = repos_dir.join(relative);
    std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
    git_cmd(
        repos_dir,
        &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
    );
}

/// Populate a bare repo from a scratch working tree: one commit on `main`,
/// and one ref under `refs/collab/` so a fetch has something to show.
fn seed_content(work_root: &Path, repos_dir: &Path, relative: &str, marker: &str) {
    let bare = repos_dir.join(relative);
    let work = work_root.join(marker);
    std::fs::create_dir_all(&work).unwrap();
    git_cmd(&work, &["init", "-q", "-b", "main"]);
    git_cmd(&work, &["config", "user.email", "seed@example.com"]);
    git_cmd(&work, &["config", "user.name", "Seed"]);
    std::fs::write(work.join("README.md"), format!("# {marker}\n")).unwrap();
    git_cmd(&work, &["add", "-A"]);
    git_cmd(&work, &["commit", "-q", "-m", marker]);
    git_cmd(&work, &["push", "-q", bare.to_str().unwrap(), "main:main"]);
    // A collab-shaped ref, pushed by its full name. Its content does not
    // matter here; that it is advertised over HTTP does.
    git_cmd(
        &work,
        &[
            "push",
            "-q",
            bare.to_str().unwrap(),
            &format!("main:refs/collab/issues/{marker}"),
        ],
    );
}

/// `git ls-remote` against an http:// URL, unasserted.
fn ls_remote(dir: &Path, url: &str) -> Output {
    Command::new("git")
        .args(["ls-remote", url])
        .env("GIT_TERMINAL_PROMPT", "0")
        .current_dir(dir)
        .output()
        .expect("failed to run git ls-remote")
}

/// The reproduction from the issue, as a table: with `alpha.git` and
/// `agents/claude-a.git` both on disk, the nested one must be listed, must
/// answer on its own path, and must advertise its collab refs.
#[test]
fn a_nested_repository_is_listed_browsable_and_serves_its_collab_refs() {
    let harness = ServerHarness::new("alpha");
    let repos_dir = harness.repos_dir();
    let scratch = tempfile::TempDir::new().unwrap();

    seed_content(scratch.path(), &repos_dir, "alpha.git", "alpha");
    seed_bare_repo(&repos_dir, "agents/claude-a.git");
    seed_content(
        scratch.path(),
        &repos_dir,
        "agents/claude-a.git",
        "claude-a",
    );

    // Listed, under its full path, not its last component.
    let list = harness.get_ok("/");
    assert!(
        list.body.contains("href=\"/agents/claude-a\""),
        "the repo list must link the nested repo by its full path; got:\n{}",
        list.body
    );
    assert!(
        !list.body.contains("href=\"/claude-a\""),
        "the nested repo must not be listed under its last component alone; got:\n{}",
        list.body
    );

    // Reachable at that same path, and only that path.
    let nested = harness.get("/agents/claude-a");
    assert!(
        nested.status_line.contains("200"),
        "GET /agents/claude-a: expected 200, got {}\n{}",
        nested.status_line,
        nested.body
    );
    assert!(
        harness.get("/claude-a").status_line.contains("404"),
        "the last component alone must not resolve the nested repo"
    );
    // The unnested repo is untouched.
    harness.get_ok("/alpha");

    // Sub-routes hang off the multi-segment name.
    for suffix in ["issues", "patches", "commits", "tree"] {
        let path = format!("/agents/claude-a/{suffix}");
        let response = harness.get(&path);
        assert!(
            response.status_line.contains("200"),
            "GET {path}: expected 200, got {}\n{}",
            response.status_line,
            response.body
        );
    }

    // And the thing that matters more than the UI: a client can fetch the
    // repo's collab refs over HTTP. Advertised first (GET info/refs), then
    // actually transferred (POST git-upload-pack) — a clone over HTTP being
    // unable to reach `refs/collab/*` is the substance of the bug.
    let url = harness.http_url("/agents/claude-a.git");
    let output = ls_remote(scratch.path(), &url);
    assert!(
        output.status.success(),
        "git ls-remote {url} failed:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let refs = String::from_utf8_lossy(&output.stdout).to_string();
    assert!(
        refs.contains("refs/collab/issues/claude-a"),
        "collab refs must be advertised over HTTP for a nested repo; got:\n{refs}"
    );

    let clone = scratch.path().join("fetched");
    std::fs::create_dir_all(&clone).unwrap();
    git_cmd(&clone, &["init", "-q", "-b", "main"]);
    let fetch = Command::new("git")
        .args(["fetch", "-q", &url, "refs/collab/*:refs/collab/*"])
        .env("GIT_TERMINAL_PROMPT", "0")
        .current_dir(&clone)
        .output()
        .expect("failed to run git fetch");
    assert!(
        fetch.status.success(),
        "fetching collab refs over HTTP failed:\n{}",
        String::from_utf8_lossy(&fetch.stderr)
    );
    let fetched = Command::new("git")
        .args(["for-each-ref", "--format=%(refname)", "refs/collab/"])
        .current_dir(&clone)
        .output()
        .expect("failed to run git for-each-ref");
    assert!(
        String::from_utf8_lossy(&fetched.stdout).contains("refs/collab/issues/claude-a"),
        "the collab ref should have landed locally; got:\n{}",
        String::from_utf8_lossy(&fetched.stdout)
    );
}

/// `tools.git` and `tools` are two directories and so two repositories. The
/// old naming stripped `.git` from both, collapsed them onto one name, and
/// served whichever `discover` happened to reach first.
#[test]
fn tools_and_tools_dot_git_are_served_as_two_repositories() {
    let harness = ServerHarness::new("alpha");
    let repos_dir = harness.repos_dir();
    let scratch = tempfile::TempDir::new().unwrap();

    seed_bare_repo(&repos_dir, "tools.git");
    seed_bare_repo(&repos_dir, "tools");
    seed_content(scratch.path(), &repos_dir, "tools.git", "suffixed");
    seed_content(scratch.path(), &repos_dir, "tools", "plain");

    let list = harness.get_ok("/");
    assert!(
        list.body.contains("href=\"/tools\"") && list.body.contains("href=\"/tools.git\""),
        "both must be listed, under distinct names; got:\n{}",
        list.body
    );

    // Each answers on its own address, with its own content.
    assert!(harness.get_ok("/tools").body.contains("plain"));
    assert!(harness.get_ok("/tools.git").body.contains("suffixed"));

    // And each is clonable as itself.
    for (url_path, marker) in [("/tools", "plain"), ("/tools.git", "suffixed")] {
        let url = harness.http_url(url_path);
        let output = ls_remote(scratch.path(), &url);
        assert!(
            output.status.success(),
            "git ls-remote {url} failed:\n{}",
            String::from_utf8_lossy(&output.stderr)
        );
        let refs = String::from_utf8_lossy(&output.stdout).to_string();
        assert!(
            refs.contains(&format!("refs/collab/issues/{marker}")),
            "{url} should serve the {marker} repository; got:\n{refs}"
        );
    }
}

/// `repos_dir/.server/` holds the SSH host key. It is not a repository and
/// must never be presented as one, however discovery walks the tree.
#[test]
fn the_server_state_directory_is_never_a_repository() {
    let harness = ServerHarness::new("alpha");
    let host_key = harness.repos_dir().join(".server").join("host_key");
    assert!(
        host_key.exists(),
        "expected the server to have written {}",
        host_key.display()
    );

    let list = harness.get_ok("/");
    assert!(
        !list.body.contains(".server"),
        "the server state directory must not appear in the repo list; got:\n{}",
        list.body
    );
    assert!(harness.get("/.server").status_line.contains("404"));
    assert!(harness.get("/.server/host_key").status_line.contains("404"));
}

/// A repository whose name would escape `repos_dir` is not a repository.
#[test]
fn a_traversing_repo_path_is_not_served() {
    let harness = ServerHarness::new("alpha");
    for path in [
        "/../../etc/passwd",
        "/alpha/../../etc",
        "/..%2F..%2Fetc%2Fpasswd",
    ] {
        let response = harness.get(path);
        assert!(
            !response.status_line.contains("200"),
            "GET {path} must not succeed, got {}",
            response.status_line
        );
    }
}

/// The workflow wild repos exist for: an agent creates its repository by
/// pushing to it over SSH, and it is visible over HTTP on the very next
/// request. Nothing may cache discovery across that boundary.
#[test]
fn a_wild_repo_created_over_ssh_is_visible_over_http_immediately() {
    let harness = ServerHarness::new("governed");
    harness.bootstrap_settings(
        ACCESS_CONF,
        &[("alex.pub", "alex"), ("claude-a.pub", "claude-a")],
    );
    let agent = harness.named_key("claude-a");

    // Not there yet.
    assert!(
        harness.get("/agents/claude-a").status_line.contains("404"),
        "the wild repo must not exist before it is created"
    );

    harness.work_repo().commit_file("mine.txt", "1", "mine");
    let push = harness.ssh_push_from(
        harness.work_repo().dir.path(),
        &agent,
        "agents/claude-a",
        "main:main",
    );
    assert!(
        push.status.success(),
        "the agent's push should create the wild repo:\n{}",
        String::from_utf8_lossy(&push.stderr)
    );

    // Visible on the next request, with no restart and no wait.
    let response = harness.get("/agents/claude-a");
    assert!(
        response.status_line.contains("200"),
        "a wild repo must be browsable as soon as it exists, got {}\n{}",
        response.status_line,
        response.body
    );
    assert!(
        harness
            .get_ok("/")
            .body
            .contains("href=\"/agents/claude-a\""),
        "a wild repo must be listed as soon as it exists"
    );
}