// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Tests for instance defederation and admin remote-user blocks.
//!
//! These tests verify that:
//! - Instance-wide domain blocks reject inbox deliveries (403) and accept again
//! after unblocking (202)
//! - Blocked domains are refused during key resolution before any actor fetch
//! - Domain blocks normalize host:port input to the bare host
//! - Instance-wide domain blocks prevent outbound deliveries
//! - Instance-wide domain blocks hide content and reaction counts from feeds
//! - Local follows of blocked handles fail with a visible message
//! - Admin single-user blocks reject that user's deliveries
//! - Admin single-user blocks allow same-domain peers
//! - Blocking a remote user delivers an instance-actor-signed `Block`
use std::time::Duration;
use frogue::entities::{admin_remote_blocks, ap_remote_actors, instance_blocks};
use miette::IntoDiagnostic;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter};
use support::{Client, Server};
mod support;
const PASSWORD: &str = "correct horse battery staple";
fn login(client: &mut 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
);
}
#[tokio::test]
async fn domain_block_rejects_inbox_delivery() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create admin user (first user is admin)
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
// Create a remote actor
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
// Pre-populate the remote actor in the database
let remote_actor_model = 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("remote".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()?;
// Extract domain from actor URL
let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
let domain = actor_url_parsed
.host_str()
.expect("actor URL should have host");
// Admin blocks the domain, supplying a host:port pair. The port must be
// stripped so the block matches actor URLs on the host.
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[
("domain", &format!("{domain}:{remote_port}")),
("reason", "test block"),
],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect, got {}",
response.status
);
// Verify the instance block row exists, normalized to the bare host
let block = instance_blocks::Entity::find()
.filter(instance_blocks::Column::Domain.eq(domain))
.one(&db)
.await
.into_diagnostic()?;
assert!(block.is_some(), "instance block row should exist");
// Now try to send a signed activity from the blocked domain to our inbox
// The inbox should reject it with 403
let instance_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");
// Create a simple Follow activity signed by the blocked remote actor
let follow_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/follow-1", remote_actor.actor_url),
"actor": remote_actor.actor_url,
"object": format!("http://127.0.0.1:{port}/~admin")
});
let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
assert_eq!(
status, 403,
"inbox should reject deliveries from a blocked domain"
);
use frogue::entities::ap_followers;
let follow = ap_followers::Entity::find()
.filter(ap_followers::Column::RemoteActorId.eq(_remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
follow.is_none(),
"follow from blocked domain should be rejected"
);
// Unblocking the domain restores federation: the same delivery is accepted.
let response = admin_client.post_form(
"/admin/federation/unblock-domain",
&[("domain", &format!("{domain}:{remote_port}"))],
);
assert!(
response.status == 302 || response.status == 303,
"instance unblock should redirect"
);
let follow_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/follow-2", remote_actor.actor_url),
"actor": remote_actor.actor_url,
"object": format!("http://127.0.0.1:{port}/~admin")
});
let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
assert_eq!(status, 202, "inbox should accept deliveries after unblock");
Ok(())
}
#[tokio::test]
async fn blocked_domain_is_refused_before_actor_fetch() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
let domain = actor_url_parsed
.host_str()
.expect("actor URL should have host");
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[("domain", domain), ("reason", "test block")],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
// The actor is NOT cached. Resolving its signing key must refuse before
// any actor document fetch.
let instance_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");
let follow_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/follow-uncached", remote_actor.actor_url),
"actor": remote_actor.actor_url,
"object": format!("http://127.0.0.1:{port}/~admin")
});
let status = remote_actor.send_activity_with_status(&instance_inbox_url, follow_activity);
assert_eq!(
status, 403,
"uncached actor on a blocked domain should be refused"
);
assert!(
remote_actor.recorded_get_requests().is_empty(),
"blocked domains must not be fetched during key resolution"
);
Ok(())
}
#[tokio::test]
async fn local_follow_by_handle_of_blocked_domain_reports_block() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
// Keep a remote actor around to obtain a real port for the handle. It does
// not need to be reachable: the block is checked before webfinger.
let remote_port = support::free_port();
let _remote_actor = RemoteActor::serve(remote_port);
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
let mut alice_client = Client::new(port);
alice_client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut alice_client, "alice", PASSWORD);
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[("domain", "127.0.0.1"), ("reason", "test block")],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
let handle = format!("@bob@127.0.0.1:{remote_port}");
let response = alice_client.post_form("/~alice/follow", &[("handle", &handle)]);
assert_eq!(response.status, 303, "follow should redirect");
let location = response
.header("location")
.expect("redirect should have location");
assert!(
location.contains("blocked"),
"redirect should mention the block, got {location}"
);
Ok(())
}
#[tokio::test]
async fn domain_block_prevents_outbound_delivery() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create admin user
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
// Create a regular user
let mut user_client = Client::new(port);
user_client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
// Create a remote actor on a domain we'll block
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let remote_actor_model = 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("remote".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()?;
let alice_id = {
use frogue::entities::users;
users::Entity::find()
.filter(users::Column::Name.eq("alice"))
.one(&db)
.await
.into_diagnostic()?
.expect("alice should exist")
.id
};
let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
.await
.into_diagnostic()?;
// Extract domain and block it first, so the worker cannot race the block
let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
let domain = actor_url_parsed
.host_str()
.expect("actor URL should have host");
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[("domain", domain), ("reason", "test block")],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
// Enqueue a delivery to the now-blocked inbox.
let activity_json = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/pending-follow", remote_actor.actor_url),
"actor": format!("http://127.0.0.1:{port}/~alice"),
"object": remote_actor.actor_url
})
.to_string();
let activity_id = frogue::activitypub::delivery::enqueue_delivery(
&db,
alice_actor.id,
uuid::Uuid::new_v4(),
activity_json,
remote_actor.actor_url.clone(),
remote_actor.inbox_url.clone(),
)
.await
.expect("enqueueing a delivery should succeed");
// The worker polls every two seconds. Wait until it marks the delivery
// failed without contacting the blocked inbox.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
use frogue::entities::ap_deliveries;
let delivery = ap_deliveries::Entity::find()
.filter(ap_deliveries::Column::ActivityId.eq(activity_id))
.one(&db)
.await
.into_diagnostic()?;
if let Some(delivery) = delivery
&& delivery.status == "failed"
{
assert_eq!(
delivery.last_error.as_deref(),
Some("domain blocked"),
"blocked delivery should record why it was dropped"
);
break;
}
if std::time::Instant::now() >= deadline {
miette::bail!("delivery to blocked domain was not dropped in time");
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Check that no delivery was actually sent to the blocked domain
let deliveries = remote_actor.recorded_deliveries();
assert_eq!(
deliveries.len(),
0,
"no deliveries should be sent to blocked domain"
);
Ok(())
}
#[tokio::test]
async fn domain_block_hides_content_from_feed() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create admin user
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
// Create alice
let mut alice_client = Client::new(port);
alice_client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut alice_client, "alice", PASSWORD);
let alice_id = {
use frogue::entities::users;
users::Entity::find()
.filter(users::Column::Name.eq("alice"))
.one(&db)
.await
.into_diagnostic()?
.expect("alice should exist")
.id
};
let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
.await
.into_diagnostic()?;
// Create a remote actor
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let remote_actor_model = 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("remote".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()?;
// Alice follows the remote actor
let follow_model = frogue::entities::ap_following::ActiveModel {
local_actor_id: Set(alice_actor.id),
remote_actor_id: Set(remote_actor_model.id),
follow_activity_id: Set(format!(
"https://example.com/follow/{}",
uuid::Uuid::new_v4()
)),
state: Set("accepted".to_string()),
..Default::default()
};
follow_model.insert(&db).await.into_diagnostic()?;
// Create a remote note
use frogue::entities::ap_remote_notes;
let note_url = format!("{}/notes/{}", remote_actor.actor_url, uuid::Uuid::new_v4());
let note = ap_remote_notes::ActiveModel {
remote_actor_id: Set(remote_actor_model.id),
object_url: Set(note_url.clone()),
content_html: Set("<p>Test note from blocked domain</p>".to_string()),
published: Set(chrono::Utc::now().into()),
received_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let note = note.insert(&db).await.into_diagnostic()?;
// Verify note appears in feed before blocking
let feed_before = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("sqlite://", "http://"),
20,
0,
)
.await
.unwrap();
assert!(
feed_before.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == ¬e.id)),
"note should appear in feed before domain block"
);
// Extract domain and block it
let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
let domain = actor_url_parsed
.host_str()
.expect("actor URL should have host");
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[("domain", domain), ("reason", "test block")],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
// Verify note is hidden from feed after domain block
let feed_after = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("sqlite://", "http://"),
20,
0,
)
.await
.unwrap();
assert!(
!feed_after.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == ¬e.id)),
"note should be hidden from feed after domain block"
);
// Unblock the domain
let response =
admin_client.post_form("/admin/federation/unblock-domain", &[("domain", domain)]);
assert!(
response.status == 302 || response.status == 303,
"instance unblock should redirect"
);
// Verify note reappears in feed
let feed_restored = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("sqlite://", "http://"),
20,
0,
)
.await
.unwrap();
assert!(
feed_restored.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == ¬e.id)),
"note should reappear in feed after unblocking domain"
);
Ok(())
}
#[tokio::test]
async fn admin_user_block_rejects_only_that_user() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create admin user
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
// Create two remote actors on different ports (simulating same domain)
let remote_port1 = support::free_port();
let remote_actor1 = RemoteActor::serve(remote_port1);
let remote_port2 = support::free_port();
let remote_actor2 = RemoteActor::serve(remote_port2);
let remote_actor1_model = ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor1.actor_url.clone()),
inbox_url: Set(remote_actor1.inbox_url.clone()),
shared_inbox_url: Set(None),
public_key_pem: Set(remote_actor1.public_key_pem.clone()),
public_key_id: Set(remote_actor1.key_id.clone()),
name: Set(Some("Remote User 1".to_string())),
preferred_username: Set(Some("remote1".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let remote_actor1_model = remote_actor1_model.insert(&db).await.into_diagnostic()?;
let remote_actor2_model = ap_remote_actors::ActiveModel {
actor_url: Set(remote_actor2.actor_url.clone()),
inbox_url: Set(remote_actor2.inbox_url.clone()),
shared_inbox_url: Set(None),
public_key_pem: Set(remote_actor2.public_key_pem.clone()),
public_key_id: Set(remote_actor2.key_id.clone()),
name: Set(Some("Remote User 2".to_string())),
preferred_username: Set(Some("remote2".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let remote_actor2_model = remote_actor2_model.insert(&db).await.into_diagnostic()?;
// Admin blocks only the first user
let response = admin_client.post_form(
"/admin/federation/block-remote-user",
&[
("actor_url", &remote_actor1.actor_url),
("reason", "test block"),
],
);
assert!(
response.status == 302 || response.status == 303,
"admin block should redirect"
);
// Verify the admin block row exists
let block = admin_remote_blocks::Entity::find()
.filter(admin_remote_blocks::Column::RemoteActorId.eq(remote_actor1_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(block.is_some(), "admin block row should exist");
// Now try to send activities from both actors to our inbox
// Use the user inbox for admin (the actual inbox route)
let admin_inbox_url = format!("http://127.0.0.1:{port}/~admin/inbox");
// First actor should be rejected with 403
let follow_activity1 = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/follow-1", remote_actor1.actor_url),
"actor": remote_actor1.actor_url,
"object": format!("http://127.0.0.1:{port}/~admin")
});
let status1 = remote_actor1.send_activity_with_status(&admin_inbox_url, follow_activity1);
assert_eq!(status1, 403, "blocked user's delivery should be rejected");
// Check that no follow was created for the blocked user
use frogue::entities::ap_followers;
let follow1 = ap_followers::Entity::find()
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor1_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
follow1.is_none(),
"blocked user's follow should be rejected"
);
// Second actor should be accepted (same host, different actor)
let follow_activity2 = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": format!("{}/activities/follow-2", remote_actor2.actor_url),
"actor": remote_actor2.actor_url,
"object": format!("http://127.0.0.1:{port}/~admin")
});
let status2 = remote_actor2.send_activity_with_status(&admin_inbox_url, follow_activity2);
assert_eq!(status2, 202, "same-domain peer should still federate");
// Check that follow was created for the non-blocked user
let follow2 = ap_followers::Entity::find()
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor2_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
follow2.is_some(),
"non-blocked user's follow should be accepted"
);
Ok(())
}
#[tokio::test]
async fn local_follow_of_blocked_actor_rejected() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Create admin user
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
// Create alice
let mut alice_client = Client::new(port);
alice_client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
// Create a remote actor
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let remote_actor_model = 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("remote".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()?;
// Extract domain and block it
let actor_url_parsed = url::Url::parse(&remote_actor.actor_url).into_diagnostic()?;
let domain = actor_url_parsed
.host_str()
.expect("actor URL should have host");
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[("domain", domain), ("reason", "test block")],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
// Alice tries to follow the blocked actor
login(&mut alice_client, "alice", PASSWORD);
let response =
alice_client.post_form("/~alice/follow", &[("actor_url", &remote_actor.actor_url)]);
assert_eq!(
response.status, 303,
"follow of a blocked actor should redirect with an error"
);
let location = response
.header("location")
.expect("redirect should have location");
assert!(
location.contains("blocked"),
"redirect should mention the block, got {location}"
);
Ok(())
}
#[tokio::test]
async fn admin_user_block_delivers_instance_actor_block() -> miette::Result<()> {
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let remote_actor_model = 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("remote".to_string())),
icon_url: Set(None),
fetched_at: Set(chrono::Utc::now().into()),
..Default::default()
};
remote_actor_model.insert(&db).await.into_diagnostic()?;
let response = admin_client.post_form(
"/admin/federation/block-remote-user",
&[
("actor_url", &remote_actor.actor_url),
("reason", "test block"),
],
);
assert!(
response.status == 302 || response.status == 303,
"admin block should redirect"
);
// The delivery worker polls every two seconds. Wait for the Block activity
// signed by the instance actor to reach the remote inbox.
let instance_actor_url = format!("http://127.0.0.1:{port}/actor");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let delivered = remote_actor.recorded_deliveries().iter().any(|delivery| {
let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) else {
return false;
};
activity["type"] == "Block" && activity["actor"] == instance_actor_url
});
if delivered {
return Ok(());
}
if std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
miette::bail!(
"instance actor Block was not delivered to the blocked remote user; got {:?}",
remote_actor
.recorded_deliveries()
.iter()
.map(|delivery| delivery.body.clone())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn domain_block_hides_reaction_counts() -> miette::Result<()> {
use frogue::entities::{ap_notes, ap_reactions, users};
use support::ap::RemoteActor;
let port = support::free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let mut admin_client = Client::new(port);
admin_client.post_form(
"/register",
&[
("name", "admin"),
("email", "admin@example.com"),
("password", PASSWORD),
],
);
login(&mut admin_client, "admin", PASSWORD);
let mut alice_client = Client::new(port);
alice_client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
let alice_id = users::Entity::find()
.filter(users::Column::Name.eq("alice"))
.one(&db)
.await
.into_diagnostic()?
.expect("alice should exist")
.id;
let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
.await
.into_diagnostic()?;
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
let remote_actor_model = 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("remote".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()?;
let note = ap_notes::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
actor_id: Set(alice_actor.id),
content_md: Set("hello".to_string()),
content_html: Set("<p>hello</p>".to_string()),
created_at: Set(chrono::Utc::now().into()),
..Default::default()
}
.insert(&db)
.await
.into_diagnostic()?;
ap_reactions::ActiveModel {
note_id: Set(Some(note.id)),
kind: Set("like".to_string()),
remote_actor_id: Set(Some(remote_actor_model.id)),
activity_id: Set(Some(format!("{}/likes/1", remote_actor.actor_url))),
created_at: Set(chrono::Utc::now().into()),
..Default::default()
}
.insert(&db)
.await
.into_diagnostic()?;
// A local reaction has no remote actor and must survive actor filtering.
ap_reactions::ActiveModel {
note_id: Set(Some(note.id)),
kind: Set("like".to_string()),
local_user_id: Set(Some(alice_id)),
created_at: Set(chrono::Utc::now().into()),
..Default::default()
}
.insert(&db)
.await
.into_diagnostic()?;
let before = frogue::activitypub::notes::get_reactions(&db, note.id, Some(alice_id))
.await
.into_diagnostic()?;
assert_eq!(
before.likes, 2,
"local and remote reactions should be visible before the block"
);
let domain = url::Url::parse(&remote_actor.actor_url)
.into_diagnostic()?
.host_str()
.expect("actor URL should have host")
.to_string();
let response = admin_client.post_form(
"/admin/federation/block-domain",
&[
("domain", &format!("{domain}:{remote_port}")),
("reason", "test block"),
],
);
assert!(
response.status == 302 || response.status == 303,
"instance block should redirect"
);
let during = frogue::activitypub::notes::get_reactions(&db, note.id, Some(alice_id))
.await
.into_diagnostic()?;
assert_eq!(
during.likes, 1,
"only the local reaction should remain visible after the block"
);
let anonymous = frogue::activitypub::notes::get_reactions(&db, note.id, None)
.await
.into_diagnostic()?;
assert_eq!(
anonymous.likes, 1,
"the local reaction must remain visible to anonymous viewers too"
);
Ok(())
}