use std::sync::{Arc, Mutex}; use application::users::purge_account::AccountPurgeHandler; use async_trait::async_trait; use domain::{ errors::DomainError, events::{DomainEvent, EventEnvelope, EventId}, ports::{ AccountPurger, EventConsumer, EventHandler, FederationDeletionNotifier, ObjectStorage, UserRepository, }, testing::{InMemoryStore, NoopAckHandle, test_user}, value_objects::UserId, }; use futures::stream::{self, BoxStream}; type Log = Arc>>; struct Notifier { log: Log, fail: bool, } #[async_trait] impl FederationDeletionNotifier for Notifier { async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> { self.log.lock().unwrap().push("notify".into()); if self.fail { Err(DomainError::InfrastructureError("peer unreachable".into())) } else { Ok(()) } } } struct Purger { log: Log, purged: Mutex, keys: Vec, } #[async_trait] impl AccountPurger for Purger { async fn is_purged(&self, _: &UserId) -> Result { Ok(*self.purged.lock().unwrap()) } async fn owned_image_keys(&self, _: &UserId) -> Result, DomainError> { Ok(self.keys.clone()) } async fn purge(&self, _: &UserId) -> Result<(), DomainError> { self.log.lock().unwrap().push("purge".into()); *self.purged.lock().unwrap() = true; Ok(()) } async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> { self.log.lock().unwrap().push("forget".into()); Ok(()) } async fn purge_expired_tombstones(&self, _: chrono::NaiveDateTime) -> Result { Ok(0) } } struct Storage { log: Log, } #[async_trait] impl ObjectStorage for Storage { async fn store(&self, _: &str, _: &[u8]) -> Result { unimplemented!() } async fn get(&self, _: &str) -> Result, DomainError> { unimplemented!() } async fn get_stream( &self, _: &str, ) -> Result>, DomainError> { unimplemented!() } async fn delete(&self, key: &str) -> Result<(), DomainError> { self.log.lock().unwrap().push(format!("delete {key}")); Ok(()) } async fn exists(&self, _: &str) -> Result { unimplemented!() } } struct Fixture { handler: Arc, purger: Arc, log: Log, store: Arc, user: UserId, } async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture { let store = InMemoryStore::new(); let alice = test_user("alice"); store.save(&alice).await.unwrap(); if tombstoned { store.mark_deleted(alice.id(), &[]).await.unwrap(); } let log: Log = Arc::default(); let purger = Arc::new(Purger { log: log.clone(), purged: Mutex::new(false), keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()], }); let handler = Arc::new(AccountPurgeHandler::new( store.clone(), Arc::new(Notifier { log: log.clone(), fail: notify_fails, }), purger.clone(), Arc::new(Storage { log: log.clone() }), )); Fixture { handler, purger, log, store, user: alice.id().clone(), } } fn deleted(user: &UserId) -> DomainEvent { DomainEvent::UserDeleted { user_id: user.clone(), } } fn entries(log: &Log) -> Vec { log.lock().unwrap().clone() } #[test] fn the_handler_name_is_stable() { let f = tokio::runtime::Runtime::new() .unwrap() .block_on(fixture(false, true)); assert_eq!(f.handler.name(), "account_purge"); } #[tokio::test] async fn notifies_first_then_purges_then_removes_images_then_forgets_them() { let f = fixture(false, true).await; f.handler.handle(&deleted(&f.user)).await.unwrap(); assert_eq!( entries(&f.log), [ "notify", "purge", "delete avatars/a.webp", "delete banners/a.webp", "forget" ] ); } #[tokio::test] async fn purge_continues_when_federation_notify_fails() { let f = fixture(true, true).await; f.handler.handle(&deleted(&f.user)).await.unwrap(); let log = entries(&f.log); assert_eq!(log[0], "notify"); assert!(log.contains(&"purge".to_string()), "{log:?}"); assert!(log.contains(&"forget".to_string()), "{log:?}"); } #[tokio::test] async fn a_rerun_does_not_notify_again() { let f = fixture(false, true).await; f.handler.handle(&deleted(&f.user)).await.unwrap(); f.handler.handle(&deleted(&f.user)).await.unwrap(); let notices = entries(&f.log).iter().filter(|e| *e == "notify").count(); assert_eq!(notices, 1); } #[tokio::test] async fn does_nothing_when_the_account_is_not_tombstoned() { let live = fixture(false, false).await; live.handler.handle(&deleted(&live.user)).await.unwrap(); assert!(entries(&live.log).is_empty(), "a live user is never purged"); let unknown = fixture(false, true).await; unknown .handler .handle(&deleted(&UserId::generate())) .await .unwrap(); assert!(entries(&unknown.log).is_empty()); } #[tokio::test] async fn ignores_other_events() { let f = fixture(false, true).await; f.handler .handle(&DomainEvent::UserUpdated { user_id: f.user.clone(), }) .await .unwrap(); assert!(entries(&f.log).is_empty()); } struct TwiceConsumer { id: EventId, event: DomainEvent, } impl EventConsumer for TwiceConsumer { fn consume(&self) -> BoxStream<'_, Result> { let deliveries: Vec> = (0..2) .map(|_| { Ok(EventEnvelope::new( Some(self.id), self.event.clone(), Box::new(NoopAckHandle), )) }) .collect(); Box::pin(stream::iter(deliveries)) } } #[tokio::test] async fn a_redelivered_event_is_skipped_by_the_ledger() { let f = fixture(false, true).await; let consumer = TwiceConsumer { id: EventId::new(), event: deleted(&f.user), }; application::worker::WorkerService::new( Arc::new(consumer), vec![f.handler.clone() as Arc], f.store.clone(), ) .unwrap() .run(tokio::sync::watch::channel(false).1) .await; let purges = entries(&f.log).iter().filter(|e| *e == "purge").count(); assert_eq!(purges, 1); } #[tokio::test] async fn a_rerun_after_a_crash_following_the_commit_deletes_the_images() { let f = fixture(false, true).await; *f.purger.purged.lock().unwrap() = true; f.handler.handle(&deleted(&f.user)).await.unwrap(); assert_eq!( entries(&f.log), [ "purge", "delete avatars/a.webp", "delete banners/a.webp", "forget" ], "no second federation notice, images removed and forgotten" ); }