// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Tests for user blocking and silencing functionality.
use std::time::Duration;
use frogue::entities::{ap_followers, ap_following, user_blocks, user_silences};
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";
/// Logs in with the given credentials.
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 block_remote_actor_removes_follows_and_delivers_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()?;
// Create a local user (alice)
let mut client = Client::new(port);
client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut 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
};
// Create alice's actor (actors are lazy-created on first AP access)
let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
.await
.into_diagnostic()?;
// Create a remote actor to block
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 = 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("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 = 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()?;
// Remote actor follows alice
let follower_model = ap_followers::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()
)),
..Default::default()
};
follower_model.insert(&db).await.into_diagnostic()?;
// Alice blocks the remote actor
let response = client.post_form(&format!("/remote/{}/block", remote_actor_model.id), &[]);
assert_eq!(response.status, 303, "block should redirect after success");
// Verify the block row exists
let block = user_blocks::Entity::find()
.filter(user_blocks::Column::BlockerId.eq(alice_id))
.filter(user_blocks::Column::BlockedRemoteActorId.eq(remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(block.is_some(), "block row should exist");
// Verify follows are removed both ways
let following = ap_following::Entity::find()
.filter(ap_following::Column::LocalActorId.eq(alice_actor.id))
.filter(ap_following::Column::RemoteActorId.eq(remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
following.is_none(),
"alice should no longer follow remote actor"
);
let follower = ap_followers::Entity::find()
.filter(ap_followers::Column::LocalActorId.eq(alice_actor.id))
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
follower.is_none(),
"remote actor should no longer follow alice"
);
// Wait for Block activity delivery
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut block_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("Block") {
block_delivery = Some(delivery);
break;
}
}
}
if block_delivery.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let delivery = block_delivery.expect("should have received Block activity delivery");
let activity: serde_json::Value =
serde_json::from_str(&delivery.body).expect("delivery body should be valid JSON");
assert_eq!(activity["type"], "Block", "should be a Block activity");
assert!(
activity["actor"].as_str().unwrap().contains("/~alice"),
"actor should be alice"
);
assert!(
activity["object"].as_str().unwrap() == remote_actor.actor_url,
"object should be the blocked actor URL"
);
Ok(())
}
#[tokio::test]
async fn block_local_user_creates_block_row() -> miette::Result<()> {
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 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
};
// Create bob
let mut bob_client = Client::new(port);
bob_client.post_form(
"/register",
&[
("name", "bob"),
("email", "bob@example.com"),
("password", PASSWORD),
],
);
let bob_id = {
use frogue::entities::users;
users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?
.expect("bob should exist")
.id
};
// Alice blocks Bob
let response = alice_client.post_form("/~bob/block", &[]);
assert_eq!(response.status, 303, "block should redirect after success");
// Verify the block row exists
let block = user_blocks::Entity::find()
.filter(user_blocks::Column::BlockerId.eq(alice_id))
.filter(user_blocks::Column::BlockedUserId.eq(bob_id))
.one(&db)
.await
.into_diagnostic()?;
assert!(block.is_some(), "block row should exist");
// Note: Local-to-local follows are not tracked in the schema,
// so there are no follow relationships to remove
Ok(())
}
#[tokio::test]
async fn silence_remote_actor_hides_feed_but_keeps_follows() -> 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 a local user (alice)
let mut client = Client::new(port);
client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut 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
};
// Create alice's actor (actors are lazy-created on first AP access)
let alice_actor = frogue::activitypub::actor::get_or_create(&db, alice_id)
.await
.into_diagnostic()?;
// Create a remote actor to silence
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 = 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("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 = 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()?;
// Alice silences the remote actor
let response = client.post_form(&format!("/remote/{}/silence", remote_actor_model.id), &[]);
assert_eq!(
response.status, 303,
"silence should redirect after success"
);
// Verify the silence row exists
let silence = user_silences::Entity::find()
.filter(user_silences::Column::BlockerId.eq(alice_id))
.filter(user_silences::Column::BlockedRemoteActorId.eq(remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(silence.is_some(), "silence row should exist");
// Verify follows are intact
let following = ap_following::Entity::find()
.filter(ap_following::Column::LocalActorId.eq(alice_actor.id))
.filter(ap_following::Column::RemoteActorId.eq(remote_actor_model.id))
.one(&db)
.await
.into_diagnostic()?;
assert!(
following.is_some(),
"alice should still follow remote actor"
);
// Verify no Block activity was delivered
let deadline = std::time::Instant::now() + Duration::from_millis(500);
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("Block") {
panic!("should not have delivered Block activity for silence");
}
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Ok(())
}
#[tokio::test]
async fn blocked_remote_actor_filtered_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 alice
let mut client = Client::new(port);
client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut 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
};
// Create alice's actor
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 = 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("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 = 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()
};
let _follow = follow_model.insert(&db).await.into_diagnostic()?;
// Create a remote note from the blocked actor
use frogue::entities::ap_remote_notes;
let note = ap_remote_notes::ActiveModel {
remote_actor_id: Set(remote_actor_model.id),
object_url: Set("https://example.com/note/1".to_string()),
content_html: Set("<p>Test note</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 the note appears in alice's feed before blocking
let feed_before = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "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 blocking"
);
// Manually insert a block row WITHOUT deleting the follow (to test
// filtering)
use frogue::entities::user_blocks;
let block = user_blocks::ActiveModel {
blocker_id: Set(alice_id),
blocked_user_id: Set(None),
blocked_remote_actor_id: Set(Some(remote_actor_model.id)),
created_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let _block = block.insert(&db).await.into_diagnostic()?;
// Verify the note is filtered from alice's feed after blocking (even though
// follow still exists)
let feed_after = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "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 filtered from feed after blocking"
);
Ok(())
}
#[tokio::test]
async fn silenced_remote_actor_filtered_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 alice
let mut client = Client::new(port);
client.post_form(
"/register",
&[
("name", "alice"),
("email", "alice@example.com"),
("password", PASSWORD),
],
);
login(&mut 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
};
// Create alice's actor
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 = 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("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 = 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()
};
let _follow = follow_model.insert(&db).await.into_diagnostic()?;
// Create a remote note from the silenced actor
use frogue::entities::ap_remote_notes;
let note = ap_remote_notes::ActiveModel {
remote_actor_id: Set(remote_actor_model.id),
object_url: Set("https://example.com/note/2".to_string()),
content_html: Set("<p>Test note</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 the note appears in alice's feed before silencing
let feed_before = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "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 silencing"
);
// Manually insert a silence row (to test filtering)
use frogue::entities::user_silences;
let silence = user_silences::ActiveModel {
blocker_id: Set(alice_id),
blocked_user_id: Set(None),
blocked_remote_actor_id: Set(Some(remote_actor_model.id)),
created_at: Set(chrono::Utc::now().into()),
..Default::default()
};
let _silence = silence.insert(&db).await.into_diagnostic()?;
// Verify the note is filtered from alice's feed after silencing
let feed_after = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "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 filtered from feed after silencing"
);
Ok(())
}
#[tokio::test]
async fn unblock_remote_actor_restores_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()?;
// Register 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
};
// Create alice's actor
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 = 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("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()?;
// Create a note from the remote actor
let note_url = format!("{}/notes/{}", remote_actor.actor_url, uuid::Uuid::new_v4());
let note = frogue::entities::ap_remote_notes::ActiveModel {
remote_actor_id: Set(remote_actor_model.id),
object_url: Set(note_url.clone()),
content_html: Set("<p>Test note</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()?;
// Alice follows the remote actor
let follow_model = 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()
};
let _follow = follow_model.insert(&db).await.into_diagnostic()?;
// Verify the note appears in alice's feed before blocking
let feed_before = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "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 blocking"
);
// Alice blocks the remote actor
let response = alice_client.post_form(&format!("/remote/{}/block", remote_actor_model.id), &[]);
assert_eq!(response.status, 303, "blocking should redirect");
// Verify the note is filtered from alice's feed after blocking
let feed_after_block = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "http://"),
20,
0,
)
.await
.unwrap();
assert!(
!feed_after_block.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == ¬e.id)),
"note should be filtered from feed after blocking"
);
// Alice unblocks the remote actor
let response =
alice_client.post_form(&format!("/remote/{}/unblock", remote_actor_model.id), &[]);
assert_eq!(response.status, 303, "unblocking should redirect");
// Verify the block row is deleted
let block_row = user_blocks::Entity::find()
.filter(user_blocks::Column::BlockerId.eq(alice_id))
.filter(user_blocks::Column::BlockedRemoteActorId.eq(Some(remote_actor_model.id)))
.one(&db)
.await
.into_diagnostic()?;
assert!(
block_row.is_none(),
"block row should be deleted after unblocking"
);
// Re-establish the follow relationship (blocking deleted it)
let follow_model = 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()
};
let _follow = follow_model.insert(&db).await.into_diagnostic()?;
// Verify the note is restored to alice's feed after unblocking and
// re-following
let feed_after_unblock = frogue::activitypub::feed::get_home_feed(
&db,
alice_id,
&server.database_url().replace("postgres://", "http://"),
20,
0,
)
.await
.unwrap();
assert!(
feed_after_unblock.iter().any(|entry| matches!(entry, frogue::activitypub::feed::FeedEntry::Remote { id, .. } if id == ¬e.id)),
"note should be restored to feed after unblocking and re-following"
);
Ok(())
}
#[tokio::test]
async fn silence_local_user_creates_silence_row() -> miette::Result<()> {
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()?;
// Register 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
};
// Register bob
let mut bob_client = Client::new(port);
bob_client.post_form(
"/register",
&[
("name", "bob"),
("email", "bob@example.com"),
("password", PASSWORD),
],
);
let bob_id = {
use frogue::entities::users;
users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?
.expect("bob should exist")
.id
};
// Alice silences Bob
let response = alice_client.post_form("/~bob/silence", &[]);
assert_eq!(response.status, 303, "silencing should redirect");
// Verify a silence row was created
let silence_row = user_silences::Entity::find()
.filter(user_silences::Column::BlockerId.eq(alice_id))
.filter(user_silences::Column::BlockedUserId.eq(Some(bob_id)))
.one(&db)
.await
.into_diagnostic()?;
assert!(
silence_row.is_some(),
"silence row should exist for alice -> bob"
);
let silence_row = silence_row.unwrap();
assert!(
silence_row.blocked_remote_actor_id.is_none(),
"silence row should not have remote_actor_id for local user"
);
Ok(())
}
#[tokio::test]
async fn block_local_user_delivers_no_federation() -> miette::Result<()> {
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()?;
// Register 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);
// Register bob
let mut bob_client = Client::new(port);
bob_client.post_form(
"/register",
&[
("name", "bob"),
("email", "bob@example.com"),
("password", PASSWORD),
],
);
let bob_id = {
use frogue::entities::users;
users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?
.expect("bob should exist")
.id
};
// Get bob's user record to check for AP actor
let _bob_user = frogue::entities::users::Entity::find()
.filter(frogue::entities::users::Column::Id.eq(bob_id))
.one(&db)
.await
.into_diagnostic()?
.expect("bob should exist");
// Alice blocks Bob
let response = alice_client.post_form("/~bob/block", &[]);
assert_eq!(response.status, 303, "blocking should redirect");
// Verify that no federation occurred (bob is local, so there should be no
// AP actor created for delivery) Local-to-local blocks don't involve
// federation, so we just verify the block row exists
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 block_row = user_blocks::Entity::find()
.filter(user_blocks::Column::BlockerId.eq(alice_id))
.filter(user_blocks::Column::BlockedUserId.eq(Some(bob_id)))
.one(&db)
.await
.into_diagnostic()?;
assert!(
block_row.is_some(),
"block row should exist for alice -> bob"
);
let block_row = block_row.unwrap();
assert!(
block_row.blocked_remote_actor_id.is_none(),
"block row should not have remote_actor_id for local user"
);
Ok(())
}
#[tokio::test]
async fn silence_remote_actor_profile_still_200() -> 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()?;
// Register 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);
// Create a remote actor
let remote_port = support::free_port();
let remote_actor = RemoteActor::serve(remote_port);
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("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 silences the remote actor
let response =
alice_client.post_form(&format!("/remote/{}/silence", remote_actor_model.id), &[]);
assert_eq!(response.status, 303, "silencing should redirect");
// Verify a silence row was created
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 silence_row = user_silences::Entity::find()
.filter(user_silences::Column::BlockerId.eq(alice_id))
.filter(user_silences::Column::BlockedRemoteActorId.eq(Some(remote_actor_model.id)))
.one(&db)
.await
.into_diagnostic()?;
assert!(
silence_row.is_some(),
"silence row should exist for alice -> remote actor"
);
// The profile page for the remote actor should still be accessible
// (silencing doesn't prevent viewing the profile, just hides content from
// feed) Note: remote actors don't have profile pages in the current
// implementation, but we can verify the silence row exists and the
// endpoint worked
assert_eq!(response.status, 303, "silence endpoint should redirect");
Ok(())
}