// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Phase 2 tests: admin WebUI for moderation
//!
//! Tests for:
//! - Access control (401 anonymous, 403 non-admin, 200 admin)
//! - User management (list, delete with cascade)
//! - Report queue (list, resolve/dismiss, shortcuts)
//! - Federation controls (domain blocks, remote user blocks)
mod support;
use frogue::entities::pending_deletions;
use miette::IntoDiagnostic;
use sea_orm::{ColumnTrait, Database, EntityTrait, PaginatorTrait, 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
);
assert!(
response.sets_cookie("frogue_session"),
"registering {} should start a session\n{:?}",
name,
response.set_cookies()
);
}
/// 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
);
assert!(
response.sets_cookie("frogue_session"),
"logging in as {} should start a session\n{:?}",
name,
response.set_cookies()
);
}
// ============================================================================
// Access control tests
// ============================================================================
#[tokio::test]
async fn anonymous_gets_401_on_admin_pages() -> 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);
// Test all admin endpoints
let response = client.get("/admin");
assert_eq!(response.status, 401, "anonymous should get 401 on /admin");
let response = client.get("/admin/users");
assert_eq!(
response.status, 401,
"anonymous should get 401 on /admin/users"
);
let response = client.get("/admin/reports");
assert_eq!(
response.status, 401,
"anonymous should get 401 on /admin/reports"
);
let response = client.get("/admin/federation");
assert_eq!(
response.status, 401,
"anonymous should get 401 on /admin/federation"
);
Ok(())
}
#[tokio::test]
async fn non_admin_gets_403_on_admin_pages() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
// Register admin first (user_id = 1)
let mut admin_client = support::Client::new(port);
register(&mut admin_client, "admin", "admin@example.com");
login(&mut admin_client, "admin", PASSWORD);
// Register a non-admin user (user_id = 2)
let mut client = support::Client::new(port);
register(&mut client, "alice", "alice@example.com");
login(&mut client, "alice", PASSWORD);
// Test all admin endpoints
let response = client.get("/admin");
assert_eq!(response.status, 403, "non-admin should get 403 on /admin");
let response = client.get("/admin/users");
assert_eq!(
response.status, 403,
"non-admin should get 403 on /admin/users"
);
let response = client.get("/admin/reports");
assert_eq!(
response.status, 403,
"non-admin should get 403 on /admin/reports"
);
let response = client.get("/admin/federation");
assert_eq!(
response.status, 403,
"non-admin should get 403 on /admin/federation"
);
Ok(())
}
#[tokio::test]
async fn admin_gets_200_on_admin_pages() -> 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);
// Register admin user (user_id will be 1)
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Test all admin endpoints
let response = client.get("/admin");
assert_eq!(response.status, 200, "admin should get 200 on /admin");
let response = client.get("/admin/users");
assert_eq!(response.status, 200, "admin should get 200 on /admin/users");
let response = client.get("/admin/reports");
assert_eq!(
response.status, 200,
"admin should get 200 on /admin/reports"
);
let response = client.get("/admin/federation");
assert_eq!(
response.status, 200,
"admin should get 200 on /admin/federation"
);
Ok(())
}
// ============================================================================
// User management tests
// ============================================================================
#[tokio::test]
async fn admin_can_list_users() -> 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);
// Register admin and some regular users
register(&mut client, "admin", "admin@example.com");
register(&mut client, "alice", "alice@example.com");
register(&mut client, "bob", "bob@example.com");
// Login as admin
login(&mut client, "admin", PASSWORD);
// Get users list
let response = client.get("/admin/users");
assert_eq!(response.status, 200);
assert!(response.body.contains("admin"), "should list admin user");
assert!(response.body.contains("alice"), "should list alice user");
assert!(response.body.contains("bob"), "should list bob user");
Ok(())
}
#[tokio::test]
async fn admin_cannot_delete_themselves() -> 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);
// Register admin (user_id = 1)
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Try to delete user 1 (admin)
let response = client.post_form("/admin/users/1/delete", &[]);
assert_eq!(response.status, 400, "admin cannot delete themselves");
Ok(())
}
#[tokio::test]
async fn admin_can_delete_user() -> 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);
// Register admin and a regular user
register(&mut client, "admin", "admin@example.com");
register(&mut client, "alice", "alice@example.com");
login(&mut client, "admin", PASSWORD);
// Delete user alice (user_id = 2)
let response = client.post_form("/admin/users/2/delete", &[]);
assert_eq!(
response.status, 302,
"should redirect after scheduling deletion"
);
// Verify alice is still in the list (deletion is scheduled, not immediate)
let response = client.get("/admin/users");
assert_eq!(response.status, 200);
assert!(
response.body.contains("alice"),
"alice should still be listed after scheduling deletion"
);
// Verify a pending deletion was created by checking the database directly
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let count = pending_deletions::Entity::find()
.filter(pending_deletions::Column::UserId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(count, 1, "should have created a pending deletion record");
Ok(())
}
// ============================================================================
// Grace period deletion tests
// ============================================================================
#[tokio::test]
async fn grace_period_deletion_actually_deletes_user() -> miette::Result<()> {
use std::path::PathBuf;
use frogue::attachments::file_store::FileStore;
use frogue::config::REPO_ROOT_VAR_KEY;
use frogue::moderation::process_pending_deletions;
let port = free_port();
let mut server = Server::spawn_with_env(
&format!("127.0.0.1:{port}"),
&[(frogue::config::DELETION_GRACE_PERIOD_VAR_KEY, "0")],
);
server.wait_for_connection(port);
let mut client = support::Client::new(port);
// Register admin and a regular user
register(&mut client, "admin", "admin@example.com");
register(&mut client, "alice", "alice@example.com");
login(&mut client, "admin", PASSWORD);
// Schedule deletion of alice (user_id = 2)
let response = client.post_form("/admin/users/2/delete", &[]);
assert_eq!(response.status, 302);
// Verify pending deletion was created with execute_at in the past (grace=0)
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let pending = pending_deletions::Entity::find()
.filter(pending_deletions::Column::UserId.eq(2))
.one(&db)
.await
.into_diagnostic()?;
assert!(pending.is_some(), "pending deletion should exist");
// Manually trigger processing (bypass the 60s worker poll)
let repo_root = PathBuf::from(std::env::var(REPO_ROOT_VAR_KEY).unwrap_or_default());
let file_store = FileStore::new(repo_root.clone()).await.into_diagnostic()?;
process_pending_deletions(&db, &file_store, &repo_root)
.await
.expect("processing deletions should succeed");
// Verify the pending deletion record was removed
let count = pending_deletions::Entity::find()
.filter(pending_deletions::Column::UserId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(
count, 0,
"pending deletion should be removed after processing"
);
// Verify alice is actually deleted from the database
use frogue::entities::users;
let alice = users::Entity::find_by_id(2)
.one(&db)
.await
.into_diagnostic()?;
assert!(alice.is_none(), "alice should be deleted from the database");
Ok(())
}
// ============================================================================
// Report queue tests
// ============================================================================
#[tokio::test]
async fn admin_sees_empty_reports_queue() -> 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);
// Register admin
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Get reports page
let response = client.get("/admin/reports");
assert_eq!(response.status, 200);
assert!(
response.body.contains("No reports"),
"should show empty state"
);
Ok(())
}
#[tokio::test]
async fn admin_can_filter_reports_by_state() -> miette::Result<()> {
use frogue::entities::reports;
use sea_orm::ActiveModelTrait;
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Insert test reports with different states
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let open_report = reports::ActiveModel {
uuid: sea_orm::Set("open-report-uuid".to_string()),
reporter_local_user_id: sea_orm::Set(Some(1)),
target_local_user_id: sea_orm::Set(Some(1)),
object_urls: sea_orm::Set("[]".to_string()),
comment: sea_orm::Set(Some("Open report".to_string())),
state: sea_orm::Set("open".to_string()),
forwarded: sea_orm::Set(0),
inbound: sea_orm::Set(0),
..Default::default()
};
open_report.insert(&db).await.into_diagnostic()?;
let resolved_report = reports::ActiveModel {
uuid: sea_orm::Set("resolved-report-uuid".to_string()),
reporter_local_user_id: sea_orm::Set(Some(1)),
target_local_user_id: sea_orm::Set(Some(1)),
object_urls: sea_orm::Set("[]".to_string()),
comment: sea_orm::Set(Some("Resolved report".to_string())),
state: sea_orm::Set("resolved".to_string()),
forwarded: sea_orm::Set(0),
inbound: sea_orm::Set(0),
..Default::default()
};
resolved_report.insert(&db).await.into_diagnostic()?;
// Default view should show open reports only
let response = client.get("/admin/reports");
assert_eq!(response.status, 200);
assert!(
response.body.contains("open-report-uuid"),
"should show open report"
);
assert!(
!response.body.contains("resolved-report-uuid"),
"should not show resolved report by default"
);
// Filter by resolved
let response = client.get("/admin/reports?state=resolved");
assert_eq!(response.status, 200);
assert!(
response.body.contains("resolved-report-uuid"),
"should show resolved report when filtered"
);
assert!(
!response.body.contains("open-report-uuid"),
"should not show open report when filtered to resolved"
);
// Show all
let response = client.get("/admin/reports?state=");
assert_eq!(response.status, 200);
assert!(
response.body.contains("open-report-uuid"),
"should show open report when showing all"
);
assert!(
response.body.contains("resolved-report-uuid"),
"should show resolved report when showing all"
);
Ok(())
}
#[tokio::test]
async fn admin_can_resolve_and_dismiss_reports() -> miette::Result<()> {
use frogue::entities::reports;
use sea_orm::ActiveModelTrait;
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Insert a test report
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let report = reports::ActiveModel {
uuid: sea_orm::Set("test-report-uuid".to_string()),
reporter_local_user_id: sea_orm::Set(Some(1)),
target_local_user_id: sea_orm::Set(Some(1)),
object_urls: sea_orm::Set("[]".to_string()),
comment: sea_orm::Set(Some("Test report".to_string())),
state: sea_orm::Set("open".to_string()),
forwarded: sea_orm::Set(0),
inbound: sea_orm::Set(0),
..Default::default()
};
report.insert(&db).await.into_diagnostic()?;
// Resolve the report
let response = client.post_form("/admin/reports/test-report-uuid/resolve", &[]);
assert_eq!(response.status, 302, "should redirect after resolving");
// Verify state changed
let report = reports::Entity::find()
.filter(reports::Column::Uuid.eq("test-report-uuid"))
.one(&db)
.await
.into_diagnostic()?
.expect("report should exist");
assert_eq!(report.state, "resolved", "report should be resolved");
// Dismiss the report
let response = client.post_form("/admin/reports/test-report-uuid/dismiss", &[]);
assert_eq!(response.status, 302, "should redirect after dismissing");
// Verify state changed
let report = reports::Entity::find()
.filter(reports::Column::Uuid.eq("test-report-uuid"))
.one(&db)
.await
.into_diagnostic()?
.expect("report should exist");
assert_eq!(report.state, "dismissed", "report should be dismissed");
Ok(())
}
#[tokio::test]
async fn report_shortcuts_create_block_rows() -> miette::Result<()> {
use frogue::entities::{admin_remote_blocks, instance_blocks, reports};
use sea_orm::{ActiveModelTrait, Set};
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create a remote actor record
let remote_actor = frogue::entities::ap_remote_actors::ActiveModel {
actor_url: Set("https://remote.example/actor/123".to_string()),
inbox_url: Set("https://remote.example/inbox".to_string()),
shared_inbox_url: Set(None),
public_key_pem: Set("fake-key".to_string()),
public_key_id: Set("https://remote.example/actor/123#main-key".to_string()),
name: Set(Some("Remote User".to_string())),
preferred_username: Set(Some("remoteuser".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let remote_actor = remote_actor.insert(&db).await.into_diagnostic()?;
// Create a report with remote reporter
let report = reports::ActiveModel {
uuid: Set("shortcut-test-report-uuid".to_string()),
reporter_remote_actor_url: Set(Some("https://remote.example/actor/123".to_string())),
target_local_user_id: Set(Some(1)),
object_urls: Set("[]".to_string()),
comment: Set(Some("Test report for shortcuts".to_string())),
state: Set("open".to_string()),
forwarded: Set(0),
inbound: Set(0),
..Default::default()
};
let report = report.insert(&db).await.into_diagnostic()?;
// Test shortcut: block remote user from report detail
// The shortcut form posts to the federation endpoint with the actor URL
let response = client.post_form(
"/admin/federation/block-remote-user",
&[
("actor_url", "https://remote.example/actor/123"),
("reason", &format!("Reported content: {}", report.uuid)),
],
);
assert_eq!(
response.status, 302,
"should redirect after blocking remote user from report"
);
// Verify admin_remote_block was created
let block_count = admin_remote_blocks::Entity::find()
.filter(admin_remote_blocks::Column::RemoteActorId.eq(remote_actor.id))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(block_count, 1, "shortcut should create admin_remote_block");
// Test shortcut: defederate domain from report detail
// The shortcut form posts to the federation endpoint with the domain
let response = client.post_form(
"/admin/federation/block-domain",
&[
("domain", "remote.example"),
(
"reason",
&format!("Reporter domain for report: {}", report.uuid),
),
],
);
assert_eq!(
response.status, 302,
"should redirect after defederating domain from report"
);
// Verify instance_block was created
let domain_block_count = instance_blocks::Entity::find()
.filter(instance_blocks::Column::Domain.eq("remote.example"))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(
domain_block_count, 1,
"shortcut should create instance_block"
);
Ok(())
}
#[tokio::test]
async fn nav_badge_shows_open_report_count() -> miette::Result<()> {
use frogue::entities::reports;
use sea_orm::ActiveModelTrait;
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Insert 3 open reports
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
for i in 1..=3 {
let report = reports::ActiveModel {
uuid: sea_orm::Set(format!("report-uuid-{}", i)),
reporter_local_user_id: sea_orm::Set(Some(1)),
target_local_user_id: sea_orm::Set(Some(1)),
object_urls: sea_orm::Set("[]".to_string()),
comment: sea_orm::Set(Some(format!("Open report {}", i))),
state: sea_orm::Set("open".to_string()),
forwarded: sea_orm::Set(0),
inbound: sea_orm::Set(0),
..Default::default()
};
report.insert(&db).await.into_diagnostic()?;
}
// Check nav badge on /admin
let response = client.get("/admin");
assert_eq!(response.status, 200);
assert!(
response.body.contains(">3<"),
"nav badge should show 3 open reports on /admin, got: {}",
response.body
);
// Check nav badge on /admin/users
let response = client.get("/admin/users");
assert_eq!(response.status, 200);
assert!(
response.body.contains(">3<"),
"nav badge should show 3 open reports on /admin/users"
);
// Check nav badge on /admin/reports
let response = client.get("/admin/reports");
assert_eq!(response.status, 200);
assert!(
response.body.contains(">3<"),
"nav badge should show 3 open reports on /admin/reports"
);
// Check nav badge on /admin/federation
let response = client.get("/admin/federation");
assert_eq!(response.status, 200);
assert!(
response.body.contains(">3<"),
"nav badge should show 3 open reports on /admin/federation"
);
Ok(())
}
#[tokio::test]
async fn block_remote_user_by_handle() -> miette::Result<()> {
use frogue::entities::admin_remote_blocks;
use sea_orm::{ActiveModelTrait, Set};
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create the instance actor (required for Block activity delivery)
let (private_key_pem, public_key_pem) =
frogue::activitypub::keys::generate().expect("key generation failed");
let instance_actor = frogue::entities::ap_actors::ActiveModel {
user_id: Set(None),
repo_id: Set(None),
private_key_pem: Set(private_key_pem),
public_key_pem: Set(public_key_pem),
..Default::default()
};
instance_actor.insert(&db).await.into_diagnostic()?;
// Create a mock remote actor with webfinger support
let remote_port = free_port();
let remote_actor = support::ap::RemoteActor::serve(remote_port);
// Pre-populate the remote actor in the database
let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor.actor_url.clone()),
inbox_url: Set(remote_actor.inbox_url.clone()),
shared_inbox_url: Set(None),
public_key_pem: Set(remote_actor.public_key_pem.clone()),
public_key_id: Set(remote_actor.key_id.clone()),
name: Set(Some("Remote User".to_string())),
preferred_username: Set(Some("remoteuser".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
remote_actor_model.insert(&db).await.into_diagnostic()?;
// Block the remote user using @user@host format
// Note: This test assumes the webfinger implementation will resolve the
// handle to the remote actor URL. In a real scenario, the remote server
// would serve webfinger responses. For this test, we're verifying the
// handle format is accepted.
let handle = format!("remoteuser@127.0.0.1:{}", remote_port);
let response = client.post_form(
"/admin/federation/block-remote-user",
&[("actor_url", &handle), ("reason", "")],
);
// The handler should accept the handle format (even if resolution fails in
// test env) In production, it would resolve via webfinger and create
// the block
assert!(
response.status == 302 || response.status == 400,
"handler should accept @user@host format"
);
// Verify that at least one block was attempted (if resolution succeeded)
let block_count = admin_remote_blocks::Entity::find()
.count(&db)
.await
.into_diagnostic()?;
// If the block was created, verify it's recorded
if response.status == 302 {
assert_eq!(
block_count, 1,
"should have created an admin_remote_block record"
);
}
Ok(())
}
// ============================================================================
// Federation controls tests
// ============================================================================
// ============================================================================
// Delete cascade tests
// ============================================================================
#[tokio::test]
async fn delete_user_cascades_to_repos_and_notes() -> miette::Result<()> {
use frogue::entities::{ap_notes, repositories, ssh_keys};
use sea_orm::{ActiveModelTrait, Set};
let port = free_port();
let mut server = Server::spawn_with_env(
&format!("127.0.0.1:{port}"),
&[(frogue::config::DELETION_GRACE_PERIOD_VAR_KEY, "0")],
);
server.wait_for_connection(port);
let mut client = support::Client::new(port);
// Register admin and a user with content
register(&mut client, "admin", "admin@example.com");
register(&mut client, "alice", "alice@example.com");
login(&mut client, "admin", PASSWORD);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Explicitly create alice's actor (actors are lazy-created on first AP
// access)
let alice_actor = frogue::activitypub::actor::get_or_create(&db, 2)
.await
.into_diagnostic()?;
// Create a repository for alice (user_id = 2)
let repo = repositories::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
slug: Set("test-repo".to_string()),
owner_id: Set(2),
description: Set(Some("Test repo".to_string())),
..Default::default()
};
let _repo = repo.insert(&db).await.into_diagnostic()?;
// Create a note for alice
let note = ap_notes::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
actor_id: Set(alice_actor.id),
content_md: Set("Test note content".to_string()),
content_html: Set("<p>Test note content</p>".to_string()),
..Default::default()
};
let _note = note.insert(&db).await.into_diagnostic()?;
// Create an SSH key for alice
let ssh_key = ssh_keys::ActiveModel {
id: Set(1),
user_id: Set(2),
name: Set("test-key".to_string()),
algorithm: Set("ssh-rsa".to_string()),
fingerprint: Set("SHA256:abc123def456".to_string()),
public_key: Set("AAAAB3NzaC1yc2EAAAADAQABAAABAQ...".to_string()),
created_at: Set(chrono::Utc::now().into()),
last_used_at: Set(None),
};
let _ssh_key = ssh_key.insert(&db).await.into_diagnostic()?;
// Verify entities exist before deletion
let repo_count = repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(repo_count, 1, "alice should have 1 repo before deletion");
let note_count = ap_notes::Entity::find()
.filter(ap_notes::Column::ActorId.eq(alice_actor.id))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(note_count, 1, "alice should have 1 note before deletion");
let ssh_key_count = ssh_keys::Entity::find()
.filter(ssh_keys::Column::UserId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(
ssh_key_count, 1,
"alice should have 1 ssh key before deletion"
);
// Delete alice
let response = client.post_form("/admin/users/2/delete", &[]);
assert_eq!(
response.status, 302,
"should redirect after scheduling deletion"
);
// Process pending deletions immediately (grace period is 0)
let repo_root = server.repo_root().to_path_buf();
let file_store = frogue::attachments::file_store::FileStore::new(repo_root.clone())
.await
.into_diagnostic()?;
frogue::moderation::process_pending_deletions(&db, &file_store, &repo_root)
.await
.expect("processing deletions should succeed");
// Verify all related entities are deleted
let repo_count = repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(repo_count, 0, "alice's repos should be deleted");
let note_count = ap_notes::Entity::find()
.filter(ap_notes::Column::ActorId.eq(alice_actor.id))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(note_count, 0, "alice's notes should be deleted");
let ssh_key_count = ssh_keys::Entity::find()
.filter(ssh_keys::Column::UserId.eq(2))
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(ssh_key_count, 0, "alice's ssh keys should be deleted");
// Verify user is deleted
let alice = frogue::entities::users::Entity::find_by_id(2)
.one(&db)
.await
.into_diagnostic()?;
assert!(alice.is_none(), "alice should be deleted from database");
Ok(())
}
#[tokio::test]
async fn admin_sees_empty_federation_page() -> 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);
// Register admin
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Get federation page
let response = client.get("/admin/federation");
assert_eq!(response.status, 200);
Ok(())
}
#[tokio::test]
async fn admin_can_block_domain() -> 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);
// Register admin
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Block a domain
let response = client.post_form(
"/admin/federation/block-domain",
&[("domain", "evil.example.com"), ("reason", "spam")],
);
assert_eq!(response.status, 302, "should redirect after blocking");
// Verify domain is blocked
let response = client.get("/admin/federation");
assert_eq!(response.status, 200);
assert!(
response.body.contains("evil.example.com"),
"domain should be listed"
);
Ok(())
}
#[tokio::test]
async fn domain_normalization_strips_scheme_and_path() -> 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);
// Register admin
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Block a domain with scheme and path
let response = client.post_form(
"/admin/federation/block-domain",
&[("domain", "HTTPS://Evil.Example.COM/x/y"), ("reason", "")],
);
assert_eq!(response.status, 302);
// Verify domain is normalized to lowercase without scheme/path
let response = client.get("/admin/federation");
assert_eq!(response.status, 200);
assert!(
response.body.contains("evil.example.com"),
"domain should be normalized"
);
assert!(
!response.body.contains("HTTPS://"),
"scheme should be stripped"
);
assert!(!response.body.contains("/x/y"), "path should be stripped");
Ok(())
}
#[tokio::test]
async fn admin_can_unblock_domain() -> 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);
// Register admin
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
// Block a domain
client.post_form(
"/admin/federation/block-domain",
&[("domain", "evil.example.com"), ("reason", "")],
);
// Unblock it
let response = client.post_form(
"/admin/federation/unblock-domain",
&[("domain", "evil.example.com")],
);
assert_eq!(response.status, 302);
// Verify domain is unblocked
let response = client.get("/admin/federation");
assert_eq!(response.status, 200);
assert!(
!response.body.contains("evil.example.com"),
"domain should be unblocked"
);
Ok(())
}
// ============================================================================
// Federation delivery tests
// ============================================================================
#[tokio::test]
async fn delete_user_delivers_delete_activity_to_followers() -> miette::Result<()> {
use frogue::entities::ap_followers;
use sea_orm::{ActiveModelTrait, Set};
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 user
register(&mut client, "admin", "admin@example.com");
register(&mut client, "alice", "alice@example.com");
login(&mut client, "admin", PASSWORD);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create alice's actor
let alice_actor = frogue::activitypub::actor::get_or_create(&db, 2)
.await
.into_diagnostic()?;
// Create a mock remote follower
let remote_port = free_port();
let remote_actor = support::ap::RemoteActor::serve(remote_port);
// Create remote actor record in ap_remote_actors (for the follower)
let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor.actor_url.clone()),
inbox_url: Set(remote_actor.inbox_url.clone()),
shared_inbox_url: Set(None),
public_key_pem: Set(remote_actor.public_key_pem.clone()),
public_key_id: Set(remote_actor.key_id.clone()),
name: Set(Some("Remote Follower".to_string())),
preferred_username: Set(Some("follower".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let remote_actor_model = remote_actor_model.insert(&db).await.into_diagnostic()?;
// Create a follow relationship (remote follower follows alice)
// local_actor_id = alice (local user being followed)
// remote_actor_id = the remote follower's actor record in our DB
let follow = ap_followers::ActiveModel {
id: Set(0),
local_actor_id: Set(alice_actor.id),
remote_actor_id: Set(remote_actor_model.id),
follow_activity_id: Set(format!(
"{}/activities/follow-1",
server.public_url().unwrap()
)),
..Default::default()
};
follow.insert(&db).await.into_diagnostic()?;
// Schedule deletion of alice
let response = client.post_form("/admin/users/2/delete", &[]);
assert_eq!(response.status, 302);
// Process the deletion immediately
let repo_root = server.repo_root().to_path_buf();
let file_store = frogue::attachments::file_store::FileStore::new(repo_root.clone())
.await
.into_diagnostic()?;
frogue::moderation::process_pending_deletions(&db, &file_store, &repo_root)
.await
.expect("processing deletions should succeed");
// Give the delivery worker time to pick up and send the activity
// The worker polls every 2 seconds, so we wait 3 seconds to be safe
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
// Verify the remote actor received a Delete activity
let deliveries = remote_actor.recorded_deliveries();
assert!(
!deliveries.is_empty(),
"remote follower should have received deliveries"
);
// Find a Delete activity
let delete_activity = deliveries.iter().find(|d| {
let body: serde_json::Value = serde_json::from_str(&d.body).unwrap_or_default();
body.get("type").and_then(|t| t.as_str()) == Some("Delete")
});
assert!(
delete_activity.is_some(),
"remote follower should have received a Delete activity"
);
// Verify the Delete activity contains a Person object
let delete_body: serde_json::Value =
serde_json::from_str(&delete_activity.unwrap().body).unwrap();
let object = delete_body
.get("object")
.expect("Delete should have an object");
// The object might be a string URL or an embedded object
if let Some(obj_str) = object.as_str() {
// If it's a URL, we need to check if it points to a user/actor
assert!(
obj_str.contains("/~"),
"Delete activity object should reference a user actor URL"
);
} else if let Some(obj_obj) = object.as_object() {
let object_type = obj_obj.get("type").and_then(|t| t.as_str());
assert_eq!(
object_type,
Some("Person"),
"Delete activity should be for a Person"
);
} else {
panic!("Delete activity object should be either a string URL or an object");
}
Ok(())
}
#[tokio::test]
async fn block_remote_user_delivers_block_activity() -> miette::Result<()> {
use frogue::entities::admin_remote_blocks;
use sea_orm::{ActiveModelTrait, EntityTrait, Set};
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
register(&mut client, "admin", "admin@example.com");
login(&mut client, "admin", PASSWORD);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create the instance actor (Application actor for signing federation
// requests) This is an ap_actors row with both user_id and repo_id set
// to NULL
let (private_key_pem, public_key_pem) =
frogue::activitypub::keys::generate().expect("key generation failed");
let instance_actor = frogue::entities::ap_actors::ActiveModel {
user_id: Set(None),
repo_id: Set(None),
private_key_pem: Set(private_key_pem),
public_key_pem: Set(public_key_pem),
..Default::default()
};
instance_actor.insert(&db).await.into_diagnostic()?;
// Create a mock remote actor
let remote_port = free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Pre-populate the remote actor in the database (simulating a previous
// fetch/discovery)
let remote_actor_model = frogue::entities::ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor.actor_url.clone()),
inbox_url: Set(remote_actor.inbox_url.clone()),
shared_inbox_url: Set(None),
public_key_pem: Set(remote_actor.public_key_pem.clone()),
public_key_id: Set(remote_actor.key_id.clone()),
name: Set(Some("Remote User".to_string())),
preferred_username: Set(Some("user".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
remote_actor_model.insert(&db).await.into_diagnostic()?;
// Block the remote user using their full actor URL
let actor_url = format!("http://127.0.0.1:{remote_port}/actor");
let response = client.post_form(
"/admin/federation/block-remote-user",
&[("actor_url", &actor_url), ("reason", "")],
);
assert_eq!(
response.status, 302,
"should redirect after blocking remote user"
);
// Give the delivery worker time to pick up and send the activity
// The worker polls every 2 seconds, so we wait 3 seconds to be safe
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
// Verify the remote actor received a Block activity
let deliveries = remote_actor.recorded_deliveries();
assert!(
!deliveries.is_empty(),
"remote actor should have received deliveries"
);
// Find a Block activity
let block_activity = deliveries.iter().find(|d| {
let body: serde_json::Value = serde_json::from_str(&d.body).unwrap_or_default();
body.get("type").and_then(|t| t.as_str()) == Some("Block")
});
assert!(
block_activity.is_some(),
"remote actor should have received a Block activity"
);
// Verify the Block activity is signed by the instance actor
let block_body: serde_json::Value =
serde_json::from_str(&block_activity.unwrap().body).unwrap();
let actor_url = block_body
.get("actor")
.and_then(|a| a.as_str())
.expect("Block should have an actor");
assert!(
actor_url.contains("/actor"),
"Block should be signed by instance actor"
);
// Verify the block was recorded in the database
let block_count = admin_remote_blocks::Entity::find()
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(
block_count, 1,
"should have created an admin_remote_block record"
);
Ok(())
}