purge: skip broadcasts of deleted authors, purge outbox log, guards, 410 inbox test

This commit is contained in:
2026-10-26 02:39:06 +01:00
parent 51a70b5260
commit 80dbdec107
9 changed files with 318 additions and 27 deletions

View File

@@ -6,7 +6,7 @@ use domain::{
events::DomainEvent,
ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
UserFederationSettingsQuery, UserRepository,
},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
@@ -25,6 +25,7 @@ pub struct ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
}
@@ -38,6 +39,7 @@ impl ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
) -> Self {
Self {
@@ -48,6 +50,7 @@ impl ActivityPubEventHandler {
goal_repo,
stats_repo,
federation_settings,
user_repo,
instance,
}
}
@@ -60,6 +63,15 @@ impl EventHandler for ActivityPubEventHandler {
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
// A deleted account must not speak again: its queued events would
// otherwise sign and send from the tombstone after the Delete. Only
// the Delete itself (sent by the deletion notifier) goes out.
if let Some(author) = self.outbound_author(event)
&& self.is_tombstoned(&author).await
{
tracing::debug!(user = %author.value(), "skipping federation event of a deleted account");
return Ok(());
}
match event {
DomainEvent::ReviewLogged {
review_id, user_id, ..
@@ -161,6 +173,38 @@ impl EventHandler for ActivityPubEventHandler {
}
impl ActivityPubEventHandler {
/// The local account an outbound activity would be sent as. A queued
/// delivery counts unless it is the account's `Delete`.
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
match event {
DomainEvent::ReviewLogged { user_id, .. }
| DomainEvent::ReviewUpdated { user_id, .. }
| DomainEvent::ReviewDeleted { user_id, .. }
| DomainEvent::UserUpdated { user_id }
| DomainEvent::WatchlistEntryAdded { user_id, .. }
| DomainEvent::WatchlistEntryRemoved { user_id, .. }
| DomainEvent::GoalCreated { user_id, .. }
| DomainEvent::GoalUpdated { user_id, .. }
| DomainEvent::GoalDeleted { user_id, .. }
| DomainEvent::UserAccountMoved { user_id, .. } => Some(user_id.clone()),
DomainEvent::FederationDeliveryRequested {
activity_json,
signing_actor_id,
..
} if activity_json.get("type").and_then(|t| t.as_str()) != Some("Delete") => {
Some(UserId::from_uuid(*signing_actor_id))
}
_ => None,
}
}
async fn is_tombstoned(&self, user: &UserId) -> bool {
matches!(
self.user_repo.find_tombstoned_by_id(user).await,
Ok(Some(_))
)
}
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings

View File

@@ -179,6 +179,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
goal_repo,
stats_repo,
federation_settings,
std::sync::Arc::clone(&user_repo),
instance,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -16,7 +16,9 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, WatchlistWithMovie},
ports::{EventHandler, LocalApContentQuery, MovieCommand},
ports::{
EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository,
},
testing::{
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
},
@@ -71,6 +73,8 @@ struct Harness {
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
user: UserId,
movie_id: MovieId,
store: Arc<InMemoryStore>,
service: Arc<ActivityPubService>,
}
async fn harness() -> Harness {
@@ -119,14 +123,23 @@ async fn harness() -> Harness {
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store.upsert_movie(&movie, &[]).await.unwrap();
let service = Arc::new(service);
store
.save(&domain::testing::test_user_with_role(
user.clone(),
domain::models::UserRole::Standard,
))
.await
.unwrap();
let handler = ActivityPubEventHandler::new(
Arc::new(service),
Arc::clone(&service),
Arc::new(NoContent),
Arc::new(PanicReviewRepository),
store.clone(),
Arc::new(PanicGoalQuery),
Arc::new(PanicStatsRepository),
store,
store.clone(),
store.clone(),
InstanceIdentity::new("https://diary.example"),
);
Harness {
@@ -134,6 +147,8 @@ async fn harness() -> Harness {
delivered: rx,
user,
movie_id: movie.id().clone(),
store,
service,
}
}
@@ -191,3 +206,61 @@ async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
assert_ne!(first, second);
}
/// Queued events of a deleted account must not sign and send from its
/// tombstone; the account's own Delete still goes out.
#[tokio::test]
async fn a_deleted_account_sends_nothing_but_its_delete() {
let mut h = harness().await;
h.store.mark_deleted(&h.user, &[]).await.unwrap();
let logged = DomainEvent::ReviewLogged {
review_id: domain::value_objects::ReviewId::generate(),
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
rating: domain::value_objects::Rating::new(4).unwrap(),
watched_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let added = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let queued = DomainEvent::FederationDeliveryRequested {
inbox_url: "https://remote.example/inbox".into(),
activity_json: serde_json::json!({"type": "Create"}),
signing_actor_id: h.user.value(),
};
// The review repository panics if the handler gets past the guard.
for event in [&logged, &added, &queued] {
h.handler.handle(event).await.unwrap();
}
assert!(
tokio::time::timeout(Duration::from_millis(300), h.delivered.recv())
.await
.is_err(),
"nothing is delivered for a deleted account"
);
activitypub::ApDeletionNotifier::new(
Arc::clone(&h.service),
InstanceIdentity::new("https://diary.example"),
)
.notify_deleted(&h.user)
.await
.unwrap();
let delete = tokio::time::timeout(Duration::from_secs(5), h.delivered.recv())
.await
.expect("the Delete is delivered")
.unwrap();
assert_eq!(delete["type"], "Delete");
}