integration_pipeline.rs
187 lignes · 6814 octets
//! End-to-end integration tests for the guard pipeline. //! //! These exercise the full chain — `GuardObserver` ⇒ detectors ⇒ //! `BanManager::auto_ban` ⇒ `MemoryStore` — without spinning up Docker //! or Postgres. They complement the per-module unit tests by proving //! that the wiring between layers actually flips the ban decision once //! a detector trips. //! //! For the docker-compose-based testbed flow (3 instances, signed //! requests over HTTP) see `tests/fediverse-testbed/` and the runbook //! at `docs/fediverse-guard.md`. use std::sync::Arc; use chrono::Utc; use rustwarden_fediverse::observer::FediverseEventObserver; use rustwarden_fediverse_guard::{ BanDecision, BanManager, GuardConfig, GuardObserver, GuardStore, MemoryStore, default_detectors, }; fn fresh_observer(cfg: GuardConfig) -> (GuardObserver, BanManager) { let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); let manager = BanManager::new(store.clone(), cfg.clone()); let detectors = default_detectors(&cfg); let observer = GuardObserver::new(manager.clone(), detectors, None); (observer, manager) } #[tokio::test] async fn signature_failures_above_threshold_trigger_auto_ban() { // for_tests sets signature_failures = (3, 60), so 4 distinct // failures within the window must trip the detector and persist a // ban that the manager surfaces on the next is_banned call. let cfg = GuardConfig::for_tests(); let (observer, manager) = fresh_observer(cfg); let domain = "spammer.tld"; for i in 0..4 { observer .on_signature_failure(domain, None, &format!("bad sig {i}")) .await; } let decision = manager.decide(domain).await.expect("decide"); assert!( matches!(decision, BanDecision::Deny), "expected Banned, got {decision:?}" ); } #[tokio::test] async fn replay_attempts_above_threshold_trigger_auto_ban() { // for_tests sets replay_attempts = (2, 60); 3 replays must ban. let cfg = GuardConfig::for_tests(); let (observer, manager) = fresh_observer(cfg); let domain = "replay.tld"; for _ in 0..3 { observer .on_replay_attempt(domain, None, serde_json::json!({"skew_secs": 600})) .await; } assert!(manager.is_banned(domain).await.expect("is_banned")); } #[tokio::test] async fn ssrf_attempts_above_threshold_trigger_auto_ban() { // for_tests sets fetch_ssrf = (3, 60); 4 hits must ban. let cfg = GuardConfig::for_tests(); let (observer, manager) = fresh_observer(cfg); let host = "metadata.local"; for _ in 0..4 { observer.on_fetch_ssrf(host, Some("169.254.169.254")).await; } assert!(manager.is_banned(host).await.expect("is_banned")); } #[tokio::test] async fn unrelated_domains_are_not_collateral_banned() { // Two distinct hostile domains. A flood from `bad.tld` must not // affect the decision for `nice.tld` — the detectors are domain- // scoped and the BanEntry rows live on a per-domain key. let cfg = GuardConfig::for_tests(); let (observer, manager) = fresh_observer(cfg); for _ in 0..10 { observer.on_signature_failure("bad.tld", None, "x").await; } assert!(manager.is_banned("bad.tld").await.expect("is_banned")); assert!(!manager.is_banned("nice.tld").await.expect("is_banned")); } #[tokio::test] async fn disabled_guard_swallows_events() { // When `enabled = false`, the observer must drop everything on the // floor — no event is stored, no detector runs, no ban appears. let mut cfg = GuardConfig::for_tests(); cfg.enabled = false; let (observer, manager) = fresh_observer(cfg); let domain = "ignored.tld"; for _ in 0..50 { observer.on_signature_failure(domain, None, "bad").await; } let active = manager .store() .active_bans_for_domain(domain, Utc::now()) .await .expect("active_bans"); assert!( active.is_empty(), "guard disabled must not insert any ban; got {active:?}" ); } #[tokio::test] async fn manual_ban_overrides_default_allow() { // No detector fires, but an admin inserts a ban directly via the // store. The manager must surface it as Banned on the next decide. let cfg = GuardConfig::for_tests(); let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); let manager = BanManager::new(store.clone(), cfg); let now = Utc::now(); let ban = rustwarden_fediverse_guard::BanEntry { id: uuid::Uuid::new_v4(), domain: "manual.tld".into(), actor_uri: None, reason: "test manual".into(), severity: "suspend".into(), triggered_by: "manual:admin".into(), banned_at: now, expires_at: Some(now + chrono::Duration::hours(1)), lifted_at: None, lifted_by: None, }; store.insert_ban(&ban).await.expect("insert_ban"); assert!(manager.is_banned("manual.tld").await.expect("is_banned")); } #[tokio::test] async fn allow_acl_bypasses_active_ban() { // Documented precedence (cf. docs/fediverse-guard.md §5): // ACL deny > active ban > ACL allow > default allow // An ACL `allow` after a ban exists should NOT lift the ban — the // active ban still wins. Test the converse: when ONLY an allow ACL // is present (no ban), the precedence resolves to Allowed. let cfg = GuardConfig::for_tests(); let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); let manager = BanManager::new(store.clone(), cfg); let now = Utc::now(); let acl = rustwarden_fediverse_guard::AclEntry { id: uuid::Uuid::new_v4(), kind: rustwarden_fediverse_guard::AclKind::Allow, domain: "partner.tld".into(), reason: Some("trusted partner".into()), created_by: uuid::Uuid::new_v4(), created_at: now, updated_at: now, }; store.upsert_acl(&acl).await.expect("upsert_acl"); let decision = manager.decide("partner.tld").await.expect("decide"); assert!(matches!(decision, BanDecision::Allow), "got {decision:?}"); } #[tokio::test] async fn deny_acl_overrides_default_allow() { let cfg = GuardConfig::for_tests(); let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); let manager = BanManager::new(store.clone(), cfg); let now = Utc::now(); let acl = rustwarden_fediverse_guard::AclEntry { id: uuid::Uuid::new_v4(), kind: rustwarden_fediverse_guard::AclKind::Deny, domain: "evil.tld".into(), reason: Some("known bad".into()), created_by: uuid::Uuid::new_v4(), created_at: now, updated_at: now, }; store.upsert_acl(&acl).await.expect("upsert_acl"); assert!(manager.is_banned("evil.tld").await.expect("is_banned")); }
GitRust