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"));
}