🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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(&note_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(())
}