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