//! Tests for ForgeFed ticket and patch federation document serving.
mod support;
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use support::{Server, free_port};
/// Register a user through the web form.
fn register(client: &mut support::Client, name: &str) {
let email = format!("{name}@example.org");
let response = client.post_form(
"/register",
&[
("name", name),
("email", &email),
("password", "correct horse battery staple"),
],
);
assert_eq!(
response.status, 303,
"registering should redirect: {}",
response.body
);
}
/// Create a repository through the web form.
fn create_repository(client: &mut support::Client, owner: &str, slug: &str, visibility: &str) {
let response = client.post_form(
&format!("/~{owner}/repositories"),
&[
("slug", slug),
("description", "A test repository"),
("visibility", visibility),
],
);
assert_eq!(response.status, 303, "creating repository should redirect");
}
/// Create a ticket through the web form.
fn create_ticket(
client: &mut support::Client,
owner: &str,
repo: &str,
summary: &str,
description: &str,
) {
let response = client.post_form(
&format!("/~{owner}/{repo}/tickets"),
&[("summary", summary), ("description", description)],
);
assert_eq!(response.status, 303, "creating ticket should redirect");
}
/// Create a ticket via remote Offer activity.
/// Returns the ticket URL.
fn create_ticket_via_offer(
_client: &mut support::Client,
port: u16,
owner: &str,
repo: &str,
summary: &str,
description: &str,
remote: &support::ap::RemoteActor,
) -> String {
let repo_url = format!("http://127.0.0.1:{port}/~{owner}/{repo}");
let inbox_url = format!("{repo_url}/inbox");
let offer = serde_json::json!({
"@context": ["https://www.w3.org/ns/activitystreams", "https://forgefed.org/ns"],
"type": "Offer",
"id": format!("{}/offers/{}", remote.actor_url, uuid::Uuid::new_v4()),
"actor": remote.actor_url,
"to": [repo_url],
"object": {
"type": "Ticket",
"id": format!("{}/tickets/{}", remote.actor_url, uuid::Uuid::new_v4()),
"attributedTo": remote.actor_url,
"summary": summary,
"content": description,
},
});
remote.send_activity(&inbox_url, offer);
std::thread::sleep(std::time::Duration::from_millis(500));
// Return the ticket URL (assuming it's ticket #1)
format!("{repo_url}/tickets/1")
}
/// Fetch a ticket document with ActivityPub content negotiation.
fn fetch_ticket_ap(
client: &mut support::Client,
owner: &str,
repo: &str,
number: i64,
) -> serde_json::Value {
let response = client.get_with_accept(&format!("/~{owner}/{repo}/tickets/{number}"));
assert_eq!(response.status, 200, "ticket AP document should be served");
serde_json::from_str(&response.body).expect("valid JSON")
}
/// Fetch a ticket replies collection.
fn fetch_ticket_replies(
client: &mut support::Client,
owner: &str,
repo: &str,
number: i64,
) -> serde_json::Value {
let response = client.get_with_accept(&format!("/~{owner}/{repo}/tickets/{number}/replies"));
assert_eq!(response.status, 200, "ticket replies should be served");
serde_json::from_str(&response.body).expect("valid JSON")
}
/// Test that ticket documents are served with correct ForgeFed structure.
#[test]
fn ticket_document_has_correct_fields() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"This is a test ticket",
);
let ticket = fetch_ticket_ap(&mut client, "alice", "testrepo", 1);
// Check @context includes ForgeFed
let context = ticket["@context"].as_array().expect("@context is array");
assert!(
context
.iter()
.any(|c| c.as_str() == Some("https://forgefed.org/ns")),
"@context should include ForgeFed namespace"
);
// Check type is Ticket
assert_eq!(ticket["type"], "Ticket", "type should be Ticket");
// Check id is the ticket URL
assert!(
ticket["id"]
.as_str()
.unwrap()
.ends_with("/~alice/testrepo/tickets/1"),
"id should be the ticket URL"
);
// Check required fields exist
assert!(
ticket["attributedTo"].is_string(),
"attributedTo should be present"
);
assert!(ticket["summary"].is_string(), "summary should be present");
assert!(ticket["content"].is_string(), "content should be present");
assert!(
ticket["mediaType"].is_string(),
"mediaType should be present"
);
assert!(ticket["source"].is_object(), "source should be present");
assert!(
ticket["context"].is_string(),
"context (tracker) should be present"
);
assert!(ticket["replies"].is_string(), "replies should be present");
// Check source structure
let source = &ticket["source"];
assert_eq!(
source["mediaType"], "text/markdown",
"source mediaType should be markdown"
);
assert!(
source["content"].is_string(),
"source content should be present"
);
// Check isResolved is false for open tickets
assert_eq!(
ticket["isResolved"], false,
"isResolved should be false for open tickets"
);
Ok(())
}
/// Test that closed tickets have isResolved=true.
#[test]
fn closed_ticket_is_resolved() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
// Close the ticket
let response = client.post_form(
"/~alice/testrepo/tickets/1",
&[
("summary", "Test ticket"),
("description", "Description"),
("state", "closed"),
],
);
assert_eq!(response.status, 303);
let ticket = fetch_ticket_ap(&mut client, "alice", "testrepo", 1);
assert_eq!(
ticket["isResolved"], true,
"isResolved should be true for closed tickets"
);
// resolvedBy is set to the repo owner when closed (we don't track who specifically closed it)
assert!(
ticket["resolvedBy"].is_string(),
"resolvedBy should be present for closed tickets"
);
Ok(())
}
/// Test that ticket replies collection is served.
#[test]
fn ticket_replies_collection() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
// Add a comment
let response = client.post_form(
"/~alice/testrepo/tickets/1/comments",
&[("content", "This is a comment")],
);
assert_eq!(response.status, 303);
let replies = fetch_ticket_replies(&mut client, "alice", "testrepo", 1);
// Check it's an OrderedCollection
assert_eq!(
replies["type"], "OrderedCollection",
"replies should be OrderedCollection"
);
assert_eq!(replies["totalItems"], 1, "should have 1 reply");
// Check orderedItems array contains URLs (valid ActivityPub pattern)
let items = replies["orderedItems"]
.as_array()
.expect("orderedItems is array");
assert_eq!(items.len(), 1, "should have 1 item");
// The item is a URL string to the comment Note
let comment_url = items[0].as_str().expect("item should be a URL string");
assert!(
comment_url.contains("/tickets/1/comments/"),
"comment URL should point to the comment endpoint"
);
Ok(())
}
/// Test that patch documents are served with correct structure.
#[test]
fn patch_document_has_correct_fields() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Create a patch proposal via API (simulating git push)
// For now, we'll just test that a non-existent patch returns 404
let response = client.get_with_accept("/~alice/testrepo/patches/1");
assert_eq!(response.status, 404, "non-existent patch should return 404");
// TODO: Once we have a way to create patches via the API, test the full document structure
// For now, this test just verifies the route exists and handles 404 correctly
Ok(())
}
/// Test that browsers get HTML, not JSON-LD.
#[test]
fn ticket_html_vs_json_ld_content_negotiation() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
// Fetch with browser Accept header
let html_response = client.get("/~alice/testrepo/tickets/1");
assert_eq!(html_response.status, 200);
assert!(
html_response
.header("content-type")
.is_some_and(|v| v.contains("text/html")),
"browser should get HTML"
);
// Fetch with ActivityPub Accept header
let ap_response = client.get_with_accept("/~alice/testrepo/tickets/1");
assert_eq!(ap_response.status, 200);
assert!(
ap_response
.header("content-type")
.is_some_and(|v| v.contains("application/activity+json")),
"AP client should get JSON-LD"
);
Ok(())
}
// ============================================================================
// Phase 3: Outbound federation tests
// ============================================================================
/// Test that creating a ticket emits an Accept activity to repository followers.
/// This verifies the Offer+Accept flow for local ticket creation.
#[test]
fn ticket_creation_emits_accept_to_followers() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will follow the repository
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Remote actor follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
remote_actor.follow(repo_url.clone());
// Wait for the follow to be accepted
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
'follow_wait: while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
let deliveries = remote_actor.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 'follow_wait;
}
}
}
}
// Drain deliveries to clear the Follow Accept before creating the ticket
remote_actor.drain_deliveries();
// Create a ticket
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"This is a test ticket",
);
// Wait for delivery
let delivery = remote_actor
.wait_for_delivery(5)
.unwrap_or_else(|| panic!("should receive Accept activity\nServer log:"));
// Parse the delivered activity
let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");
// Verify it's an Accept activity
assert_eq!(
activity["type"], "Accept",
"delivered activity should be Accept"
);
assert_eq!(
activity["actor"], repo_url,
"Accept actor should be repository"
);
// Verify it has a result field pointing to the ticket
assert!(
activity["result"].is_string(),
"Accept should have result field"
);
assert!(
activity["result"].as_str().unwrap().contains("/tickets/1"),
"result should point to the ticket"
);
// Verify the object is the Offer (should be a URL string)
assert!(
activity["object"].is_string(),
"Accept object should be Offer URL"
);
Ok(())
}
/// Test that closing a ticket emits a Resolve activity to followers.
#[test]
fn ticket_close_emits_resolve_to_followers() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will follow the repository
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Remote actor follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
remote_actor.follow(repo_url.clone());
// Wait for the follow to be accepted
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
'follow_wait: while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
let deliveries = remote_actor.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 'follow_wait;
}
}
}
}
// Drain deliveries to clear the Follow Accept before creating the ticket
remote_actor.drain_deliveries();
// Create a ticket
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
// Clear deliveries from ticket creation
remote_actor.drain_deliveries();
// Close the ticket
let response = client.post_form(
"/~alice/testrepo/tickets/1",
&[
("summary", "Test ticket"),
("description", "Description"),
("state", "closed"),
],
);
assert_eq!(response.status, 303);
// Wait for Resolve delivery (loop until we find it or timeout)
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut resolve_delivery = None;
while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.drain_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("Resolve") {
resolve_delivery = Some(delivery);
break;
}
}
}
if resolve_delivery.is_some() {
break;
}
}
let delivery = resolve_delivery.expect("should receive Resolve activity");
// Parse the delivered activity
let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");
// Verify it's a Resolve activity
assert_eq!(
activity["type"], "Resolve",
"delivered activity should be Resolve"
);
assert_eq!(
activity["actor"], repo_url,
"Resolve actor should be repository"
);
// Verify the object is the ticket URL
assert!(
activity["object"].is_string(),
"Resolve object should be ticket URL"
);
assert!(
activity["object"].as_str().unwrap().contains("/tickets/1"),
"object should be the ticket URL"
);
Ok(())
}
// ============================================================================
// Phase 2 + 3: Comment federation tests
// ============================================================================
/// Helper to create a comment via the web form.
fn create_comment(
client: &mut support::Client,
owner: &str,
repo: &str,
ticket_number: i64,
content: &str,
) {
let response = client.post_form(
&format!("/~{owner}/{repo}/tickets/{ticket_number}/comments"),
&[("content", content)],
);
assert_eq!(response.status, 303, "creating comment should redirect");
}
/// Helper to fetch the first comment URL from the replies collection.
fn fetch_first_comment_url(
client: &mut support::Client,
owner: &str,
repo: &str,
ticket_number: i64,
) -> String {
let replies = fetch_ticket_replies(client, owner, repo, ticket_number);
let items = replies["orderedItems"]
.as_array()
.expect("orderedItems is array");
assert!(!items.is_empty(), "should have at least one comment");
items[0]
.as_str()
.expect("comment URL should be a string")
.to_string()
}
/// Helper to fetch a comment Note document with ActivityPub content negotiation.
fn fetch_comment_ap(
client: &mut support::Client,
port: u16,
comment_url: &str,
) -> serde_json::Value {
// Convert full URL to path
let path = comment_url
.strip_prefix(&format!("http://127.0.0.1:{}", port))
.expect("comment URL should be on same host");
let response = client.get_with_accept(path);
assert_eq!(
response.status, 200,
"comment AP document should be served: {}",
response.body
);
serde_json::from_str(&response.body).expect("valid JSON")
}
/// Test that comment Note documents are served with correct ForgeFed structure.
#[test]
fn comment_note_document_has_correct_fields() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
create_comment(
&mut client,
"alice",
"testrepo",
1,
"This is a test comment",
);
let comment_url = fetch_first_comment_url(&mut client, "alice", "testrepo", 1);
let comment = fetch_comment_ap(&mut client, port, &comment_url);
// Check @context includes ForgeFed
let context = comment["@context"].as_array().expect("@context is array");
assert!(
context
.iter()
.any(|c| c.as_str() == Some("https://forgefed.org/ns")),
"@context should include ForgeFed namespace"
);
// Check type is Note
assert_eq!(comment["type"], "Note", "type should be Note");
// Check id is the comment URL
assert_eq!(
comment["id"].as_str().unwrap(),
comment_url,
"id should be the comment URL"
);
// Check required fields exist
assert!(
comment["attributedTo"].is_string(),
"attributedTo should be present"
);
assert!(comment["content"].is_string(), "content should be present");
assert!(
comment["mediaType"].is_string(),
"mediaType should be present"
);
assert!(
comment["inReplyTo"].is_string(),
"inReplyTo should be present"
);
assert!(comment["source"].is_object(), "source should be present");
// Check inReplyTo points to the ticket
let in_reply_to = comment["inReplyTo"].as_str().unwrap();
assert!(
in_reply_to.contains("/tickets/1"),
"inReplyTo should point to the ticket"
);
// Check source structure
let source = &comment["source"];
assert_eq!(
source["mediaType"], "text/markdown",
"source mediaType should be markdown"
);
assert!(
source["content"].is_string(),
"source content should be present"
);
assert!(
source["content"]
.as_str()
.unwrap()
.contains("This is a test comment"),
"source content should contain the original markdown"
);
// Check to and cc fields
assert!(comment["to"].is_array(), "to should be an array");
assert!(comment["cc"].is_array(), "cc should be an array");
Ok(())
}
/// Test that creating a comment emits a Create(Note) activity to followers.
#[test]
fn comment_creation_emits_create_to_followers() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will follow the repository
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Remote actor follows the repository
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
remote_actor.follow(repo_url.clone());
// Wait for the follow to be accepted
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
'follow_wait: while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
let deliveries = remote_actor.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 'follow_wait;
}
}
}
}
// Drain deliveries to clear the Follow Accept
remote_actor.drain_deliveries();
// Create a ticket
create_ticket(
&mut client,
"alice",
"testrepo",
"Test ticket",
"Description",
);
// Clear deliveries from ticket creation
remote_actor.drain_deliveries();
// Create a comment
create_comment(
&mut client,
"alice",
"testrepo",
1,
"This is a test comment",
);
// Wait for Create delivery (loop until we find it or timeout)
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut create_delivery = None;
while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.drain_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("Create") {
create_delivery = Some(delivery);
break;
}
}
}
if create_delivery.is_some() {
break;
}
}
let delivery = create_delivery.expect("should receive Create activity");
// Parse the delivered activity
let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");
// Verify it's a Create activity
assert_eq!(
activity["type"], "Create",
"delivered activity should be Create"
);
// Verify the actor is the user (not the repository)
assert!(
activity["actor"].as_str().unwrap().contains("/~alice"),
"Create actor should be the user"
);
// Verify the object is a Note
assert!(
activity["object"].is_object(),
"Create object should be a Note object"
);
let note = &activity["object"];
assert_eq!(note["type"], "Note", "object type should be Note");
assert!(note["content"].is_string(), "Note should have content");
assert!(note["inReplyTo"].is_string(), "Note should have inReplyTo");
// Verify to and cc fields
assert!(activity["to"].is_array(), "to should be an array");
assert!(activity["cc"].is_array(), "cc should be an array");
Ok(())
}
// Phase 4: Inbound federation tests
/// Test that a remote Offer activity creates a local ticket and sends Accept.
#[test]
fn remote_offer_creates_ticket_and_accepts() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Create a remote Offer activity with an embedded Ticket
let ticket_id = format!("http://127.0.0.1:{remote_port}/tickets/1");
let offer_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Offer",
"id": format!("http://127.0.0.1:{remote_port}/offers/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"type": "Ticket",
"id": ticket_id,
"attributedTo": remote_actor.actor_url,
"summary": "Remote ticket from offer",
"content": "This is a ticket created via Offer",
"attachment": []
},
"target": repo_url
});
// Send the Offer to the repository inbox
remote_actor.send_activity(&inbox_url, offer_activity);
// Wait for Accept delivery
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut accept_delivery = None;
while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.drain_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") {
accept_delivery = Some(delivery);
break;
}
}
}
if accept_delivery.is_some() {
break;
}
}
let delivery = accept_delivery.expect("should receive Accept activity");
let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");
// Verify the Accept is for the Offer
assert_eq!(activity["type"], "Accept", "should be Accept");
assert_eq!(
activity["actor"].as_str().unwrap(),
repo_url,
"Accept actor should be repository"
);
// Verify the ticket was created by fetching it directly
let ticket_path = format!("/~alice/testrepo/tickets/1");
let response = client.get_with_accept(&ticket_path);
let ticket_response: serde_json::Value = serde_json::from_str(&response.body)
.map_err(|e| miette::miette!("Failed to parse JSON: {}", e))?;
assert_eq!(
ticket_response["type"], "Ticket",
"should have created a ticket"
);
assert_eq!(
ticket_response["summary"].as_str().unwrap(),
"Remote ticket from offer",
"ticket should have correct summary"
);
Ok(())
}
/// Test that a remote Resolve activity closes a local ticket.
#[test]
fn remote_resolve_closes_ticket() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Create a ticket via remote Offer so the remote actor is authorized
let ticket_url = create_ticket_via_offer(
&mut client,
port,
"alice",
"testrepo",
"Test ticket",
"Description",
&remote_actor,
);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Create a remote Resolve activity for ticket #1
let resolve_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Resolve",
"id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": ticket_url
});
// Send the Resolve to the repository inbox
remote_actor.send_activity(&inbox_url, resolve_activity);
// Wait for processing - give it more time for the delivery and processing
std::thread::sleep(std::time::Duration::from_millis(2000));
// Verify the ticket is closed
let ticket_json = client.get_ap(&ticket_url)?;
let is_resolved = ticket_json["isResolved"]
.as_bool()
.ok_or_else(|| miette::miette!("missing isResolved"))?;
assert!(is_resolved, "ticket should be resolved");
Ok(())
}
/// Test that a remote Undo(Resolve) activity reopens a local ticket.
#[test]
fn remote_undo_resolve_reopens_ticket() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Create a ticket via remote Offer so the remote actor is authorized
let ticket_url = create_ticket_via_offer(
&mut client,
port,
"alice",
"testrepo",
"Test ticket",
"Description",
&remote_actor,
);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Close the ticket first via Resolve
let resolve_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Resolve",
"id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": ticket_url
});
remote_actor.send_activity(&inbox_url, resolve_activity);
std::thread::sleep(std::time::Duration::from_millis(500));
// Now send Undo(Resolve) to reopen
let undo_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Undo",
"id": format!("http://127.0.0.1:{remote_port}/undos/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": {
"type": "Resolve",
"id": format!("http://127.0.0.1:{remote_port}/resolves/1"),
"actor": remote_actor.actor_url,
"object": ticket_url
}
});
// Send the Undo to the repository inbox
remote_actor.send_activity(&inbox_url, undo_activity);
// Wait a bit for processing
std::thread::sleep(std::time::Duration::from_millis(500));
// Verify the ticket is reopened
let ticket_json = client.get_ap(&ticket_url)?;
let is_resolved = ticket_json["isResolved"]
.as_bool()
.ok_or_else(|| miette::miette!("missing isResolved"))?;
assert!(!is_resolved, "ticket should be reopened");
Ok(())
}
/// Test that a remote Edit activity updates a local ticket.
#[test]
fn remote_edit_updates_ticket() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Create a ticket via remote Offer so the remote actor is authorized
let ticket_url = create_ticket_via_offer(
&mut client,
port,
"alice",
"testrepo",
"Original summary",
"Original description",
&remote_actor,
);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Create a remote Edit activity for ticket #1
let edit_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Edit",
"id": format!("http://127.0.0.1:{remote_port}/edits/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": {
"type": "Ticket",
"id": ticket_url,
"summary": "Updated summary",
"content": "Updated description"
}
});
// Send the Edit to the repository inbox
remote_actor.send_activity(&inbox_url, edit_activity);
// Wait for processing - give it more time for the delivery and processing
std::thread::sleep(std::time::Duration::from_millis(2000));
// Verify the ticket was updated
let ticket_json = client.get_ap(&ticket_url)?;
let summary = ticket_json["summary"]
.as_str()
.ok_or_else(|| miette::miette!("missing summary"))?;
assert_eq!(summary, "Updated summary", "summary should be updated");
Ok(())
}
/// Test that a remote Create(Note) activity creates a comment on a ticket.
#[test]
fn remote_create_creates_comment() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Create a ticket via remote Offer
let ticket_url = create_ticket_via_offer(
&mut client,
port,
"alice",
"testrepo",
"Test ticket",
"Description",
&remote_actor,
);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Create a remote Create(Note) activity for a comment on ticket #1
let note_id = format!("http://127.0.0.1:{remote_port}/notes/1");
let create_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": format!("http://127.0.0.1:{remote_port}/creates/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": {
"type": "Note",
"id": note_id,
"attributedTo": remote_actor.actor_url,
"inReplyTo": ticket_url,
"content": "<p>This is a remote comment</p>",
"source": {
"mediaType": "text/markdown",
"content": "This is a remote comment"
}
}
});
// Send the Create to the repository inbox
remote_actor.send_activity(&inbox_url, create_activity);
// Wait for processing - give it more time for the delivery and processing
std::thread::sleep(std::time::Duration::from_millis(2000));
// Verify the comment was created
let replies_url = format!("{}/replies", ticket_url);
let replies_json = client.get_ap(&replies_url)?;
let total_items = replies_json["totalItems"]
.as_u64()
.ok_or_else(|| miette::miette!("missing totalItems"))?;
assert_eq!(total_items, 1, "should have 1 comment");
Ok(())
}
/// Test that a remote Offer with inline Patch is rejected politely.
#[test]
fn remote_offer_with_patch_is_rejected() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will send activities
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
// Create a remote Offer activity with an embedded Patch (MergeRequest)
let patch_id = format!("http://127.0.0.1:{remote_port}/patches/1");
let offer_activity = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"type": "Offer",
"id": format!("http://127.0.0.1:{remote_port}/offers/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"type": "Patch",
"id": patch_id,
"attributedTo": remote_actor.actor_url,
"summary": "Remote patch from offer",
"content": "This is a patch",
"attachment": [{
"type": "PatchCollection",
"origin": "http://remote.example/repo",
"target": repo_url,
"orderedItems": []
}]
},
"target": repo_url
});
// Send the Offer to the repository inbox
remote_actor.send_activity(&inbox_url, offer_activity);
// Wait for Reject delivery
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut reject_delivery = None;
while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.drain_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("Reject") {
reject_delivery = Some(delivery);
break;
}
}
}
if reject_delivery.is_some() {
break;
}
}
let delivery = reject_delivery.expect("should receive Reject activity");
let activity: serde_json::Value = serde_json::from_str(&delivery.body).expect("valid JSON");
// Verify the Reject is for the Offer
assert_eq!(activity["type"], "Reject", "should be Reject");
assert_eq!(
activity["actor"].as_str().unwrap(),
repo_url,
"Reject actor should be repository"
);
Ok(())
}
/// Test that a user can open a ticket on a remote tracker.
#[test]
fn remote_ticket_submission_creates_pending_offer() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
// Set up a remote repository that tracks its own tickets
let remote_port = free_port();
let remote_repo = RemoteActor::serve(remote_port);
// Serve a Repository actor document with ticketsTrackedBy pointing to itself
let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
let repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", repo_url),
"ticketsTrackedBy": tracker_url,
"sendPatchesTo": tracker_url
});
remote_repo.serve_note_at_url("/repo", repo_doc);
// Serve the tracker actor document
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url),
"tracksTicketsFor": [repo_url]
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Try to open a ticket on the remote tracker via the form
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &repo_url),
("kind", "ticket"),
("summary", "Remote test ticket"),
("description", "This is a test ticket on a remote tracker"),
],
);
// Should redirect after successful submission
assert_eq!(
response.status, 303,
"remote ticket submission should redirect: {}",
response.body
);
// Wait for the Offer to be delivered to the remote tracker
// The delivery worker checks every 2 seconds, so poll with a timeout
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
let mut deliveries = Vec::new();
while start.elapsed() < timeout {
deliveries = remote_repo.drain_deliveries();
if !deliveries.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(!deliveries.is_empty(), "should have delivered Offer");
let offer_delivery = &deliveries[0];
let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");
assert_eq!(offer["type"], "Offer", "should be an Offer activity");
assert_eq!(
offer["actor"].as_str().unwrap(),
format!("http://127.0.0.1:{port}/~alice"),
"Offer actor should be alice"
);
assert_eq!(
offer["target"].as_str().unwrap(),
repo_url,
"Offer target should be remote repo"
);
assert_eq!(
offer["object"]["type"], "Ticket",
"Offer object should be a Ticket"
);
assert_eq!(
offer["object"]["summary"].as_str().unwrap(),
"Remote test ticket",
"Ticket summary should match"
);
Ok(())
}
/// Test that when a remote tracker Accepts our Offer, the pending ticket is marked accepted.
#[test]
fn remote_ticket_acceptance_updates_status() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
// Set up a remote repository that tracks its own tickets
let remote_port = free_port();
let remote_repo = RemoteActor::serve(remote_port);
let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
let repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", repo_url),
"ticketsTrackedBy": tracker_url,
"sendPatchesTo": tracker_url
});
remote_repo.serve_note_at_url("/repo", repo_doc);
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url),
"tracksTicketsFor": [repo_url]
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Submit a remote ticket
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &repo_url),
("kind", "ticket"),
("summary", "Remote test ticket"),
("description", "This is a test ticket on a remote tracker"),
],
);
assert_eq!(
response.status, 303,
"remote ticket submission should redirect: {}",
response.body
);
// Wait for the Offer to be delivered
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
let mut deliveries = Vec::new();
while start.elapsed() < timeout {
deliveries = remote_repo.drain_deliveries();
if !deliveries.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(!deliveries.is_empty(), "should have delivered Offer");
let offer_delivery = &deliveries[0];
let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");
let offer_id = offer["id"].as_str().expect("offer has id").to_string();
// Now the remote tracker sends back an Accept
let assigned_ticket_url = format!("http://127.0.0.1:{remote_port}/tickets/42");
let accept = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/accept-1", remote_repo.actor_url),
"type": "Accept",
"actor": remote_repo.actor_url,
"object": offer_id,
"result": assigned_ticket_url
});
// Send the Accept to alice's inbox
remote_repo.send_activity(&format!("http://127.0.0.1:{port}/~alice/inbox"), accept);
// Wait for the Accept to be processed
std::thread::sleep(std::time::Duration::from_millis(500));
// Check that the remote ticket is now marked as accepted
let response = client.get("/~alice/remote-tickets");
assert_eq!(response.status, 200, "should show remote tickets page");
assert!(
response.body.contains("accepted"),
"remote ticket should be marked as accepted"
);
assert!(
response.body.contains(&assigned_ticket_url),
"should show the assigned ticket URL"
);
Ok(())
}
/// Test that when a remote tracker Rejects our Offer, the pending ticket is marked rejected.
#[test]
fn remote_ticket_rejection_updates_status() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
// Set up a remote repository
let remote_port = free_port();
let remote_repo = RemoteActor::serve(remote_port);
let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
let repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", repo_url),
"ticketsTrackedBy": tracker_url
});
remote_repo.serve_note_at_url("/repo", repo_doc);
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url),
"tracksTicketsFor": [repo_url]
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Submit a remote ticket
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &repo_url),
("kind", "ticket"),
("summary", "Remote test ticket"),
("description", "This ticket will be rejected"),
],
);
assert_eq!(response.status, 303);
// Wait for delivery
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
let mut deliveries = Vec::new();
while start.elapsed() < timeout {
deliveries = remote_repo.drain_deliveries();
if !deliveries.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(!deliveries.is_empty());
let offer_delivery = &deliveries[0];
let offer: serde_json::Value = serde_json::from_str(&offer_delivery.body).expect("valid JSON");
let offer_id = offer["id"].as_str().expect("offer has id").to_string();
// Remote tracker sends a Reject
let reject = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/reject-1", remote_repo.actor_url),
"type": "Reject",
"actor": remote_repo.actor_url,
"object": offer_id,
"summary": "We don't accept external tickets"
});
remote_repo.send_activity(&format!("http://127.0.0.1:{port}/~alice/inbox"), reject);
// Wait for processing
std::thread::sleep(std::time::Duration::from_millis(500));
// Check that the remote ticket is now marked as rejected
let response = client.get("/~alice/remote-tickets");
assert_eq!(response.status, 200);
assert!(
response.body.contains("rejected"),
"remote ticket should be marked as rejected"
);
Ok(())
}
/// Test that our own repository actor document includes tracksTicketsFor and tracksPatchesFor = [self].
#[test]
fn repo_actor_includes_tracks_fields() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Fetch the repo actor document with ActivityPub content negotiation
let response = client.get_with_accept("/~alice/testrepo");
assert_eq!(
response.status, 200,
"repo actor should be served: {}",
response.body
);
let repo_actor: serde_json::Value = serde_json::from_str(&response.body).expect("valid JSON");
// Verify it's a Repository
assert_eq!(
repo_actor["type"], "Repository",
"should be a Repository actor"
);
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
assert_eq!(
repo_actor["id"].as_str().unwrap(),
repo_url,
"repo actor id should be the repo URL"
);
// Verify tracksTicketsFor = [self]
let tracks_tickets = repo_actor["tracksTicketsFor"]
.as_array()
.expect("tracksTicketsFor should be an array");
assert_eq!(
tracks_tickets.len(),
1,
"tracksTicketsFor should have 1 element"
);
assert_eq!(
tracks_tickets[0].as_str().unwrap(),
repo_url,
"tracksTicketsFor should contain the repo URL itself"
);
// Verify tracksPatchesFor = [self]
let tracks_patches = repo_actor["tracksPatchesFor"]
.as_array()
.expect("tracksPatchesFor should be an array");
assert_eq!(
tracks_patches.len(),
1,
"tracksPatchesFor should have 1 element"
);
assert_eq!(
tracks_patches[0].as_str().unwrap(),
repo_url,
"tracksPatchesFor should contain the repo URL itself"
);
Ok(())
}
/// Test that remote ticket submission is rejected when the tracker doesn't advertise tracksTicketsFor.
#[test]
fn remote_ticket_rejected_when_tracker_lacks_back_reference() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
// Set up a remote repository with a separate tracker that doesn't advertise tracksTicketsFor
let remote_port = free_port();
let remote_repo = RemoteActor::serve(remote_port);
let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
// Repo points to a separate tracker (not self-tracking)
let repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", repo_url),
"ticketsTrackedBy": tracker_url,
"sendPatchesTo": tracker_url
});
remote_repo.serve_note_at_url("/repo", repo_doc);
// Tracker does NOT include tracksTicketsFor (missing back-reference)
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url)
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Try to open a ticket on the remote tracker
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &repo_url),
("kind", "ticket"),
("summary", "Remote test ticket"),
(
"description",
"This should be rejected due to missing back-reference",
),
],
);
// Should show an error (not redirect with 303)
assert_ne!(
response.status, 303,
"submission should fail when tracker lacks tracksTicketsFor"
);
assert!(
response.body.contains("tracksTicketsFor") || response.body.contains("bidirectional"),
"error should mention the missing back-reference: {}",
response.body
);
// Verify no Offer was delivered
let deliveries = remote_repo.recorded_deliveries();
assert!(
deliveries.is_empty(),
"should not deliver Offer when bidirectional reference is missing"
);
Ok(())
}
/// Test that patch submission requires origin repository and branch fields.
#[test]
fn remote_patch_requires_origin_fields() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
// Set up a remote repository that accepts patches
let remote_port = free_port();
let remote_repo = RemoteActor::serve(remote_port);
let repo_url = format!("http://127.0.0.1:{remote_port}/repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
let repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", repo_url),
"sendPatchesTo": tracker_url
});
remote_repo.serve_note_at_url("/repo", repo_doc);
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url),
"tracksPatchesFor": [repo_url]
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Try to submit a patch without origin fields - should fail
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &repo_url),
("kind", "mr"),
("summary", "Remote test patch"),
("description", "This patch is missing origin fields"),
],
);
// Should show an error (not redirect with 303)
assert_ne!(
response.status, 303,
"patch submission should fail without origin fields"
);
assert!(
response.body.contains("origin") || response.body.contains("branch"),
"error should mention missing origin fields: {}",
response.body
);
Ok(())
}
/// Test that outbound patch submission generates a Patch object with patch collection.
#[test]
fn remote_patch_submission_generates_patch_object() -> 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 client = support::Client::new(port);
register(&mut client, "alice");
// Create a local repository for alice
create_repository(&mut client, "alice", "origin-repo", "public");
// Populate the repository with commits on a feature branch
let scratch = Scratch::new();
let bare = server.repo_root().join("alice").join("origin-repo.git");
let work = scratch.path("work");
fs::create_dir_all(&work).expect("the working tree should be creatable");
// Initialize working tree
assert_ok(&git(&work, &["init", "-q", "-b", "main"]), "initialising");
fs::write(
work.join("README.md"),
"# Origin Repo\n\nInitial content.\n",
)
.expect("writing the readme");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Initial commit"]),
"committing",
);
// Create a feature branch with changes
assert_ok(
&git(&work, &["checkout", "-q", "-b", "feature"]),
"creating feature branch",
);
fs::write(work.join("feature.txt"), "New feature content.\n").expect("writing feature file");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Add feature"]),
"committing feature",
);
// Push to the bare repository
assert_ok(
&git(&work, &["push", "-q", bare.to_str().unwrap(), "main"]),
"pushing main",
);
assert_ok(
&git(&work, &["push", "-q", bare.to_str().unwrap(), "feature"]),
"pushing feature branch",
);
// Set up a remote repository that accepts patches
let remote_port = free_port();
let remote_repo = support::ap::RemoteActor::serve(remote_port);
let target_repo_url = format!("http://127.0.0.1:{remote_port}/target-repo");
let tracker_url = format!("http://127.0.0.1:{remote_port}/tracker");
let origin_repo_url = format!("http://127.0.0.1:{port}/~alice/origin-repo");
let target_repo_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": target_repo_url,
"type": "Repository",
"inbox": format!("{}/inbox", target_repo_url),
"sendPatchesTo": tracker_url
});
remote_repo.serve_note_at_url("/target-repo", target_repo_doc);
let tracker_doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns"
],
"id": tracker_url,
"type": "Tracker",
"inbox": format!("{}/inbox", tracker_url),
"tracksPatchesFor": [target_repo_url]
});
remote_repo.serve_note_at_url("/tracker", tracker_doc);
// Submit a patch
let response = client.post_form(
"/~alice/remote-tickets",
&[
("target", &target_repo_url),
("kind", "mr"),
("summary", "Add new feature"),
("description", "This patch adds a new feature"),
("origin_repo", &origin_repo_url),
("origin_branch", "feature"),
("target_branch", "main"),
],
);
// Should redirect with 303 on success
assert_eq!(
response.status, 303,
"patch submission should succeed: {}",
response.body
);
// Wait for delivery
std::thread::sleep(std::time::Duration::from_millis(2000));
// Verify the Offer was delivered and contains a Patch object
let delivered = remote_repo.recorded_deliveries();
assert!(!delivered.is_empty(), "should have delivered an activity");
let offer: serde_json::Value = serde_json::from_str(&delivered[0].body).expect("valid JSON");
assert_eq!(offer["type"], "Offer", "should be an Offer");
assert_eq!(offer["object"]["type"], "Patch", "object should be a Patch");
// Verify the Patch has an attachment with a Branch
assert_eq!(
offer["object"]["attachment"]["type"], "Branch",
"attachment should be a Branch"
);
assert_eq!(
offer["object"]["attachment"]["name"], "feature",
"branch name should match"
);
// Verify the Patch has a Collection object
assert_eq!(
offer["object"]["object"]["type"], "Collection",
"object.object should be a Collection"
);
// Verify the collection has items (patches)
let items = offer["object"]["object"]["items"]
.as_array()
.expect("items should be an array");
assert!(
!items.is_empty(),
"collection should have at least one patch"
);
// Each item should be an Article with content
for item in items {
assert_eq!(item["type"], "Article", "each item should be an Article");
assert!(
item["content"].as_str().is_some(),
"each Article should have content"
);
}
Ok(())
}
/// Scratch directory for git working trees.
struct Scratch {
dir: std::path::PathBuf,
}
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("frogue-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).expect("scratch dir should be creatable");
Scratch { dir }
}
fn path(&self, name: &str) -> std::path::PathBuf {
let p = self.dir.join(name);
fs::create_dir_all(&p).expect("subdirectory should be creatable");
p
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
/// Runs `git` in `dir` with the test's own identity.
fn git(dir: &Path, args: &[&str]) -> Output {
Command::new("git")
.current_dir(dir)
.args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git should run")
}
/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
assert!(
output.status.success(),
"{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
/// Test that remote ticket creators are displayed with remote badge and link in WebUI.
#[test]
fn remote_ticket_author_displayed_with_badge() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up a remote actor that will create a ticket
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Create a ticket via remote Offer
let _ticket_url = create_ticket_via_offer(
&mut client,
port,
"alice",
"testrepo",
"Remote ticket",
"This ticket was created by a remote actor",
&remote_actor,
);
// Wait for processing
std::thread::sleep(std::time::Duration::from_millis(500));
// Fetch the ticket list page (HTML)
let response = client.get("/~alice/testrepo/tickets");
assert_eq!(response.status, 200, "ticket list should load");
// Verify the remote badge is present
assert!(
response.body.contains("remote-badge"),
"ticket list should show remote badge for remote author: {}",
response.body
);
// Verify the author name is present (should be the remote actor's preferred_username or display name)
// The RemoteActor serves a default actor with name "Remote User"
assert!(
response.body.contains("Remote User") || response.body.contains("remote"),
"ticket list should show remote author name: {}",
response.body
);
// Fetch the individual ticket page (HTML)
let response = client.get("/~alice/testrepo/tickets/1");
assert_eq!(response.status, 200, "ticket detail should load");
// Verify the remote badge is present on the detail page
assert!(
response.body.contains("remote-badge"),
"ticket detail should show remote badge for remote author: {}",
response.body
);
Ok(())
}
/// Test that remote comment authors are displayed with remote badge and link in WebUI.
#[test]
fn remote_comment_author_displayed_with_badge() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = support::Client::new(port);
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Create a local ticket first
create_ticket(
&mut client,
"alice",
"testrepo",
"Local ticket",
"This is a local ticket",
);
// Set up a remote actor that will comment
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Get the repository actor's inbox URL
let repo_url = format!("http://127.0.0.1:{port}/~alice/testrepo");
let inbox_url = format!("{repo_url}/inbox");
let ticket_url = format!("{repo_url}/tickets/1");
// Create a remote comment via Create(Note) activity
let note_id = format!("http://127.0.0.1:{remote_port}/notes/1");
let create_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": format!("http://127.0.0.1:{remote_port}/creates/1"),
"actor": remote_actor.actor_url,
"to": [repo_url],
"object": {
"type": "Note",
"id": note_id,
"attributedTo": remote_actor.actor_url,
"inReplyTo": ticket_url,
"content": "<p>This is a remote comment</p>",
"source": {
"mediaType": "text/markdown",
"content": "This is a remote comment"
}
}
});
// Send the Create to the repository inbox
remote_actor.send_activity(&inbox_url, create_activity);
// Wait for processing
std::thread::sleep(std::time::Duration::from_millis(2000));
// Fetch the ticket detail page (HTML)
let response = client.get("/~alice/testrepo/tickets/1");
assert_eq!(response.status, 200, "ticket detail should load");
// Verify the remote badge is present for the comment
assert!(
response.body.contains("remote-badge"),
"ticket detail should show remote badge for remote commenter: {}",
response.body
);
// Verify the comment content is present
assert!(
response.body.contains("remote comment"),
"ticket detail should show the remote comment: {}",
response.body
);
Ok(())
}