main / frogue / tests / patch_federation.rs
Raw
//! Tests for ForgeFed patch proposal federation.
//!
//! These tests verify that patch proposal activities (Offer, Add, Resolve,
//! Push) are correctly emitted to repository followers.
mod support;
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
use support::ap::RemoteActor;
use support::{Server, free_port};
use uuid::Uuid;
const PASSWORD: &str = "correct horse battery staple";
fn spawn() -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
(server, port)
}
/// Registers `name` through the form, which logs them in.
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", PASSWORD)],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Creates a repository through the 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 repository from the patch federation tests",
),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating a repository should redirect\n{}",
response.body
);
}
/// Creates a patch proposal directly in the database for testing.
async fn create_patch(
server: &Server,
repository_id: i64,
creator_id: i64,
number: i64,
summary: &str,
status: &str,
) -> i64 {
use chrono::Utc;
use frogue::entities::patch_proposals::{ActiveModel, MergePolicy, Status};
let db = Database::connect(server.database_url())
.await
.expect("connecting to test database");
let status = match status {
"draft" => Status::Draft,
"open" => Status::Open,
"closed" => Status::Closed,
"merged" => Status::Merged,
_ => panic!("unknown status"),
};
let now = Utc::now().fixed_offset();
let pp = ActiveModel {
uuid: Set(Uuid::new_v4()),
repository_id: Set(repository_id),
creator_id: Set(Some(creator_id)),
number: Set(number),
summary: Set(summary.to_string()),
description: Set(Some("Test description".to_string())),
source_ref: Set(format!("patches/{number}")),
target_branch: Set("main".to_string()),
status: Set(status),
merge_policy: Set(MergePolicy::PreferFastForward),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
let pp = pp.insert(&db).await.expect("inserting patch");
pp.id
}
/// Gets the repository ID for a given owner/slug
async fn get_repository_id(server: &Server, _owner: &str, slug: &str) -> i64 {
use frogue::entities::repositories::{Column, Entity as Repositories};
let db = Database::connect(server.database_url())
.await
.expect("connecting to test database");
let repo = Repositories::find()
.filter(Column::Slug.eq(slug))
.one(&db)
.await
.expect("querying repository")
.expect("repository not found");
repo.id
}
/// Gets the user ID for a given username
async fn get_user_id(server: &Server, name: &str) -> i64 {
use frogue::entities::users::{Column, Entity as Users};
let db = Database::connect(server.database_url())
.await
.expect("connecting to test database");
let user = Users::find()
.filter(Column::Name.eq(name))
.one(&db)
.await
.expect("querying user")
.expect("user not found");
user.id
}
/// Seeds the bare git repository with commits so merges can succeed.
/// Creates a commit on main and a descendant commit on patches/1.
fn seed_git_repo(server: &Server, owner: &str, slug: &str) {
use std::process::Command;
let bare_path = server.repo_root().join(owner).join(format!("{slug}.git"));
// Create a temporary working directory
let temp_dir = std::env::temp_dir().join(format!("frogue-test-git-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
// Initialize a git repo
let output = Command::new("git")
.args(["init", "-q", "-b", "main"])
.current_dir(&temp_dir)
.output()
.expect("git init");
assert!(output.status.success(), "git init failed: {:?}", output);
// Configure git user
Command::new("git")
.args(["config", "user.email", "test@example.org"])
.current_dir(&temp_dir)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&temp_dir)
.output()
.expect("git config name");
// Create initial commit on main
std::fs::write(temp_dir.join("README.md"), "# Test\n").expect("write README");
Command::new("git")
.args(["add", "README.md"])
.current_dir(&temp_dir)
.output()
.expect("git add");
let output = Command::new("git")
.args(["commit", "-q", "-m", "Initial commit"])
.current_dir(&temp_dir)
.output()
.expect("git commit");
assert!(output.status.success(), "git commit failed: {:?}", output);
// Create a branch for patches/1
let output = Command::new("git")
.args(["checkout", "-q", "-b", "patches/1"])
.current_dir(&temp_dir)
.output()
.expect("git checkout");
assert!(output.status.success(), "git checkout failed: {:?}", output);
// Create a commit on patches/1
std::fs::write(temp_dir.join("patch.txt"), "Patch content\n").expect("write patch file");
Command::new("git")
.args(["add", "patch.txt"])
.current_dir(&temp_dir)
.output()
.expect("git add patch");
let output = Command::new("git")
.args(["commit", "-q", "-m", "Add patch"])
.current_dir(&temp_dir)
.output()
.expect("git commit patch");
assert!(
output.status.success(),
"git commit patch failed: {:?}",
output
);
// Push main to bare repo
let output = Command::new("git")
.args([
"push",
"-q",
bare_path.to_str().unwrap(),
"main:refs/heads/main",
])
.current_dir(&temp_dir)
.output()
.expect("git push main");
assert!(
output.status.success(),
"git push main failed: {:?}",
output
);
// Push patches/1 to bare repo
let output = Command::new("git")
.args([
"push",
"-q",
bare_path.to_str().unwrap(),
"patches/1:refs/patches/1",
])
.current_dir(&temp_dir)
.output()
.expect("git push patches/1");
assert!(
output.status.success(),
"git push patches/1 failed: {:?}",
output
);
// Clean up
std::fs::remove_dir_all(&temp_dir).ok();
}
/// Test that opening a draft patch emits Offer and Accept activities.
#[actix_web::test]
async fn opening_draft_patch_emits_offer_accept() {
let (server, port) = spawn();
let mut client = support::Client::new(port);
// Register Alice and create her repository
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Register Bob (this logs in Bob)
register(&mut client, "bob");
// Set up remote follower
let remote_actor = RemoteActor::serve(free_port());
let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
remote_actor.follow(repo_url);
// Wait for follow acceptance
let accept_delivery = remote_actor.wait_for_delivery(10);
assert!(
accept_delivery.is_some(),
"should receive delivery for Follow"
);
let accept: serde_json::Value =
serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
assert_eq!(
accept.get("type").and_then(|t| t.as_str()),
Some("Accept"),
"should receive Accept for Follow"
);
// Get IDs
let repo_id = get_repository_id(&server, "alice", "testrepo").await;
let bob_id = get_user_id(&server, "bob").await;
// Create a draft patch as Bob
let _patch_id = create_patch(&server, repo_id, bob_id, 1, "Test patch", "draft").await;
// Bob opens the draft via web UI
let response = client.post_form(
"/~alice/testrepo/patches/1",
&[
("summary", "Test patch"),
("description", "Test description"),
("target_branch", "main"),
("status", "open"),
],
);
assert_eq!(response.status, 303, "editing patch should redirect");
// Wait for Accept activity (Offer is stored but not delivered)
let mut found_accept = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.recorded_deliveries();
for delivery in deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
&& activity.get("type").and_then(|t| t.as_str()) == Some("Accept")
&& let Some(actor) = activity.get("actor").and_then(|a| a.as_str())
&& actor.contains("/~alice/testrepo")
{
found_accept = true;
break;
}
}
if found_accept {
break;
}
}
assert!(
found_accept,
"should receive Accept for Offer when opening draft"
);
}
/// Test that merging a patch emits Resolve and Push activities.
#[actix_web::test]
async fn merging_patch_emits_resolve_push() {
let (server, port) = spawn();
let mut client = support::Client::new(port);
// Register Alice and create her repository
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Set up remote follower
let remote_actor = RemoteActor::serve(free_port());
let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
remote_actor.follow(repo_url);
// Wait for follow acceptance
let accept_delivery = remote_actor.wait_for_delivery(10);
assert!(
accept_delivery.is_some(),
"should receive delivery for Follow"
);
let accept: serde_json::Value =
serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
assert_eq!(
accept.get("type").and_then(|t| t.as_str()),
Some("Accept"),
"should receive Accept for Follow"
);
// Get IDs
let repo_id = get_repository_id(&server, "alice", "testrepo").await;
let alice_id = get_user_id(&server, "alice").await;
// Seed the git repo with commits so merge can succeed
seed_git_repo(&server, "alice", "testrepo");
// Create an open patch as Alice (so Alice can merge it)
create_patch(&server, repo_id, alice_id, 1, "Test patch", "open").await;
// Alice merges the patch
let response = client.post_form("/~alice/testrepo/patches/1/merge", &[]);
assert_eq!(response.status, 303, "merging patch should redirect");
// Wait for Resolve activity
let mut found_resolve = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.recorded_deliveries();
for delivery in deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
&& activity.get("type").and_then(|t| t.as_str()) == Some("Resolve")
{
found_resolve = true;
assert_eq!(
activity["actor"].as_str().unwrap(),
format!("http://127.0.0.1:{}/~alice/testrepo", port),
"Resolve actor should be repository"
);
break;
}
}
if found_resolve {
break;
}
}
assert!(found_resolve, "should receive Resolve when merging");
// Wait for Push activity
let mut found_push = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.recorded_deliveries();
for delivery in deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
&& activity.get("type").and_then(|t| t.as_str()) == Some("Push")
{
found_push = true;
break;
}
}
if found_push {
break;
}
}
assert!(found_push, "should receive Push when merging");
}
/// Test that closing a patch emits Resolve activity.
#[actix_web::test]
async fn closing_patch_emits_resolve() {
let (server, port) = spawn();
let mut client = support::Client::new(port);
// Register Alice and create her repository
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Register Bob (this logs in Bob)
register(&mut client, "bob");
// Set up remote follower
let remote_actor = RemoteActor::serve(free_port());
let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
remote_actor.follow(repo_url);
// Wait for follow acceptance
let accept_delivery = remote_actor.wait_for_delivery(10);
assert!(
accept_delivery.is_some(),
"should receive delivery for Follow"
);
let accept: serde_json::Value =
serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
assert_eq!(
accept.get("type").and_then(|t| t.as_str()),
Some("Accept"),
"should receive Accept for Follow"
);
// Get IDs
let repo_id = get_repository_id(&server, "alice", "testrepo").await;
let bob_id = get_user_id(&server, "bob").await;
// Create an open patch as Bob
create_patch(&server, repo_id, bob_id, 1, "Test patch", "open").await;
// Bob closes the patch
let response = client.post_form(
"/~alice/testrepo/patches/1",
&[
("summary", "Test patch"),
("description", "Test description"),
("target_branch", "main"),
("status", "closed"),
],
);
assert_eq!(response.status, 303, "closing patch should redirect");
// Wait for Resolve activity
let mut found_resolve = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.recorded_deliveries();
for delivery in deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
&& activity.get("type").and_then(|t| t.as_str()) == Some("Resolve")
{
found_resolve = true;
break;
}
}
if found_resolve {
break;
}
}
assert!(found_resolve, "should receive Resolve when closing");
}
/// Test that reopening a closed patch emits Undo(Resolve) activity.
#[actix_web::test]
async fn reopening_patch_emits_undo_resolve() {
let (server, port) = spawn();
let mut client = support::Client::new(port);
// Register Alice and create her repository
register(&mut client, "alice");
create_repository(&mut client, "alice", "testrepo", "public");
// Register Bob (this logs in Bob)
register(&mut client, "bob");
// Set up remote follower
let remote_actor = RemoteActor::serve(free_port());
let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
remote_actor.follow(repo_url);
// Wait for follow acceptance
let accept_delivery = remote_actor.wait_for_delivery(10);
assert!(
accept_delivery.is_some(),
"should receive delivery for Follow"
);
let accept: serde_json::Value =
serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
assert_eq!(
accept.get("type").and_then(|t| t.as_str()),
Some("Accept"),
"should receive Accept for Follow"
);
// Get IDs
let repo_id = get_repository_id(&server, "alice", "testrepo").await;
let bob_id = get_user_id(&server, "bob").await;
// Create an open patch as Bob
create_patch(&server, repo_id, bob_id, 1, "Test patch", "open").await;
// Bob closes the patch (this sets resolve_activity_url)
let response = client.post_form(
"/~alice/testrepo/patches/1",
&[
("summary", "Test patch"),
("description", "Test description"),
("target_branch", "main"),
("status", "closed"),
],
);
assert_eq!(response.status, 303, "closing patch should redirect");
// Wait a bit for the Resolve to be emitted
std::thread::sleep(std::time::Duration::from_millis(500));
// Clear deliveries before reopening
remote_actor.drain_deliveries();
// Bob reopens the patch (this should emit Undo(Resolve))
let response = client.post_form(
"/~alice/testrepo/patches/1",
&[
("summary", "Test patch"),
("description", "Test description"),
("target_branch", "main"),
("status", "open"),
],
);
assert_eq!(response.status, 303, "reopening patch should redirect");
// Wait for Undo(Resolve) activity
let mut found_undo = false;
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(100));
let deliveries = remote_actor.recorded_deliveries();
for delivery in deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
&& activity.get("type").and_then(|t| t.as_str()) == Some("Undo")
{
let object = activity.get("object").expect("Undo should have object");
if object.get("type").and_then(|t| t.as_str()) == Some("Resolve") {
found_undo = true;
break;
}
}
}
if found_undo {
break;
}
}
assert!(found_undo, "should receive Undo(Resolve) when reopening");
}