//! ForgeFed Repository actor tests
//!
//! Tests for the Repository actor type implementation, including:
//! - Actor document with ForgeFed context and properties
//! - Follow/Accept flow for repository actors
//! - Push activity emission from git hooks
mod support;
use std::time::{Duration, Instant};
use support::ap::RemoteActor;
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
/// Register a user through the web form.
fn register(client: &mut Client, name: &str) {
let email = format!("{name}@example.org");
let response = client.post_form(
"/register",
&[("name", name), ("email", &email), ("password", PASSWORD)],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Create a repository through the web form.
fn create_repository(client: &mut Client, owner: &str, name: &str, visibility: &str) {
let response = client.post_form(
&format!("/~{}/repositories", owner),
&[
("slug", name),
("description", "A test repository"),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating repository should redirect\n{}",
response.body
);
}
/// Test 1: Repository actor document has correct ForgeFed structure
#[tokio::test]
async fn repository_actor_document_is_correct() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "proj", "public");
// Request the repository actor document
let response =
alice_client.get_with("/~alice/proj", &[("Accept", "application/activity+json")]);
assert_eq!(
response.status, 200,
"Repository actor should be accessible"
);
let actor: serde_json::Value =
serde_json::from_str(&response.body).expect("response should be valid JSON");
// Verify @context includes ForgeFed namespace
let context = actor["@context"]
.as_array()
.expect("@context should be an array");
assert!(
context.iter().any(|c| c == "https://forgefed.org/ns"),
"@context should include https://forgefed.org/ns"
);
// Verify type is Repository
assert_eq!(actor["type"], "Repository", "type should be Repository");
// Verify id
assert_eq!(
actor["id"],
format!("http://127.0.0.1:{port}/~alice/proj"),
"id should be the repository URL"
);
// Verify attributedTo points to owner
assert_eq!(
actor["attributedTo"],
format!("http://127.0.0.1:{port}/~alice"),
"attributedTo should point to owner"
);
// Verify inbox
assert_eq!(
actor["inbox"],
format!("http://127.0.0.1:{port}/~alice/proj/inbox"),
"inbox should be present"
);
// Verify outbox
assert_eq!(
actor["outbox"],
format!("http://127.0.0.1:{port}/~alice/proj/outbox"),
"outbox should be present"
);
// Verify followers
assert_eq!(
actor["followers"],
format!("http://127.0.0.1:{port}/~alice/proj/followers"),
"followers should be present"
);
// Verify publicKey
assert!(
actor["publicKey"]["id"].as_str().is_some(),
"publicKey.id should be present"
);
assert_eq!(
actor["publicKey"]["owner"],
format!("http://127.0.0.1:{port}/~alice/proj"),
"publicKey.owner should match actor id"
);
// Verify name and summary
assert_eq!(actor["name"], "proj", "name should be the repository slug");
assert_eq!(
actor["summary"], "A test repository",
"summary should be the description"
);
// Verify cloneUri
assert_eq!(
actor["cloneUri"],
format!("http://127.0.0.1:{port}/alice/proj.git"),
"cloneUri should be the HTTP clone URL"
);
// Verify pushUri is an array
assert!(actor["pushUri"].is_array(), "pushUri should be an array");
let push_uris = actor["pushUri"].as_array().unwrap();
assert!(
push_uris.len() >= 2,
"pushUri should have at least HTTP and SSH URLs"
);
// Verify published timestamp
assert!(
actor["published"].as_str().is_some(),
"published should be present"
);
// Verify ticketsTrackedBy (self-referential)
assert_eq!(
actor["ticketsTrackedBy"],
format!("http://127.0.0.1:{port}/~alice/proj"),
"ticketsTrackedBy should be self-referential"
);
// Verify sendPatchesTo (self-referential)
assert_eq!(
actor["sendPatchesTo"],
format!("http://127.0.0.1:{port}/~alice/proj"),
"sendPatchesTo should be self-referential"
);
Ok(())
}
/// Test 2: Private repository actor returns 404
#[tokio::test]
async fn private_repository_actor_not_found() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "secret", "private");
// Request the repository actor document
let response =
alice_client.get_with("/~alice/secret", &[("Accept", "application/activity+json")]);
assert_eq!(
response.status, 404,
"Private repository actor should not be accessible"
);
Ok(())
}
/// Test 3: Repository actor keypair is created lazily
#[tokio::test]
async fn lazy_keypair_creation_for_repository() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "lazy", "public");
// First request should create the keypair
let response1 =
alice_client.get_with("/~alice/lazy", &[("Accept", "application/activity+json")]);
assert_eq!(response1.status, 200);
let actor1: serde_json::Value =
serde_json::from_str(&response1.body).expect("response should be valid JSON");
// Second request should return the same keypair
let response2 =
alice_client.get_with("/~alice/lazy", &[("Accept", "application/activity+json")]);
assert_eq!(response2.status, 200);
let actor2: serde_json::Value =
serde_json::from_str(&response2.body).expect("response should be valid JSON");
// The publicKeyPem should be identical
assert_eq!(
actor1["publicKey"]["publicKeyPem"], actor2["publicKey"]["publicKeyPem"],
"Keypair should be stable across requests"
);
Ok(())
}
/// Test 4: Remote actor can follow a repository and receive Accept
#[tokio::test]
async fn repository_actor_follow_flow() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "followed", "public");
// Start a remote actor recorder
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Bob follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/followed");
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": repo_url
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/followed/inbox",
&body,
);
let response = alice_client.post_json_with_headers(
"/~alice/followed/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(
response.status, 202,
"Repository inbox should accept Follow"
);
// Poll for Accept(Follow) delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_accept = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
if let Some(obj) = activity.get("object") {
if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
found_accept = true;
break;
}
}
}
}
}
if found_accept {
break;
}
}
assert!(
found_accept,
"Accept(Follow) should be delivered to the follower"
);
Ok(())
}
/// Test 5: Repository followers collection is populated
#[tokio::test]
async fn repository_followers_collection_populated() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "tracked", "public");
// Start a remote actor recorder
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Bob follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/tracked");
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/2", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": repo_url
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/tracked/inbox",
&body,
);
alice_client.post_json_with_headers(
"/~alice/tracked/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Wait for processing
std::thread::sleep(Duration::from_millis(200));
// Request the followers collection
let response = alice_client.get_with(
"/~alice/tracked/followers",
&[("Accept", "application/activity+json")],
);
assert_eq!(
response.status, 200,
"Followers collection should be accessible"
);
let followers: serde_json::Value =
serde_json::from_str(&response.body).expect("response should be valid JSON");
assert_eq!(
followers["type"], "OrderedCollection",
"type should be OrderedCollection"
);
assert_eq!(followers["totalItems"], 1, "totalItems should be 1");
let items = followers["orderedItems"]
.as_array()
.expect("orderedItems should be an array");
assert_eq!(items.len(), 1, "There should be one follower");
assert_eq!(
items[0], recorder.actor_url,
"The follower should be the remote actor"
);
Ok(())
}
/// Test 6: Commit document is served
#[tokio::test]
async fn commit_document_is_served() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "commits", "public");
// For now, we'll just test that the endpoint exists and returns 404 for
// unknown commits In the future, we'll push a real commit and verify
// it's served
let response = alice_client.get_with(
"/~alice/commits/commits/abc123def456",
&[("Accept", "application/activity+json")],
);
assert_eq!(response.status, 404, "Unknown commit should return 404");
Ok(())
}
/// Test 7: Repository outbox lists Push activities (initially empty)
#[tokio::test]
async fn repository_outbox_lists_pushes() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "pushed", "public");
// Request the outbox
let response = alice_client.get_with(
"/~alice/pushed/outbox",
&[("Accept", "application/activity+json")],
);
assert_eq!(response.status, 200, "Outbox should be accessible");
let outbox: serde_json::Value =
serde_json::from_str(&response.body).expect("response should be valid JSON");
assert_eq!(
outbox["type"], "OrderedCollection",
"type should be OrderedCollection"
);
// Initially empty (no pushes yet)
assert_eq!(outbox["totalItems"], 0, "totalItems should be 0 initially");
Ok(())
}
/// Test 8: Push emits Push activity (end-to-end)
/// This test verifies that pushing a commit over SSH emits a Push activity
/// that appears in the outbox and is delivered to followers.
#[tokio::test]
async fn push_emits_push_activity() -> miette::Result<()> {
use std::fs;
use std::process::Command;
// Check for required tools
if !Command::new("git").arg("--version").output().is_ok()
|| !Command::new("ssh").arg("-V").output().is_ok()
|| !Command::new("ssh-keygen").arg("--version").output().is_ok()
{
eprintln!("skipping: this test needs `git`, `ssh` and `ssh-keygen` on PATH");
return Ok(());
}
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let ssh_port = server.ssh_port();
server.wait_for_connection(ssh_port);
// Create scratch directory
let scratch = std::env::temp_dir().join(format!("frogue-push-test-{}", std::process::id()));
fs::create_dir_all(&scratch).expect("scratch dir should be creatable");
// Generate SSH key
let key_path = scratch.join("id_ed25519");
let output = Command::new("ssh-keygen")
.args([
"-t",
"ed25519",
"-f",
key_path.to_str().unwrap(),
"-N",
"",
"-C",
"test",
])
.output()
.expect("ssh-keygen should run");
assert!(
output.status.success(),
"ssh-keygen failed: {}",
String::from_utf8_lossy(&output.stderr)
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
.expect("key permissions should be settable");
}
// Register user and add SSH key
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
let pub_key =
fs::read_to_string(key_path.with_extension("pub")).expect("pub key should be readable");
let response = alice_client.post_form("/~alice/ssh-keys", &[("key", pub_key.trim())]);
assert_eq!(response.status, 303, "adding SSH key should redirect");
create_repository(&mut alice_client, "alice", "push-test", "public");
// Start a remote actor that will follow the repository
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/push-test");
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": repo_url
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/push-test/inbox",
&body,
);
alice_client.post_json_with_headers(
"/~alice/push-test/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Wait for Accept
let deadline = Instant::now() + Duration::from_secs(10);
'outer: while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
break 'outer;
}
}
}
}
// Clone the repository over SSH
let ssh_cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o PreferredAuthentications=publickey -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p {}",
key_path.display(),
ssh_port
);
let clone_dir = scratch.join("clone");
let output = Command::new("git")
.env("GIT_SSH_COMMAND", &ssh_cmd)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args([
"clone",
&format!("ssh://git@127.0.0.1:{}/alice/push-test.git", ssh_port),
clone_dir.to_str().unwrap(),
])
.output()
.expect("git clone should run");
assert!(
output.status.success(),
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Make a commit and push
fs::write(clone_dir.join("README.md"), "# Test\n").expect("file should be writable");
let git_cmd = |args: &[&str]| {
Command::new("git")
.current_dir(&clone_dir)
.env("GIT_SSH_COMMAND", &ssh_cmd)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
.args(args)
.output()
.expect("git should run")
};
assert_ok(&git_cmd(&["add", "README.md"]), "git add");
assert_ok(&git_cmd(&["commit", "-m", "Initial commit"]), "git commit");
assert_ok(&git_cmd(&["push", "origin", "main"]), "git push");
// Wait for Push activity to be emitted
std::thread::sleep(Duration::from_millis(500));
// Verify outbox has the Push activity
let response = alice_client.get_with(
"/~alice/push-test/outbox?page=1",
&[("Accept", "application/activity+json")],
);
assert_eq!(response.status, 200, "outbox should be accessible");
let outbox: serde_json::Value =
serde_json::from_str(&response.body).expect("outbox should be JSON");
assert!(
outbox["totalItems"].as_u64().unwrap_or(0) >= 1,
"outbox should have at least 1 Push activity"
);
let items = outbox["orderedItems"]
.as_array()
.expect("orderedItems should be an array");
let push = &items[0];
assert_eq!(push["type"], "Push", "first item should be a Push");
assert_eq!(
push["actor"], repo_url,
"Push actor should be repository URL"
);
assert_eq!(
push["attributedTo"],
format!("http://127.0.0.1:{port}/~alice"),
"attributedTo should be pusher"
);
assert_eq!(push["target"], repo_url, "target should be repository URL");
// Verify the remote actor received the Push delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_push = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Push") {
found_push = true;
break;
}
}
}
if found_push {
break;
}
}
assert!(
found_push,
"remote actor should have received Push delivery"
);
// Cleanup
let _ = fs::remove_dir_all(&scratch);
Ok(())
}
fn assert_ok(output: &std::process::Output, what: &str) {
assert!(
output.status.success(),
"{} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
what,
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
/// Test 9: HTML content negotiation regression test
/// Verifies that requesting a repository page with Accept: text/html returns
/// HTML, not JSON.
#[tokio::test]
async fn repo_page_still_html_for_browsers() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "html-test", "public");
// Request with text/html Accept header
let response = alice_client.get("/~alice/html-test");
assert_eq!(
response.status, 200,
"Repository page should be accessible as HTML"
);
// Verify it's HTML (not JSON)
let content_type = response.content_type.as_deref().unwrap_or("");
assert!(
content_type.contains("text/html"),
"Content-Type should be text/html, got: {}",
content_type
);
// Verify it contains HTML content
assert!(
response.body.contains("<!DOCTYPE html>") || response.body.contains("<html"),
"Response should be HTML"
);
Ok(())
}
/// Test 10: Federation-disabled regression test
/// Verifies that when federation is disabled (no FROGUE_PUBLIC_URL), all
/// repository ActivityPub endpoints return 404.
#[tokio::test]
async fn federation_disabled_repo_endpoints_404() -> miette::Result<()> {
let port = free_port();
// Spawn server WITHOUT federation enabled
let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut alice_client = Client::new(port);
register(&mut alice_client, "alice");
create_repository(&mut alice_client, "alice", "no-fed", "public");
// All ActivityPub endpoints should return 404
// Note: inbox is POST-only, so we test it separately below
let endpoints = vec![
"/~alice/no-fed",
"/~alice/no-fed/outbox",
"/~alice/no-fed/followers",
"/~alice/no-fed/commits/abc123",
];
for endpoint in endpoints {
let response = alice_client.get_with(endpoint, &[("Accept", "application/activity+json")]);
assert_eq!(
response.status, 404,
"{} should return 404 when federation is disabled",
endpoint
);
}
// Test POST to inbox separately
let response = alice_client.post_json(
"/~alice/no-fed/inbox",
"{}",
&[("Content-Type", "application/activity+json")],
);
assert_eq!(
response.status, 404,
"inbox should return 404 when federation is disabled"
);
Ok(())
}