//! Phase 4 tests: ActivityPub delivery to followers
mod support;
use std::time::{Duration, Instant};
use support::ap::RemoteActor;
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
/// Register a user through the web form.
fn register(client: &mut Client, name: &str) {
let email = format!("{name}@example.org");
let response = client.post_form(
"/register",
&[("name", name), ("email", &email), ("password", PASSWORD)],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Test 1: RemoteActor follows alice → Accept(Follow) is delivered
#[tokio::test]
async fn follow_is_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 recorder
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Bob follows alice by sending a Follow activity to her inbox
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": format!("http://127.0.0.1:{port}/~alice")
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/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,
"Follow should be accepted. Server log:"
);
// Poll for Accept(Follow) delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_accept = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
if let Some(obj) = activity.get("object") {
if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
found_accept = true;
break;
}
}
}
}
}
if found_accept {
break;
}
}
assert!(
found_accept,
"should have received Accept(Follow) within deadline"
);
Ok(())
}
/// Test 2: Alice publishes a note → followers receive Create(Note)
#[tokio::test]
async fn note_is_delivered_to_followers() -> 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 recorder
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Bob follows alice
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": format!("http://127.0.0.1:{port}/~alice")
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&body,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Wait for Accept(Follow)
let deadline = Instant::now() + Duration::from_secs(10);
let mut has_accept = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
has_accept = true;
break;
}
}
}
if has_accept {
break;
}
}
assert!(has_accept, "should have received Accept(Follow)");
// Alice posts a note
let response = alice_client.post_form("/~alice/notes", &[("content", "hello **world**")]);
assert_eq!(response.status, 303, "posting a note should redirect");
// Poll for Create(Note) delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_create = 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("Create") {
if let Some(obj) = activity.get("object") {
if obj.get("type").and_then(|t| t.as_str()) == Some("Note") {
// Verify the note content
let content = obj.get("content").and_then(|c| c.as_str()).unwrap();
assert!(
content.contains("<strong>world</strong>"),
"note should contain rendered markdown"
);
found_create = true;
break;
}
}
}
}
}
if found_create {
break;
}
}
assert!(
found_create,
"should have received Create(Note) within deadline"
);
Ok(())
}
/// Test 3: Two followers with shared inbox → exactly one delivery
#[tokio::test]
async fn shared_inbox_deduplicates() -> 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 multiple remote actors with a shared inbox
let shared_inbox_port = free_port();
let recorder = support::ap::SharedInboxActors::serve(shared_inbox_port, &["bob", "charlie"]);
// Two actors follow alice, both using the shared inbox
for actor in &recorder.actors {
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", actor.actor_url),
"type": "Follow",
"actor": actor.actor_url,
"object": format!("http://127.0.0.1:{port}/~alice")
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&actor.private_key_pem,
&actor.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&body,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
}
// Wait for both Accept(Follow)
let deadline = Instant::now() + Duration::from_secs(10);
let mut accept_count = 0;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
accept_count = deliveries
.iter()
.filter(|d| {
serde_json::from_str::<serde_json::Value>(&d.body)
.ok()
.and_then(|a| a.get("type")?.as_str().map(|t| t == "Accept"))
.unwrap_or(false)
})
.count();
if accept_count >= 2 {
break;
}
}
assert_eq!(accept_count, 2, "should have received 2 Accept(Follow)");
// Alice posts a note
let response = alice_client.post_form("/~alice/notes", &[("content", "hello shared inbox")]);
assert_eq!(response.status, 303);
// Wait for delivery
let deadline = Instant::now() + Duration::from_secs(10);
let mut create_count = 0;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
create_count = deliveries
.iter()
.filter(|d| {
serde_json::from_str::<serde_json::Value>(&d.body)
.ok()
.and_then(|a| a.get("type")?.as_str().map(|t| t == "Create"))
.unwrap_or(false)
})
.count();
if create_count >= 1 {
break;
}
}
assert_eq!(
create_count, 1,
"shared inbox should receive exactly one delivery, got {}",
create_count
);
Ok(())
}
/// Test 4: Retry logic - 500 twice, third succeeds
#[tokio::test]
async fn delivery_retries_on_failure() -> 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 recorder that fails twice
let recorder_port = free_port();
let recorder = RemoteActor::serve_with_failures(recorder_port, 2);
// Bob follows alice
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": format!("http://127.0.0.1:{port}/~alice")
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&body,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Wait for Accept(Follow) (will also fail twice, then succeed)
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
let deliveries = recorder.recorded_deliveries();
let has_accept = deliveries.iter().any(|d| {
serde_json::from_str::<serde_json::Value>(&d.body)
.ok()
.and_then(|a| a.get("type")?.as_str().map(|t| t == "Accept"))
.unwrap_or(false)
});
if has_accept {
break;
}
}
// Alice posts a note
let response = alice_client.post_form("/~alice/notes", &[("content", "retry test")]);
assert_eq!(response.status, 303);
// Wait for successful delivery after retries
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
let deliveries = recorder.recorded_deliveries();
let has_create = deliveries.iter().any(|d| {
serde_json::from_str::<serde_json::Value>(&d.body)
.ok()
.and_then(|a| a.get("type")?.as_str().map(|t| t == "Create"))
.unwrap_or(false)
});
if has_create {
break;
}
}
// Verify we got the delivery after retries
let deliveries = recorder.recorded_deliveries();
let create_count = deliveries
.iter()
.filter(|d| {
serde_json::from_str::<serde_json::Value>(&d.body)
.ok()
.and_then(|a| a.get("type")?.as_str().map(|t| t == "Create"))
.unwrap_or(false)
})
.count();
assert!(
create_count >= 1,
"should have received Create after retries"
);
Ok(())
}
/// Test 5: Signed Like on a note → 202, note shows 1 like
#[tokio::test]
async fn like_is_recorded_on_note() -> 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");
// Alice posts a note
alice_client.post_form("/~alice/notes", &[("content", "test note")]);
// Get the note URL from the outbox
let outbox_response =
alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
let outbox_json: serde_json::Value =
serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
let first_url = outbox_json["first"]
.as_str()
.expect("outbox should have first page");
let page_response =
alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
let page_json: serde_json::Value =
serde_json::from_str(&page_response.body).expect("page should be valid JSON");
let note_url = page_json["orderedItems"][0]["object"]["id"]
.as_str()
.expect("note should have an id");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor likes the note
let like_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/like/1", recorder.actor_url),
"type": "Like",
"actor": recorder.actor_url,
"object": note_url
});
let body = serde_json::to_string(&like_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, "Like should be accepted");
// Check that the note's HTML shows 1 like
let profile_response = alice_client.get("/~alice");
assert!(
profile_response.body.contains("1 ❤️"),
"profile should show 1 like (heart emoji), got: {}",
profile_response.body
);
// Check that the note JSON is unchanged (counts not in Note document)
let note_response = alice_client.get_with(note_url, &[("Accept", "application/activity+json")]);
let note_json: serde_json::Value =
serde_json::from_str(¬e_response.body).expect("note should be valid JSON");
assert!(
!note_json.as_object().unwrap().contains_key("likes"),
"note JSON should not contain likes count"
);
Ok(())
}
/// Test 6: Signed EmojiReact with content: "🔥" → shows in the emoji list
#[tokio::test]
async fn emoji_react_is_recorded() -> 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");
// Alice posts a note
alice_client.post_form("/~alice/notes", &[("content", "test note")]);
// Get the note URL
let outbox_response =
alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
let outbox_json: serde_json::Value =
serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
let first_url = outbox_json["first"]
.as_str()
.expect("outbox should have first page");
let page_response =
alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
let page_json: serde_json::Value =
serde_json::from_str(&page_response.body).expect("page should be valid JSON");
let note_url = page_json["orderedItems"][0]["object"]["id"]
.as_str()
.expect("note should have an id");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor emoji reacts to the note
let react_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/react/1", recorder.actor_url),
"type": "EmojiReact",
"actor": recorder.actor_url,
"object": note_url,
"content": "🔥"
});
let body = serde_json::to_string(&react_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, "EmojiReact should be accepted");
// Check that the profile shows the emoji
let profile_response = alice_client.get("/~alice");
assert!(
profile_response.body.contains("🔥"),
"profile should show the emoji, got: {}",
profile_response.body
);
Ok(())
}
/// Test 7: Redelivered identical Like (same activity id) → still 1 (idempotent)
#[tokio::test]
async fn like_is_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");
// Alice posts a note
alice_client.post_form("/~alice/notes", &[("content", "test note")]);
// Get the note URL
let outbox_response =
alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
let outbox_json: serde_json::Value =
serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
let first_url = outbox_json["first"]
.as_str()
.expect("outbox should have first page");
let page_response =
alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
let page_json: serde_json::Value =
serde_json::from_str(&page_response.body).expect("page should be valid JSON");
let note_url = page_json["orderedItems"][0]["object"]["id"]
.as_str()
.expect("note should have an id");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor likes the note (first time)
let like_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/like/1", recorder.actor_url),
"type": "Like",
"actor": recorder.actor_url,
"object": note_url
});
let body = serde_json::to_string(&like_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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Remote actor likes the note (second time, same activity id)
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Check that the note's HTML still shows only 1 like
let profile_response = alice_client.get("/~alice");
assert!(
profile_response.body.contains("1 ❤️"),
"profile should show 1 like (idempotent, heart emoji), got: {}",
profile_response.body
);
Ok(())
}
/// Test 8: Signed Undo(Like) → like gone
#[tokio::test]
async fn undo_like_removes_reaction() -> 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");
// Alice posts a note
alice_client.post_form("/~alice/notes", &[("content", "test note")]);
// Get the note URL
let outbox_response =
alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
let outbox_json: serde_json::Value =
serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
let first_url = outbox_json["first"]
.as_str()
.expect("outbox should have first page");
let page_response =
alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
let page_json: serde_json::Value =
serde_json::from_str(&page_response.body).expect("page should be valid JSON");
let note_url = page_json["orderedItems"][0]["object"]["id"]
.as_str()
.expect("note should have an id");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor likes the note
let like_id = format!("{}/like/1", recorder.actor_url);
let like_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": like_id,
"type": "Like",
"actor": recorder.actor_url,
"object": note_url
});
let body = serde_json::to_string(&like_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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Verify the like is recorded
let profile_response = alice_client.get("/~alice");
assert!(
profile_response.body.contains("1 ❤️"),
"profile should show 1 like (heart emoji) after Like"
);
// Remote actor undoes the like
let undo_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/undo/1", recorder.actor_url),
"type": "Undo",
"actor": recorder.actor_url,
"object": {
"id": like_id,
"type": "Like"
}
});
let undo_body = serde_json::to_string(&undo_json).unwrap();
let (undo_signature, undo_date, undo_digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/inbox",
&undo_body,
);
let undo_response = alice_client.post_json_with_headers(
"/~alice/inbox",
&undo_body,
&[
("Signature", &undo_signature),
("Date", &undo_date),
("Digest", &undo_digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(undo_response.status, 202, "Undo should be accepted");
// Check that the like is gone
let profile_response = alice_client.get("/~alice");
assert!(
!profile_response.body.contains("❤️"),
"profile should not show heart emoji after Undo, got: {}",
profile_response.body
);
Ok(())
}
/// Test 9: Like whose object is not a local note → 202, nothing recorded
#[tokio::test]
async fn like_non_local_note_is_ignored() -> 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);
// Remote actor likes a non-local note
let like_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/like/1", recorder.actor_url),
"type": "Like",
"actor": recorder.actor_url,
"object": "http://example.com/note/123"
});
let body = serde_json::to_string(&like_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,
"Like should be accepted (but ignored)"
);
Ok(())
}
/// Test 10: Like signed by actor X but with actor field naming Y → 401
#[tokio::test]
async fn like_with_mismatched_actor_is_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");
// Alice posts a note
alice_client.post_form("/~alice/notes", &[("content", "test note")]);
// Get the note URL
let outbox_response =
alice_client.get_with("/~alice/outbox", &[("Accept", "application/activity+json")]);
let outbox_json: serde_json::Value =
serde_json::from_str(&outbox_response.body).expect("outbox should be valid JSON");
let first_url = outbox_json["first"]
.as_str()
.expect("outbox should have first page");
let page_response =
alice_client.get_with(first_url, &[("Accept", "application/activity+json")]);
let page_json: serde_json::Value =
serde_json::from_str(&page_response.body).expect("page should be valid JSON");
let note_url = page_json["orderedItems"][0]["object"]["id"]
.as_str()
.expect("note should have an id");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Remote actor likes the note but claims to be a different actor
let like_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/like/1", recorder.actor_url),
"type": "Like",
"actor": "http://example.com/actor/mallory",
"object": note_url
});
let body = serde_json::to_string(&like_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, 401,
"Like with mismatched actor should be rejected"
);
Ok(())
}
/// Test 11: Remote actor cache is populated with name and preferred_username
#[tokio::test]
async fn remote_actor_cache_populates_name() -> miette::Result<()> {
use frogue::entities::ap_remote_actors;
use sea_orm::{ColumnTrait, Database, EntityTrait, QueryFilter};
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);
// Bob follows alice by sending a Follow activity to her inbox
let follow_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/follow/1", recorder.actor_url),
"type": "Follow",
"actor": recorder.actor_url,
"object": format!("http://127.0.0.1:{port}/~alice")
});
let body = serde_json::to_string(&follow_json).unwrap();
let (signature, date, digest) = support::ap::signed_post(
&recorder.private_key_pem,
&recorder.key_id,
&format!("127.0.0.1:{port}"),
"/~alice/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,
"Follow should be accepted. Server log:"
);
// Wait for Accept(Follow) delivery to ensure the remote actor was resolved
let deadline = Instant::now() + Duration::from_secs(10);
let mut found_accept = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
let deliveries = recorder.recorded_deliveries();
for delivery in &deliveries {
if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
if activity.get("type").and_then(|t| t.as_str()) == Some("Accept") {
if let Some(obj) = activity.get("object") {
if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
found_accept = true;
break;
}
}
}
}
}
if found_accept {
break;
}
}
assert!(
found_accept,
"should have received Accept(Follow) within deadline"
);
// Connect to the database and verify the remote actor was cached with name
// and preferred_username
let db = Database::connect(server.database_url())
.await
.expect("should connect to database");
let cached_actor = ap_remote_actors::Entity::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(&recorder.actor_url))
.one(&db)
.await
.expect("should query remote actors");
assert!(
cached_actor.is_some(),
"remote actor should be cached in ap_remote_actors"
);
let cached = cached_actor.unwrap();
assert_eq!(
cached.preferred_username,
Some("remote".to_string()),
"preferred_username should be populated"
);
assert_eq!(
cached.name,
Some("Remote Test User".to_string()),
"name should be populated"
);
Ok(())
}