//! Phase 2 tests: Follow/unfollow remote actors
mod support;
use std::time::{Duration, Instant};
use support::ap::RemoteActor;
use support::{Client, Server, free_port};
/// 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", "correct horse battery staple"),
],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Test: Follow a remote actor sends Follow activity and creates pending row
#[tokio::test]
async fn follow_sends_activity() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow the remote actor with handle directly (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(
follow_response.status, 303,
"follow should redirect\n{}",
follow_response.body
);
// Wait for Follow activity delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_follow = 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("Follow") {
if let Some(obj) = activity.get("object") {
if obj.as_str() == Some(&recorder.actor_url) {
found_follow = true;
break;
}
}
}
}
}
if found_follow {
break;
}
}
assert!(
found_follow,
"should have received Follow activity within deadline"
);
Ok(())
}
/// Test: Receiving Accept(Follow) marks the follow as accepted
#[tokio::test]
async fn accept_marks_follow_accepted() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow with handle directly (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow delivery
let deadline = Instant::now() + Duration::from_secs(5);
let mut follow_activity_id = None;
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("Follow") {
follow_activity_id = activity
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
break;
}
}
}
if follow_activity_id.is_some() {
break;
}
}
let follow_id = follow_activity_id.expect("should have received Follow activity");
// Send Accept(Follow) from the remote actor to alice's inbox
let accept_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/accept/1", recorder.actor_url),
"type": "Accept",
"actor": recorder.actor_url,
"object": {
"id": follow_id,
"type": "Follow",
"actor": format!("http://127.0.0.1:{port}/~alice"),
"object": recorder.actor_url
}
});
let body = serde_json::to_string(&accept_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&body,
);
let response = alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(
response.status, 202,
"Accept should be accepted. Server log:"
);
// The follow should now be marked as accepted in the database
// We can't directly query the DB from here, but we can verify by checking
// that the UI shows the follow as accepted (if we add such a check)
// For now, we just verify the Accept was processed without error
Ok(())
}
/// Test: Receiving Reject(Follow) marks the follow as rejected
#[tokio::test]
async fn reject_marks_follow_rejected() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow with handle directly (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow delivery
let deadline = Instant::now() + Duration::from_secs(5);
let mut follow_activity_id = None;
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("Follow") {
follow_activity_id = activity
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
break;
}
}
}
if follow_activity_id.is_some() {
break;
}
}
let follow_id = follow_activity_id.expect("should have received Follow activity");
// Send Reject(Follow) from the remote actor
let reject_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/reject/1", recorder.actor_url),
"type": "Reject",
"actor": recorder.actor_url,
"object": {
"id": follow_id,
"type": "Follow",
"actor": format!("http://127.0.0.1:{port}/~alice"),
"object": recorder.actor_url
}
});
let body = serde_json::to_string(&reject_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&body,
);
let response = alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(
response.status, 202,
"Reject should be accepted. Server log:"
);
Ok(())
}
/// Test: Unfollow sends Undo(Follow) and removes the row
#[tokio::test]
async fn unfollow_sends_undo() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow with handle directly (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow delivery
let deadline = Instant::now() + Duration::from_secs(5);
let mut found_follow = 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("Follow") {
found_follow = true;
break;
}
}
}
if found_follow {
break;
}
}
assert!(found_follow, "should have received Follow activity");
// Unfollow
let unfollow_response =
alice_client.post_form("/~alice/unfollow", &[("actor_url", &recorder.actor_url)]);
assert_eq!(
unfollow_response.status, 303,
"unfollow should redirect\n{}",
unfollow_response.body
);
// Wait for Undo(Follow) delivery
let deadline = Instant::now() + Duration::from_secs(5);
let mut found_undo = 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("Undo") {
if let Some(obj) = activity.get("object") {
if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
found_undo = true;
break;
}
}
}
}
}
if found_undo {
break;
}
}
assert!(
found_undo,
"should have received Undo(Follow) activity within deadline"
);
Ok(())
}
/// Test: Duplicate follow is idempotent
#[tokio::test]
async fn duplicate_follow_idempotent() -> 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");
// Start a remote actor
let recorder_port = free_port();
let _recorder = RemoteActor::serve(recorder_port);
// Follow with handle directly (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
let follow1 = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(follow1.status, 303, "first follow should succeed");
// Follow again with the same handle
let follow2 = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(
follow2.status, 303,
"duplicate follow should redirect (idempotent)"
);
Ok(())
}
/// Test: Follow with invalid handle fails
#[tokio::test]
async fn follow_without_discovery_fails() -> 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");
// Try to follow with an invalid handle format (missing @)
let follow_response = alice_client.post_form("/~alice/follow", &[("handle", "invalidhandle")]);
assert_eq!(
follow_response.status, 303,
"follow should redirect with error"
);
assert!(
follow_response
.location
.as_ref()
.unwrap()
.contains("error="),
"redirect should include error parameter"
);
Ok(())
}
/// Test: Follow endpoint returns 404 when federation is disabled
#[tokio::test]
async fn follow_disabled_when_federation_off() -> miette::Result<()> {
let port = free_port();
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");
// Try to follow (should 404 since federation is disabled)
let follow_response = alice_client.post_form(
"/~alice/follow",
&[("actor_url", "http://example.com/actor")],
);
assert_eq!(
follow_response.status, 404,
"follow should return 404 when federation disabled"
);
Ok(())
}
/// Test: Profile page shows follow form for owner
#[tokio::test]
async fn profile_shows_follow_form_for_owner() -> 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");
// View own profile
let response = alice_client.get("/~alice");
assert_eq!(response.status, 200);
assert!(
response.body.contains("Follow a remote user"),
"profile should show follow form for owner, got:\n{}",
response.body
);
assert!(
response.body.contains("name=\"handle\""),
"profile should have handle input, got:\n{}",
response.body
);
Ok(())
}
/// Test: Profile page does not show follow form for non-owner
#[tokio::test]
async fn profile_no_follow_form_for_non_owner() -> 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");
let mut bob_client = Client::new(port);
register(&mut bob_client, "bob");
// View Alice's profile as Bob
let response = bob_client.get("/~alice");
assert_eq!(response.status, 200);
assert!(
!response.body.contains("Follow a remote user"),
"profile should not show follow form for non-owner, got:\n{}",
response.body
);
Ok(())
}
/// Test: Follow with handle in one step
#[tokio::test]
async fn follow_with_handle_one_step() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow with handle directly (no separate discover step)
let handle = format!("remote@127.0.0.1:{recorder_port}");
let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(
follow_response.status, 303,
"follow with handle should redirect\n{}",
follow_response.body
);
assert!(
follow_response.location.is_some(),
"should redirect with Location header"
);
assert!(
follow_response
.location
.as_ref()
.unwrap()
.contains("notice="),
"redirect should include notice parameter"
);
// Wait for Follow activity delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_follow = 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("Follow") {
if let Some(obj) = activity.get("object") {
if obj.as_str() == Some(&recorder.actor_url) {
found_follow = true;
break;
}
}
}
}
}
if found_follow {
break;
}
}
assert!(
found_follow,
"should have received Follow activity within deadline"
);
Ok(())
}
/// Test: Follow with unknown handle redirects with error
#[tokio::test]
async fn follow_with_unknown_handle_errors() -> 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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Try to follow with an unknown handle
let handle = format!("nonexistent@127.0.0.1:{recorder_port}");
let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(
follow_response.status, 303,
"follow should redirect with error\n{}",
follow_response.body
);
assert!(
follow_response
.location
.as_ref()
.unwrap()
.contains("error="),
"redirect should include error parameter, got location: {:?}",
follow_response.location
);
// Verify no Follow delivery
std::thread::sleep(Duration::from_secs(1));
let deliveries = recorder.recorded_deliveries();
let follow_count = deliveries
.iter()
.filter(|d| {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&d.body) {
activity.get("type").and_then(|t| t.as_str()) == Some("Follow")
} else {
false
}
})
.count();
assert_eq!(follow_count, 0, "should not have sent Follow activity");
Ok(())
}
/// Test: Profile page shows following list after following
#[tokio::test]
async fn profile_shows_following_list() -> 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");
// Start a remote actor
let recorder_port = free_port();
let _recorder = RemoteActor::serve(recorder_port);
// Follow with handle
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait a bit for the follow to be recorded
std::thread::sleep(Duration::from_millis(500));
// View profile
let response = alice_client.get("/~alice");
assert_eq!(response.status, 200);
assert!(
response.body.contains("Following"),
"profile should show Following section, got:\n{}",
response.body
);
assert!(
response.body.contains("pending") || response.body.contains("accepted"),
"profile should show follow state, got:\n{}",
response.body
);
assert!(
response.body.contains("Unfollow"),
"profile should show unfollow button, got:\n{}",
response.body
);
Ok(())
}
/// Test: Profile page does not show following form when federation is disabled
#[tokio::test]
async fn profile_no_follow_form_when_federation_off() -> miette::Result<()> {
let port = free_port();
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");
// View own profile
let response = alice_client.get("/~alice");
assert_eq!(response.status, 200);
assert!(
!response.body.contains("Follow a remote user"),
"profile should not show follow form when federation disabled, got:\n{}",
response.body
);
Ok(())
}
/// Test: Profile page shows react buttons on notes
#[tokio::test]
async fn profile_shows_react_buttons_on_notes() -> 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");
let mut bob_client = Client::new(port);
register(&mut bob_client, "bob");
// Alice posts a note
let response = alice_client.post_form("/~alice/notes", &[("content", "Alice's test note")]);
assert_eq!(response.status, 303, "posting a note should redirect");
// Bob views Alice's profile - should see react button
let response = bob_client.get("/~alice");
assert_eq!(response.status, 200, "profile should load");
assert!(
response.body.contains("Alice's test note"),
"profile should contain the note, got:\n{}",
response.body
);
assert!(
response.body.contains("❤️ React"),
"profile should show React button, got:\n{}",
response.body
);
Ok(())
}
/// Test: Profile page shows Unreact button after reacting
#[tokio::test]
async fn profile_shows_unreact_button_after_reacting() -> 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");
let mut bob_client = Client::new(port);
register(&mut bob_client, "bob");
// Alice posts a note
let response =
alice_client.post_form("/~alice/notes", &[("content", "Alice's react test note")]);
assert_eq!(response.status, 303, "posting a note should redirect");
// Bob views Alice's profile and extracts the note UUID
let response = bob_client.get("/~alice");
assert_eq!(response.status, 200, "profile should load");
// Extract note UUID from the note link (format: /~alice/notes/{uuid})
let note_uuid = extract_note_uuid_from_profile(&response.body, "Alice's react test note")
.expect("should find note UUID in profile");
// Bob reacts to Alice's note
let react_response = bob_client.post_form(&format!("/~alice/notes/{note_uuid}/react"), &[]);
assert_eq!(react_response.status, 303, "reacting should redirect");
// Bob views Alice's profile again - should see Unreact button
let response = bob_client.get("/~alice");
assert_eq!(response.status, 200, "profile should load");
assert!(
response.body.contains("Alice's react test note"),
"profile should contain the note, got:\n{}",
response.body
);
assert!(
response.body.contains("❤️ Unreact"),
"profile should show Unreact button after reacting, got:\n{}",
response.body
);
Ok(())
}
/// Helper to extract note UUID from profile HTML
fn extract_note_uuid_from_profile(html: &str, note_content: &str) -> Option<String> {
// Look for the note content, then find the associated UUID
// The note is in a <li> with data-note-uuid attribute
let search = "data-note-uuid=\"";
let content_pos = html.find(note_content)?;
// Search backwards for the data-note-uuid attribute
let before_content = &html[..content_pos];
let uuid_attr_pos = before_content.rfind(search)?;
let after_attr = &html[uuid_attr_pos + search.len()..];
let uuid_end = after_attr.find('"')?;
Some(after_attr[..uuid_end].to_string())
}