🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Phase 2 tests: Follow/unfollow remote actors

mod support;

use std::time::{Duration, Instant};

use support::ap::RemoteActor;
use support::{Client, Server, free_port};

/// Register a user through the web form.
fn register(client: &mut Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[
            ("name", name),
            ("email", &email),
            ("password", "correct horse battery staple"),
        ],
    );
    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Test: Follow a remote actor sends Follow activity and creates pending row
#[tokio::test]
async fn follow_sends_activity() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor with handle directly (one-step flow)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(
        follow_response.status, 303,
        "follow should redirect\n{}",
        follow_response.body
    );

    // Wait for Follow activity delivery
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut found_follow = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));

        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    if let Some(obj) = activity.get("object") {
                        if obj.as_str() == Some(&recorder.actor_url) {
                            found_follow = true;
                            break;
                        }
                    }
                }
            }
        }
        if found_follow {
            break;
        }
    }

    assert!(
        found_follow,
        "should have received Follow activity within deadline"
    );

    Ok(())
}

/// Test: Receiving Accept(Follow) marks the follow as accepted
#[tokio::test]
async fn accept_marks_follow_accepted() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow with handle directly (one-step flow)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    alice_client.post_form("/~alice/follow", &[("handle", &handle)]);

    // Wait for Follow delivery
    let deadline = Instant::now() + Duration::from_secs(5);
    let mut follow_activity_id = None;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    follow_activity_id = activity
                        .get("id")
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string());
                    break;
                }
            }
        }
        if follow_activity_id.is_some() {
            break;
        }
    }

    let follow_id = follow_activity_id.expect("should have received Follow activity");

    // Send Accept(Follow) from the remote actor to alice's inbox
    let accept_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/accept/1", recorder.actor_url),
        "type": "Accept",
        "actor": recorder.actor_url,
        "object": {
            "id": follow_id,
            "type": "Follow",
            "actor": format!("http://127.0.0.1:{port}/~alice"),
            "object": recorder.actor_url
        }
    });
    let body = serde_json::to_string(&accept_json).unwrap();

    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );

    let response = alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    assert_eq!(
        response.status, 202,
        "Accept should be accepted. Server log:"
    );

    // The follow should now be marked as accepted in the database
    // We can't directly query the DB from here, but we can verify by checking
    // that the UI shows the follow as accepted (if we add such a check)
    // For now, we just verify the Accept was processed without error

    Ok(())
}

/// Test: Receiving Reject(Follow) marks the follow as rejected
#[tokio::test]
async fn reject_marks_follow_rejected() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow with handle directly (one-step flow)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    alice_client.post_form("/~alice/follow", &[("handle", &handle)]);

    // Wait for Follow delivery
    let deadline = Instant::now() + Duration::from_secs(5);
    let mut follow_activity_id = None;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    follow_activity_id = activity
                        .get("id")
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string());
                    break;
                }
            }
        }
        if follow_activity_id.is_some() {
            break;
        }
    }

    let follow_id = follow_activity_id.expect("should have received Follow activity");

    // Send Reject(Follow) from the remote actor
    let reject_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/reject/1", recorder.actor_url),
        "type": "Reject",
        "actor": recorder.actor_url,
        "object": {
            "id": follow_id,
            "type": "Follow",
            "actor": format!("http://127.0.0.1:{port}/~alice"),
            "object": recorder.actor_url
        }
    });
    let body = serde_json::to_string(&reject_json).unwrap();

    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );

    let response = alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    assert_eq!(
        response.status, 202,
        "Reject should be accepted. Server log:"
    );

    Ok(())
}

/// Test: Unfollow sends Undo(Follow) and removes the row
#[tokio::test]
async fn unfollow_sends_undo() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow with handle directly (one-step flow)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    alice_client.post_form("/~alice/follow", &[("handle", &handle)]);

    // Wait for Follow delivery
    let deadline = Instant::now() + Duration::from_secs(5);
    let mut found_follow = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    found_follow = true;
                    break;
                }
            }
        }
        if found_follow {
            break;
        }
    }
    assert!(found_follow, "should have received Follow activity");

    // Unfollow
    let unfollow_response =
        alice_client.post_form("/~alice/unfollow", &[("actor_url", &recorder.actor_url)]);
    assert_eq!(
        unfollow_response.status, 303,
        "unfollow should redirect\n{}",
        unfollow_response.body
    );

    // Wait for Undo(Follow) delivery
    let deadline = Instant::now() + Duration::from_secs(5);
    let mut found_undo = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Undo") {
                    if let Some(obj) = activity.get("object") {
                        if obj.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                            found_undo = true;
                            break;
                        }
                    }
                }
            }
        }
        if found_undo {
            break;
        }
    }

    assert!(
        found_undo,
        "should have received Undo(Follow) activity within deadline"
    );

    Ok(())
}

/// Test: Duplicate follow is idempotent
#[tokio::test]
async fn duplicate_follow_idempotent() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let _recorder = RemoteActor::serve(recorder_port);

    // Follow with handle directly (one-step flow)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    let follow1 = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(follow1.status, 303, "first follow should succeed");

    // Follow again with the same handle
    let follow2 = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(
        follow2.status, 303,
        "duplicate follow should redirect (idempotent)"
    );

    Ok(())
}

/// Test: Follow with invalid handle fails
#[tokio::test]
async fn follow_without_discovery_fails() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Try to follow with an invalid handle format (missing @)
    let follow_response = alice_client.post_form("/~alice/follow", &[("handle", "invalidhandle")]);
    assert_eq!(
        follow_response.status, 303,
        "follow should redirect with error"
    );
    assert!(
        follow_response
            .location
            .as_ref()
            .unwrap()
            .contains("error="),
        "redirect should include error parameter"
    );

    Ok(())
}

/// Test: Follow endpoint returns 404 when federation is disabled
#[tokio::test]
async fn follow_disabled_when_federation_off() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Try to follow (should 404 since federation is disabled)
    let follow_response = alice_client.post_form(
        "/~alice/follow",
        &[("actor_url", "http://example.com/actor")],
    );
    assert_eq!(
        follow_response.status, 404,
        "follow should return 404 when federation disabled"
    );

    Ok(())
}

/// Test: Profile page shows follow form for owner
#[tokio::test]
async fn profile_shows_follow_form_for_owner() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // View own profile
    let response = alice_client.get("/~alice");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("Follow a remote user"),
        "profile should show follow form for owner, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("name=\"handle\""),
        "profile should have handle input, got:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Profile page does not show follow form for non-owner
#[tokio::test]
async fn profile_no_follow_form_for_non_owner() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    let mut bob_client = Client::new(port);
    register(&mut bob_client, "bob");

    // View Alice's profile as Bob
    let response = bob_client.get("/~alice");
    assert_eq!(response.status, 200);
    assert!(
        !response.body.contains("Follow a remote user"),
        "profile should not show follow form for non-owner, got:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Follow with handle in one step
#[tokio::test]
async fn follow_with_handle_one_step() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow with handle directly (no separate discover step)
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(
        follow_response.status, 303,
        "follow with handle should redirect\n{}",
        follow_response.body
    );
    assert!(
        follow_response.location.is_some(),
        "should redirect with Location header"
    );
    assert!(
        follow_response
            .location
            .as_ref()
            .unwrap()
            .contains("notice="),
        "redirect should include notice parameter"
    );

    // Wait for Follow activity delivery
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut found_follow = false;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));

        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    if let Some(obj) = activity.get("object") {
                        if obj.as_str() == Some(&recorder.actor_url) {
                            found_follow = true;
                            break;
                        }
                    }
                }
            }
        }
        if found_follow {
            break;
        }
    }

    assert!(
        found_follow,
        "should have received Follow activity within deadline"
    );

    Ok(())
}

/// Test: Follow with unknown handle redirects with error
#[tokio::test]
async fn follow_with_unknown_handle_errors() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Try to follow with an unknown handle
    let handle = format!("nonexistent@127.0.0.1:{recorder_port}");
    let follow_response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
    assert_eq!(
        follow_response.status, 303,
        "follow should redirect with error\n{}",
        follow_response.body
    );
    assert!(
        follow_response
            .location
            .as_ref()
            .unwrap()
            .contains("error="),
        "redirect should include error parameter, got location: {:?}",
        follow_response.location
    );

    // Verify no Follow delivery
    std::thread::sleep(Duration::from_secs(1));
    let deliveries = recorder.recorded_deliveries();
    let follow_count = deliveries
        .iter()
        .filter(|d| {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&d.body) {
                activity.get("type").and_then(|t| t.as_str()) == Some("Follow")
            } else {
                false
            }
        })
        .count();
    assert_eq!(follow_count, 0, "should not have sent Follow activity");

    Ok(())
}

/// Test: Profile page shows following list after following
#[tokio::test]
async fn profile_shows_following_list() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // Start a remote actor
    let recorder_port = free_port();
    let _recorder = RemoteActor::serve(recorder_port);

    // Follow with handle
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    alice_client.post_form("/~alice/follow", &[("handle", &handle)]);

    // Wait a bit for the follow to be recorded
    std::thread::sleep(Duration::from_millis(500));

    // View profile
    let response = alice_client.get("/~alice");
    assert_eq!(response.status, 200);
    assert!(
        response.body.contains("Following"),
        "profile should show Following section, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("pending") || response.body.contains("accepted"),
        "profile should show follow state, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("Unfollow"),
        "profile should show unfollow button, got:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Profile page does not show following form when federation is disabled
#[tokio::test]
async fn profile_no_follow_form_when_federation_off() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    // View own profile
    let response = alice_client.get("/~alice");
    assert_eq!(response.status, 200);
    assert!(
        !response.body.contains("Follow a remote user"),
        "profile should not show follow form when federation disabled, got:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Profile page shows react buttons on notes
#[tokio::test]
async fn profile_shows_react_buttons_on_notes() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    let mut bob_client = Client::new(port);
    register(&mut bob_client, "bob");

    // Alice posts a note
    let response = alice_client.post_form("/~alice/notes", &[("content", "Alice's test note")]);
    assert_eq!(response.status, 303, "posting a note should redirect");

    // Bob views Alice's profile - should see react button
    let response = bob_client.get("/~alice");
    assert_eq!(response.status, 200, "profile should load");
    assert!(
        response.body.contains("Alice's test note"),
        "profile should contain the note, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("❤️ React"),
        "profile should show React button, got:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Profile page shows Unreact button after reacting
#[tokio::test]
async fn profile_shows_unreact_button_after_reacting() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut alice_client = Client::new(port);
    register(&mut alice_client, "alice");

    let mut bob_client = Client::new(port);
    register(&mut bob_client, "bob");

    // Alice posts a note
    let response =
        alice_client.post_form("/~alice/notes", &[("content", "Alice's react test note")]);
    assert_eq!(response.status, 303, "posting a note should redirect");

    // Bob views Alice's profile and extracts the note UUID
    let response = bob_client.get("/~alice");
    assert_eq!(response.status, 200, "profile should load");

    // Extract note UUID from the note link (format: /~alice/notes/{uuid})
    let note_uuid = extract_note_uuid_from_profile(&response.body, "Alice's react test note")
        .expect("should find note UUID in profile");

    // Bob reacts to Alice's note
    let react_response = bob_client.post_form(&format!("/~alice/notes/{note_uuid}/react"), &[]);
    assert_eq!(react_response.status, 303, "reacting should redirect");

    // Bob views Alice's profile again - should see Unreact button
    let response = bob_client.get("/~alice");
    assert_eq!(response.status, 200, "profile should load");
    assert!(
        response.body.contains("Alice's react test note"),
        "profile should contain the note, got:\n{}",
        response.body
    );
    assert!(
        response.body.contains("❤️ Unreact"),
        "profile should show Unreact button after reacting, got:\n{}",
        response.body
    );

    Ok(())
}

/// Helper to extract note UUID from profile HTML
fn extract_note_uuid_from_profile(html: &str, note_content: &str) -> Option<String> {
    // Look for the note content, then find the associated UUID
    // The note is in a <li> with data-note-uuid attribute
    let search = "data-note-uuid=\"";
    let content_pos = html.find(note_content)?;

    // Search backwards for the data-note-uuid attribute
    let before_content = &html[..content_pos];
    let uuid_attr_pos = before_content.rfind(search)?;
    let after_attr = &html[uuid_attr_pos + search.len()..];
    let uuid_end = after_attr.find('"')?;
    Some(after_attr[..uuid_end].to_string())
}