purge: skip broadcasts of deleted authors, purge outbox log, guards, 410 inbox test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user