Files
movies-diary/crates/adapters/activitypub/tests/activity_ids.rs

267 lines
8.6 KiB
Rust

//! Outbound activity ids across a redelivered event, through the real k-ap
//! `ActivityPubService` (delivery captured via its `EventPublisher` seam).
//!
//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5,
//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids
//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`,
//! `Uuid::new_v4`) with no override.
use std::sync::Arc;
use std::time::Duration;
use activitypub::ActivityPubEventHandler;
use async_trait::async_trait;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, WatchlistWithMovie},
ports::{
EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository,
},
testing::{
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
},
value_objects::{InstanceIdentity, MovieId, UserId},
};
use k_ap::{
ActivityPubService, ApUser, FederationEvent,
testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder},
};
use tokio::sync::mpsc;
struct NoContent;
#[async_trait]
impl LocalApContentQuery for NoContent {
async fn get_local_watchlist_for_user(
&self,
_: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_for_movie(
&self,
_: &MovieId,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_page(
&self,
_: &UserId,
_: Option<chrono::NaiveDateTime>,
_: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
}
struct Capture(mpsc::UnboundedSender<serde_json::Value>);
#[async_trait]
impl k_ap::EventPublisher for Capture {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
if let FederationEvent::DeliveryRequested { activity, .. } = event {
let _ = self.0.send(activity);
}
Ok(())
}
}
struct Harness {
handler: ActivityPubEventHandler,
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
user: UserId,
movie_id: MovieId,
store: Arc<InMemoryStore>,
service: Arc<ActivityPubService>,
}
async fn harness() -> Harness {
let user = UserId::from_uuid(uuid::Uuid::new_v4());
let uid = user.value();
let (tx, rx) = mpsc::unbounded_channel();
let service = ActivityPubService::builder("https://diary.example")
.activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build())
.follow_repo(
MockFollowRepoBuilder::new()
.on_get_accepted_follower_inboxes(|_| {
Ok(vec!["https://remote.example/inbox".to_string()])
})
.build(),
)
.actor_repo(MockActorRepoBuilder::new().build())
.blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build())
.user_repo(
MockUserRepoBuilder::new()
.on_find_by_id(move |id| {
Ok((id == uid).then(|| ApUser {
id,
username: "alice".into(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: false,
discoverable: true,
actor_type: Default::default(),
featured_url: None,
}))
})
.build(),
)
.content_reader(k_ap::testing::MockContentReaderBuilder::new().build())
.object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build())
.event_publisher(Arc::new(Capture(tx)))
.build()
.await
.expect("service builds");
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::clone(&service),
Arc::new(NoContent),
Arc::new(PanicReviewRepository),
store.clone(),
Arc::new(PanicGoalQuery),
Arc::new(PanicStatsRepository),
store.clone(),
store.clone(),
InstanceIdentity::new("https://diary.example"),
);
Harness {
handler,
delivered: rx,
user,
movie_id: movie.id().clone(),
store,
service,
}
}
impl Harness {
async fn next_activity_id(&mut self) -> String {
let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv())
.await
.expect("an activity was delivered")
.expect("channel open");
activity["id"]
.as_str()
.expect("activity has an id")
.to_string()
}
}
#[tokio::test]
async fn activitypub_is_idempotent_when_run_twice() {
let mut h = harness().await;
let event = 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(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert!(first.starts_with("https://diary.example/activities/create/"));
assert_eq!(first, second, "a redelivered Create keeps its activity id");
}
/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts
/// minting deterministic Delete ids this fails: upgrade the ADR and flip it.
#[tokio::test]
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryRemoved {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
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");
}