🐸 Frogue(Fed)

~ololduck/Frogue

Raw

// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! Phase 4.1 tests: outbound moderation reports
//!
//! Tests for:
//! - Local user reports remote note → creates report row, delivers Flag
//!   activity (with forward)
//! - Local user reports remote note → creates report row, no delivery (without
//!   forward)
//! - Local user reports local user → creates report row, no federation
//! - Anonymous users cannot submit reports

mod support;

use miette::IntoDiagnostic;
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter};
use support::{Server, free_port};

const PASSWORD: &str = "correct horse battery staple";

/// Registers a user through the form, the way the browser would.
fn register(client: &mut support::Client, name: &str, email: &str) {
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", email), ("password", PASSWORD)],
    );
    assert_eq!(
        response.status, 303,
        "registering {} should redirect on success",
        name
    );
}

/// Logs in with the given credentials.
fn login(client: &mut support::Client, name: &str, password: &str) {
    let response = client.post_form("/login", &[("name", name), ("password", password)]);
    assert_eq!(
        response.status, 303,
        "logging in as {} should redirect on success",
        name
    );
}

// ============================================================================
// Outbound report tests
// ============================================================================

#[tokio::test]
async fn report_remote_note_with_forward_delivers_flag() -> miette::Result<()> {
    use frogue::entities::reports;
    use support::ap::RemoteActor;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a reporter
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    login(&mut client, "alice", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create a remote actor to report
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Pre-populate the remote actor in the database
    let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: sea_orm::Set(remote_actor.actor_url.clone()),
        inbox_url: sea_orm::Set(remote_actor.inbox_url.clone()),
        shared_inbox_url: sea_orm::Set(None),
        public_key_pem: sea_orm::Set(remote_actor.public_key_pem.clone()),
        public_key_id: sea_orm::Set(remote_actor.key_id.clone()),
        name: sea_orm::Set(Some("Remote User".to_string())),
        preferred_username: sea_orm::Set(Some("remote".to_string())),
        icon_url: sea_orm::Set(None),
        fetched_at: sea_orm::Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    remote_actor_model.insert(&db).await.into_diagnostic()?;

    // Create a note URL to report
    let note_url = format!("{}/notes/abc123", remote_actor.actor_url);

    // Submit a report with forward=on
    let response = client.post_form(
        "/report",
        &[
            ("target_url", note_url.as_str()),
            ("comment", "This is spam"),
            ("forward", "on"),
        ],
    );
    assert_eq!(
        response.status, 303,
        "submitting report should redirect on success"
    );

    // Verify the report row exists
    let report = reports::Entity::find()
        .filter(reports::Column::ReporterLocalUserId.is_not_null())
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("report should exist");

    assert_eq!(report.inbound, 0, "report should be outbound");
    assert_eq!(report.forwarded, 1, "report should be marked as forwarded");
    assert!(
        report.comment.unwrap().contains("spam"),
        "report should contain the comment"
    );

    // Verify the Flag activity was delivered to the remote actor
    // Poll for delivery with a deadline (up to 5 seconds)
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    let mut flag_delivery = None;
    while std::time::Instant::now() < deadline {
        let deliveries = remote_actor.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("Flag") {
                    flag_delivery = Some(delivery);
                    break;
                }
            }
        }
        if flag_delivery.is_some() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }

    let delivery = flag_delivery.expect("should have received Flag activity delivery");
    let activity: serde_json::Value =
        serde_json::from_str(&delivery.body).expect("delivery body should be valid JSON");

    assert_eq!(activity["type"], "Flag", "should be a Flag activity");
    assert!(
        activity["actor"].as_str().unwrap().contains("/~alice"),
        "actor should be alice"
    );

    // object should be an array (Mastodon convention)
    let object_array = activity["object"]
        .as_array()
        .expect("object should be an array");
    assert!(
        object_array
            .iter()
            .any(|obj| { obj.as_str().map(|s| s.contains(&note_url)).unwrap_or(false) }),
        "object array should contain the reported note URL"
    );

    Ok(())
}

#[tokio::test]
async fn report_remote_note_without_forward_no_delivery() -> miette::Result<()> {
    use frogue::entities::reports;
    use support::ap::RemoteActor;

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register admin and a reporter
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "bob", "bob@example.com");
    login(&mut client, "bob", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Create a remote actor to report
    let remote_port = free_port();
    let remote_actor = RemoteActor::serve(remote_port);

    // Pre-populate the remote actor in the database
    let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
        actor_url: sea_orm::Set(remote_actor.actor_url.clone()),
        inbox_url: sea_orm::Set(remote_actor.inbox_url.clone()),
        shared_inbox_url: sea_orm::Set(None),
        public_key_pem: sea_orm::Set(remote_actor.public_key_pem.clone()),
        public_key_id: sea_orm::Set(remote_actor.key_id.clone()),
        name: sea_orm::Set(Some("Remote User".to_string())),
        preferred_username: sea_orm::Set(Some("remote".to_string())),
        icon_url: sea_orm::Set(None),
        fetched_at: sea_orm::Set(chrono::Utc::now().into()),
        ..Default::default()
    };
    remote_actor_model.insert(&db).await.into_diagnostic()?;

    // Create a note URL to report
    let note_url = format!("{}/notes/def456", remote_actor.actor_url);

    // Submit a report WITHOUT forward
    let response = client.post_form(
        "/report",
        &[
            ("target_url", note_url.as_str()),
            ("comment", "Suspicious content"),
        ],
    );
    assert_eq!(
        response.status, 303,
        "submitting report should redirect on success"
    );

    // Verify the report row exists
    let report = reports::Entity::find()
        .filter(reports::Column::ReporterLocalUserId.is_not_null())
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("report should exist");

    assert_eq!(report.inbound, 0, "report should be outbound");
    assert_eq!(
        report.forwarded, 0,
        "report should NOT be marked as forwarded"
    );

    // Verify NO delivery occurred
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    let deliveries = remote_actor.recorded_deliveries();
    assert_eq!(
        deliveries.len(),
        0,
        "should NOT have delivered Flag activity when forward is not checked"
    );

    Ok(())
}

#[tokio::test]
async fn report_local_user_no_federation() -> miette::Result<()> {
    use frogue::entities::{reports, users};

    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    let mut client = support::Client::new(port);

    // Register users
    register(&mut client, "admin", "admin@example.com");
    register(&mut client, "alice", "alice@example.com");
    register(&mut client, "bob", "bob@example.com");
    login(&mut client, "alice", PASSWORD);

    let db = Database::connect(server.database_url())
        .await
        .into_diagnostic()?;

    // Get bob's actor URL (local user)
    let bob_user = users::Entity::find()
        .filter(users::Column::Name.eq("bob"))
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("bob should exist");

    let bob_actor_url = format!(
        "{}/~bob",
        server.public_url().expect("server should have public URL")
    );

    // Submit a report about bob (local user)
    let response = client.post_form(
        "/report",
        &[
            ("target_url", bob_actor_url.as_str()),
            ("comment", "Bob is being problematic"),
            ("forward", "on"), // Even with forward, local targets don't federate
        ],
    );
    assert_eq!(
        response.status, 303,
        "submitting report should redirect on success"
    );

    // Verify the report row exists
    let report = reports::Entity::find()
        .filter(reports::Column::ReporterLocalUserId.is_not_null())
        .one(&db)
        .await
        .into_diagnostic()?
        .expect("report should exist");

    assert_eq!(report.inbound, 0, "report should be outbound");
    assert_eq!(
        report.target_local_user_id,
        Some(bob_user.id),
        "report should target bob"
    );
    assert_eq!(report.forwarded, 0, "local reports should not be forwarded");

    Ok(())
}

#[tokio::test]
async fn anonymous_cannot_report() -> 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 client = support::Client::new(port);

    // Try to submit a report without logging in
    let response = client.post_form(
        "/report",
        &[
            ("target_url", "http://example.com/note/123"),
            ("comment", "Test"),
        ],
    );

    // Should redirect to login or return 401
    assert!(
        response.status == 303 || response.status == 401,
        "anonymous users should not be able to submit reports"
    );

    Ok(())
}