22054c427463519346462843ea625a242a2e8124 / frogue / tests / remote_notes.rs
Raw
//! Phase 3 tests: Receive and store remote notes from followed 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: Create(Note) from a followed actor is stored
#[tokio::test]
async fn create_note_from_followed_actor_is_stored() -> 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 (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) to mark the follow as accepted
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Now send a Create(Note) activity
let note_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/note1", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": format!("{}/notes/note1", recorder.actor_url),
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Hello from remote!</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(¬e_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,
"Create(Note) should be accepted. Server log:"
);
Ok(())
}
/// Test: Create(Note) with XSS content is sanitized
#[tokio::test]
async fn create_note_xss_is_sanitized() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept (same as above test, abbreviated)
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send a Create(Note) with XSS payload
let note_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/note2", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": format!("{}/notes/note2", recorder.actor_url),
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<script>alert('xss')</script><p>Safe content</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(¬e_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,
"Create(Note) with XSS should be accepted. Server log:"
);
// We can't directly query the DB, but the test verifies the endpoint accepts it
// The actual sanitization is tested in the markdown module
Ok(())
}
/// Test: Create(Note) from a non-followed actor is dropped
#[tokio::test]
async fn create_note_from_non_followed_actor_is_dropped() -> 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 but don't follow them
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Don't follow (the discover endpoint was removed, so we just don't follow)
// Send a Create(Note) activity
let note_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/note3", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": format!("{}/notes/note3", recorder.actor_url),
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Hello from non-followed actor!</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(¬e_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"),
],
);
// Should still return 202 (accepted) but the note should be dropped
assert_eq!(
response.status, 202,
"Create(Note) from non-followed actor should be accepted but dropped. Server log:"
);
Ok(())
}
/// Test: Duplicate Create(Note) is idempotent
#[tokio::test]
async fn create_note_duplicate_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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send the same Create(Note) twice
let note_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/note4", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": format!("{}/notes/note4", recorder.actor_url),
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Duplicate note</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(¬e_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 response1 = alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(response1.status, 202, "first Create should be accepted");
let response2 = alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
assert_eq!(
response2.status, 202,
"duplicate Create should be accepted (idempotent)"
);
Ok(())
}
/// Test: Update(Note) replaces content_html and re-sanitizes
#[tokio::test]
async fn update_note_replaces_content() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send initial Create(Note) activity
let note_object_url = format!("{}/notes/update_test", recorder.actor_url);
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_update_test", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Original content</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(&create_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, "Create should be accepted");
// Wait a bit for processing
std::thread::sleep(Duration::from_millis(100));
// Verify the note appears in the feed
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("Original content"),
"feed should contain original content"
);
// Send Update(Note) activity
let update_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/update_test", recorder.actor_url),
"type": "Update",
"actor": recorder.actor_url,
"object": {
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Updated content</p>",
"published": "2026-11-01T13:00:00Z"
}
});
let body = serde_json::to_string(&update_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, "Update should be accepted");
// Wait a bit for processing
std::thread::sleep(Duration::from_millis(100));
// Verify the updated content appears in the feed
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("Updated content"),
"feed should contain updated content, got:\n{}",
feed_response.body
);
assert!(
!feed_response.body.contains("Original content"),
"feed should not contain original content after update"
);
Ok(())
}
/// Test: Update(Note) with XSS content is sanitized
#[tokio::test]
async fn update_note_xss_is_sanitized() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept (abbreviated)
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send initial Create(Note)
let note_object_url = format!("{}/notes/xss_update_test", recorder.actor_url);
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_xss_update", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Safe content</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(&create_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"),
],
);
// Send Update(Note) with XSS payload
let update_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/update_xss", recorder.actor_url),
"type": "Update",
"actor": recorder.actor_url,
"object": {
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<script>alert('xss')</script><p>Updated safe content</p>",
"published": "2026-11-01T13:00:00Z"
}
});
let body = serde_json::to_string(&update_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, "Update with XSS should be accepted");
// Verify the sanitized content appears (script tag should be removed)
std::thread::sleep(Duration::from_millis(100));
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("Updated safe content"),
"feed should contain sanitized updated content"
);
assert!(
!feed_response.body.contains("<script>"),
"feed should not contain script tags after sanitization"
);
Ok(())
}
/// Test: Delete(Note) removes the note
#[tokio::test]
async fn delete_note_removes_it() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept (abbreviated)
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send Create(Note) activity
let note_object_url = format!("{}/notes/delete_test", recorder.actor_url);
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_delete_test", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Note to be deleted</p>",
"published": "2026-11-01T12:00:00Z"
}
});
let body = serde_json::to_string(&create_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, "Create should be accepted");
// Wait and verify the note appears
std::thread::sleep(Duration::from_millis(100));
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("Note to be deleted"),
"feed should contain the note before deletion"
);
// Send Delete(Note) activity
let delete_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/delete_test", recorder.actor_url),
"type": "Delete",
"actor": recorder.actor_url,
"object": note_object_url
});
let body = serde_json::to_string(&delete_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, "Delete should be accepted");
// Wait and verify the note is gone
std::thread::sleep(Duration::from_millis(100));
let feed_response = alice_client.get("/");
assert!(
!feed_response.body.contains("Note to be deleted"),
"feed should not contain the note after deletion"
);
Ok(())
}
/// Test: Delete of unknown object returns 202
#[tokio::test]
async fn delete_unknown_object_returns_202() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept (abbreviated)
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send Delete for unknown note
let unknown_note_url = format!("{}/notes/unknown_note", recorder.actor_url);
let delete_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/delete_unknown", recorder.actor_url),
"type": "Delete",
"actor": recorder.actor_url,
"object": unknown_note_url
});
let body = serde_json::to_string(&delete_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,
"Delete of unknown object should return 202 (no error)"
);
Ok(())
}
/// Test: Create with URL-only object fetches the note via signed GET
#[tokio::test]
async fn create_with_url_only_object_is_fetched() -> 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 that will also serve the note
let recorder_port = free_port();
let recorder = RemoteActor::serve_with_note(recorder_port);
// Follow the remote actor (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Set up the note to be served at a URL
let note_object_url = format!("{}/notes/url_only_test", recorder.actor_url);
let note_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": note_object_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Note fetched from URL</p>",
"published": "2026-11-01T14:00:00Z"
});
// Extract the path from the URL for the mock server
let note_path = url::Url::parse(¬e_object_url)
.unwrap()
.path()
.to_string();
recorder.serve_note_at_url(¬e_path, note_json);
// Send Create activity with URL-only object
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_url_test", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": note_object_url
});
let body = serde_json::to_string(&create_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,
"Create with URL-only object should be accepted"
);
// Wait a bit for the fetch to complete
std::thread::sleep(Duration::from_millis(200));
// Verify the note appears in the feed
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("Note fetched from URL"),
"feed should contain the fetched note content"
);
// Verify the GET request had a Signature header
let get_requests = recorder.recorded_get_requests();
assert!(
get_requests
.iter()
.any(|req| { req.path.contains("/notes/url_only_test") && req.has_signature }),
"should have made a signed GET request for the note URL"
);
Ok(())
}
/// Test: Create with URL-only object that is not a Note is dropped
#[tokio::test]
async fn create_with_url_only_non_note_is_dropped() -> 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_with_note(recorder_port);
// Follow the remote actor
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept (abbreviated)
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Set up a non-Note object to be served
let article_url = format!("{}/articles/not_a_note", recorder.actor_url);
let article_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": article_url,
"type": "Article",
"attributedTo": recorder.actor_url,
"content": "This is an article, not a note"
});
// Extract the path from the URL for the mock server
let article_path = url::Url::parse(&article_url).unwrap().path().to_string();
recorder.serve_note_at_url(&article_path, article_json);
// Send Create activity with URL pointing to non-Note
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_article_test", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": article_url
});
let body = serde_json::to_string(&create_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,
"Create should be accepted even for non-Note"
);
// Wait a bit for processing
std::thread::sleep(Duration::from_millis(200));
// Verify the non-Note does not appear in the feed
let feed_response = alice_client.get("/");
assert!(
!feed_response
.body
.contains("This is an article, not a note"),
"feed should not contain non-Note object"
);
Ok(())
}
/// Test: Announce with embedded Note from a followee stores the note
#[tokio::test]
async fn announce_with_embedded_note_from_followee_is_stored() -> 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 (one-step flow)
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// Send Announce with embedded Note
let note_url = format!("{}/notes/boosted_note", recorder.actor_url);
let announce_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/announce1", recorder.actor_url),
"type": "Announce",
"actor": recorder.actor_url,
"object": {
"id": note_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>This is a boosted note!</p>",
"published": "2026-11-01T14:00:00Z"
}
});
let body = serde_json::to_string(&announce_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, "Announce should be accepted");
// Wait for processing
std::thread::sleep(Duration::from_millis(200));
// Verify the note appears in the feed
let feed_response = alice_client.get("/");
assert!(
feed_response.body.contains("This is a boosted note!"),
"feed should contain the boosted note"
);
Ok(())
}
/// Test: Announce from a non-followee is dropped
#[tokio::test]
async fn announce_from_non_followee_is_dropped() -> 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 (but don't follow)
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Send Announce without following
let note_url = format!("{}/notes/unfollowed_note", recorder.actor_url);
let announce_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/announce_unfollowed", recorder.actor_url),
"type": "Announce",
"actor": recorder.actor_url,
"object": {
"id": note_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>This should not be stored</p>",
"published": "2026-11-01T15:00:00Z"
}
});
let body = serde_json::to_string(&announce_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,
"Announce should be accepted (but dropped)"
);
// Wait for processing
std::thread::sleep(Duration::from_millis(200));
// Verify the note does NOT appear in the feed
let feed_response = alice_client.get("/");
assert!(
!feed_response.body.contains("This should not be stored"),
"feed should not contain note from non-followee"
);
Ok(())
}
/// Test: Announce of an already-stored note is idempotent
#[tokio::test]
async fn announce_of_already_stored_note_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");
// Start a remote actor
let recorder_port = free_port();
let recorder = RemoteActor::serve(recorder_port);
// Follow the remote actor
let handle = format!("remote@127.0.0.1:{recorder_port}");
alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
// Wait for Follow and Accept
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");
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,
);
alice_client.post_json_with_headers(
"/~alice/inbox",
&body,
&[
("Signature", &signature),
("Date", &date),
("Digest", &digest),
("Content-Type", "application/activity+json"),
],
);
// First, send a Create to store the note
let note_url = format!("{}/notes/idempotent_note", recorder.actor_url);
let create_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/create_idempotent", recorder.actor_url),
"type": "Create",
"actor": recorder.actor_url,
"object": {
"id": note_url,
"type": "Note",
"attributedTo": recorder.actor_url,
"content": "<p>Original note content</p>",
"published": "2026-11-01T16:00:00Z"
}
});
let body = serde_json::to_string(&create_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"),
],
);
std::thread::sleep(Duration::from_millis(100));
// Now send an Announce for the same note (should be idempotent)
let announce_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activities/announce_idempotent", recorder.actor_url),
"type": "Announce",
"actor": recorder.actor_url,
"object": note_url
});
let body = serde_json::to_string(&announce_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, "Announce should be accepted");
std::thread::sleep(Duration::from_millis(100));
// Verify the note still appears only once in the feed
let feed_response = alice_client.get("/");
let count = feed_response.body.matches("Original note content").count();
assert_eq!(count, 1, "note should appear exactly once in feed");
Ok(())
}