// 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(¬e_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(())
}