Skip to main content

synapse_pingora/interrogator/
progression_manager.rs

1//! Progression Manager - Challenge Escalation Orchestrator
2//!
3//! Orchestrates the progressive challenge escalation system. Based on actor
4//! behavior, risk score, and challenge history, selects the appropriate
5//! challenge level and manages escalation/de-escalation.
6//!
7//! # Challenge Levels
8//!
9//! 1. **Cookie (Level 1)**: Silent tracking, no user impact
10//! 2. **JS PoW (Level 2)**: Computational challenge, ~1-5s delay
11//! 3. **CAPTCHA (Level 3)**: Human verification, requires interaction
12//! 4. **Tarpit (Level 4)**: Progressive delays, degrades experience
13//! 5. **Block (Level 5)**: Hard block with custom page
14//!
15//! # Risk Score Mapping
16//!
17//! - 0.0-0.2: No challenge (Allow)
18//! - 0.2-0.4: Cookie challenge
19//! - 0.4-0.6: JS PoW challenge
20//! - 0.6-0.8: CAPTCHA or Tarpit
21//! - 0.8-1.0: Block
22//!
23//! # Escalation Rules
24//!
25//! - Failed challenge → increment failure count
26//! - 3+ failures at level → escalate to next level
27//! - 10+ total failures → skip to block
28//! - 1 hour without incident → de-escalate one level
29//!
30//! # Integration
31//!
32//! The ProgressionManager integrates with:
33//! - CookieManager: For level 1 challenges
34//! - JsChallengeManager: For level 2 challenges
35//! - TarpitManager: For level 4 challenges (from src/tarpit/)
36
37use dashmap::DashMap;
38use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
39use std::sync::Arc;
40use std::time::{Duration, SystemTime, UNIX_EPOCH};
41use tokio::sync::Notify;
42
43use super::{
44    CaptchaManager, ChallengeResponse, CookieManager, JsChallengeManager, ValidationResult,
45};
46use crate::tarpit::TarpitManager;
47
48/// Challenge levels for progressive escalation
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
50#[repr(u8)]
51pub enum ChallengeLevel {
52    None = 0,
53    Cookie = 1,
54    JsChallenge = 2,
55    Captcha = 3,
56    Tarpit = 4,
57    Block = 5,
58}
59
60impl ChallengeLevel {
61    /// Get level from numeric value
62    pub fn from_u8(value: u8) -> Self {
63        match value {
64            0 => ChallengeLevel::None,
65            1 => ChallengeLevel::Cookie,
66            2 => ChallengeLevel::JsChallenge,
67            3 => ChallengeLevel::Captcha,
68            4 => ChallengeLevel::Tarpit,
69            _ => ChallengeLevel::Block,
70        }
71    }
72
73    /// Get display name
74    pub fn name(&self) -> &'static str {
75        match self {
76            ChallengeLevel::None => "none",
77            ChallengeLevel::Cookie => "cookie",
78            ChallengeLevel::JsChallenge => "js_challenge",
79            ChallengeLevel::Captcha => "captcha",
80            ChallengeLevel::Tarpit => "tarpit",
81            ChallengeLevel::Block => "block",
82        }
83    }
84}
85
86impl std::fmt::Display for ChallengeLevel {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}", self.name())
89    }
90}
91
92/// Per-actor challenge state
93#[derive(Debug, Clone)]
94pub struct ActorChallengeState {
95    /// Actor identifier
96    pub actor_id: String,
97    /// Current challenge level
98    pub current_level: ChallengeLevel,
99    /// Failures at current level
100    pub failures_at_level: u32,
101    /// Last challenge timestamp (ms since epoch)
102    pub last_challenge_time: u64,
103    /// Total failures across all levels
104    pub total_failures: u32,
105    /// History of escalations: (level, timestamp)
106    pub escalation_history: Vec<(ChallengeLevel, u64)>,
107    /// Last successful challenge completion (ms since epoch)
108    pub last_success_time: Option<u64>,
109}
110
111impl ActorChallengeState {
112    /// Create a new actor state
113    pub fn new(actor_id: String) -> Self {
114        Self {
115            actor_id,
116            current_level: ChallengeLevel::None,
117            failures_at_level: 0,
118            last_challenge_time: 0,
119            total_failures: 0,
120            escalation_history: Vec::new(),
121            last_success_time: None,
122        }
123    }
124}
125
126/// Configuration for progression manager
127#[derive(Debug, Clone)]
128pub struct ProgressionConfig {
129    /// Failures before escalating to next level (default: 3)
130    pub failures_before_escalate: u32,
131    /// Cooldown between escalations in seconds (default: 60)
132    pub escalation_cooldown_secs: u64,
133    /// Time without incident before de-escalating in seconds (default: 3600 = 1 hour)
134    pub auto_de_escalate_secs: u64,
135    /// Total failures that skip directly to block (default: 10)
136    pub skip_to_block_threshold: u32,
137    /// Enable cookie challenges (default: true)
138    pub enable_cookie: bool,
139    /// Enable JS PoW challenges (default: true)
140    pub enable_js_challenge: bool,
141    /// Enable CAPTCHA challenges (default: false - stub)
142    pub enable_captcha: bool,
143    /// Enable tarpit challenges (default: true)
144    pub enable_tarpit: bool,
145    /// Risk score threshold for cookie (default: 0.2)
146    pub risk_threshold_cookie: f64,
147    /// Risk score threshold for JS challenge (default: 0.4)
148    pub risk_threshold_js: f64,
149    /// Risk score threshold for CAPTCHA/tarpit (default: 0.6)
150    pub risk_threshold_captcha: f64,
151    /// Risk score threshold for block (default: 0.8)
152    pub risk_threshold_block: f64,
153    /// Block page HTML template
154    pub block_page_html: String,
155    /// Block status code (default: 403)
156    pub block_status_code: u16,
157    /// CAPTCHA page HTML template (stub)
158    pub captcha_page_html: String,
159    /// Background cleanup interval in seconds (default: 300)
160    pub cleanup_interval_secs: u64,
161    /// Max states to track (default: 100_000)
162    pub max_states: usize,
163    /// Max escalation history entries per actor (default: 100)
164    /// Prevents unbounded memory growth from malicious actors
165    pub max_escalation_history: usize,
166}
167
168impl Default for ProgressionConfig {
169    fn default() -> Self {
170        Self {
171            failures_before_escalate: 3,
172            escalation_cooldown_secs: 60,
173            auto_de_escalate_secs: 3600, // 1 hour
174            skip_to_block_threshold: 10,
175            enable_cookie: true,
176            enable_js_challenge: true,
177            enable_captcha: false, // Stub
178            enable_tarpit: true,
179            risk_threshold_cookie: 0.2,
180            risk_threshold_js: 0.4,
181            risk_threshold_captcha: 0.6,
182            risk_threshold_block: 0.8,
183            block_page_html: DEFAULT_BLOCK_PAGE.to_string(),
184            block_status_code: 403,
185            captcha_page_html: DEFAULT_CAPTCHA_PAGE.to_string(),
186            cleanup_interval_secs: 300,
187            max_states: 100_000,
188            max_escalation_history: 100, // Prevents memory exhaustion
189        }
190    }
191}
192
193/// Statistics for progression manager
194#[derive(Debug, Default)]
195pub struct ProgressionStats {
196    /// Total actors tracked
197    pub actors_tracked: AtomicU64,
198    /// Total escalations
199    pub escalations: AtomicU64,
200    /// Total de-escalations
201    pub de_escalations: AtomicU64,
202    /// Direct blocks (skipped escalation)
203    pub direct_blocks: AtomicU64,
204    /// Total challenges issued
205    pub challenges_issued: AtomicU64,
206    /// Total challenge successes
207    pub successes: AtomicU64,
208    /// Total challenge failures
209    pub failures: AtomicU64,
210}
211
212impl ProgressionStats {
213    /// Create a snapshot of current stats
214    pub fn snapshot(&self) -> ProgressionStatsSnapshot {
215        ProgressionStatsSnapshot {
216            actors_tracked: self.actors_tracked.load(Ordering::Relaxed),
217            escalations: self.escalations.load(Ordering::Relaxed),
218            de_escalations: self.de_escalations.load(Ordering::Relaxed),
219            direct_blocks: self.direct_blocks.load(Ordering::Relaxed),
220            challenges_issued: self.challenges_issued.load(Ordering::Relaxed),
221            successes: self.successes.load(Ordering::Relaxed),
222            failures: self.failures.load(Ordering::Relaxed),
223        }
224    }
225}
226
227/// Snapshot of progression stats for serialization
228#[derive(Debug, Clone, serde::Serialize)]
229pub struct ProgressionStatsSnapshot {
230    pub actors_tracked: u64,
231    pub escalations: u64,
232    pub de_escalations: u64,
233    pub direct_blocks: u64,
234    pub challenges_issued: u64,
235    pub successes: u64,
236    pub failures: u64,
237}
238
239/// Progressive challenge escalation manager
240pub struct ProgressionManager {
241    /// Per-actor challenge states
242    actor_states: DashMap<String, ActorChallengeState>,
243    /// Cookie challenge manager
244    cookie_manager: Arc<CookieManager>,
245    /// JS challenge manager
246    js_manager: Arc<JsChallengeManager>,
247    /// CAPTCHA challenge manager
248    captcha_manager: Arc<CaptchaManager>,
249    /// Tarpit manager
250    tarpit_manager: Arc<TarpitManager>,
251    /// Configuration
252    config: ProgressionConfig,
253    /// Statistics
254    stats: ProgressionStats,
255    /// Shutdown signal for background tasks
256    shutdown: Arc<Notify>,
257    /// Shutdown flag to check if shutdown was requested
258    shutdown_flag: Arc<AtomicBool>,
259}
260
261impl ProgressionManager {
262    /// Create a new progression manager
263    pub fn new(
264        cookie_manager: Arc<CookieManager>,
265        js_manager: Arc<JsChallengeManager>,
266        captcha_manager: Arc<CaptchaManager>,
267        tarpit_manager: Arc<TarpitManager>,
268        config: ProgressionConfig,
269    ) -> Self {
270        Self {
271            actor_states: DashMap::with_capacity(config.max_states.min(10_000)),
272            cookie_manager,
273            js_manager,
274            captcha_manager,
275            tarpit_manager,
276            config,
277            stats: ProgressionStats::default(),
278            shutdown: Arc::new(Notify::new()),
279            shutdown_flag: Arc::new(AtomicBool::new(false)),
280        }
281    }
282
283    /// Get configuration
284    pub fn config(&self) -> &ProgressionConfig {
285        &self.config
286    }
287
288    /// Get current statistics
289    pub fn stats(&self) -> &ProgressionStats {
290        &self.stats
291    }
292
293    /// Push an entry to escalation history with bounds checking.
294    /// If at capacity, removes oldest entry before adding new one.
295    /// This prevents memory exhaustion from malicious actors.
296    fn push_escalation_history(
297        &self,
298        state: &mut ActorChallengeState,
299        level: ChallengeLevel,
300        timestamp: u64,
301    ) {
302        // Remove oldest if at capacity
303        if state.escalation_history.len() >= self.config.max_escalation_history {
304            state.escalation_history.remove(0);
305        }
306        state.escalation_history.push((level, timestamp));
307    }
308
309    /// Get appropriate challenge for actor based on risk score and history
310    pub fn get_challenge(&self, actor_id: &str, risk_score: f64) -> ChallengeResponse {
311        let now = now_ms();
312
313        // Get or create actor state
314        let mut state = self.get_or_create_state(actor_id);
315
316        // Check for auto de-escalation
317        self.check_auto_de_escalate(&mut state, now);
318
319        // Determine effective level based on risk score and current state
320        let effective_level = self.determine_effective_level(&state, risk_score);
321
322        // Update state
323        state.last_challenge_time = now;
324        state.current_level = effective_level;
325
326        // Store updated state
327        self.actor_states.insert(actor_id.to_string(), state);
328
329        self.stats.challenges_issued.fetch_add(1, Ordering::Relaxed);
330
331        // Generate challenge for level
332        self.get_challenge_for_level(actor_id, effective_level)
333    }
334
335    /// Record a failed challenge attempt
336    pub fn record_failure(&self, actor_id: &str) {
337        let now = now_ms();
338
339        let mut state = self.get_or_create_state(actor_id);
340        state.failures_at_level += 1;
341        state.total_failures += 1;
342        state.last_challenge_time = now;
343
344        self.stats.failures.fetch_add(1, Ordering::Relaxed);
345
346        // Check if should escalate
347        if state.total_failures >= self.config.skip_to_block_threshold {
348            // Skip directly to block
349            if state.current_level != ChallengeLevel::Block {
350                self.push_escalation_history(&mut state, ChallengeLevel::Block, now);
351                state.current_level = ChallengeLevel::Block;
352                state.failures_at_level = 0;
353                self.stats.direct_blocks.fetch_add(1, Ordering::Relaxed);
354            }
355        } else if state.failures_at_level >= self.config.failures_before_escalate {
356            // Normal escalation
357            let next_level = self.next_level(state.current_level);
358            if next_level != state.current_level {
359                self.push_escalation_history(&mut state, next_level, now);
360                state.current_level = next_level;
361                state.failures_at_level = 0;
362                self.stats.escalations.fetch_add(1, Ordering::Relaxed);
363            }
364        }
365
366        self.actor_states.insert(actor_id.to_string(), state);
367    }
368
369    /// Record a successful challenge completion
370    pub fn record_success(&self, actor_id: &str) {
371        let now = now_ms();
372
373        if let Some(mut entry) = self.actor_states.get_mut(actor_id) {
374            entry.failures_at_level = 0;
375            entry.last_success_time = Some(now);
376            self.stats.successes.fetch_add(1, Ordering::Relaxed);
377        }
378    }
379
380    /// Manually escalate an actor
381    pub fn escalate(&self, actor_id: &str) -> ChallengeLevel {
382        let now = now_ms();
383
384        let mut state = self.get_or_create_state(actor_id);
385        let next_level = self.next_level(state.current_level);
386
387        if next_level != state.current_level {
388            self.push_escalation_history(&mut state, next_level, now);
389            state.current_level = next_level;
390            state.failures_at_level = 0;
391            self.stats.escalations.fetch_add(1, Ordering::Relaxed);
392        }
393
394        let level = state.current_level;
395        self.actor_states.insert(actor_id.to_string(), state);
396        level
397    }
398
399    /// Manually de-escalate an actor
400    pub fn de_escalate(&self, actor_id: &str) -> ChallengeLevel {
401        let now = now_ms();
402
403        let mut state = self.get_or_create_state(actor_id);
404        let prev_level = self.prev_level(state.current_level);
405
406        if prev_level != state.current_level {
407            self.push_escalation_history(&mut state, prev_level, now);
408            state.current_level = prev_level;
409            state.failures_at_level = 0;
410            self.stats.de_escalations.fetch_add(1, Ordering::Relaxed);
411        }
412
413        let level = state.current_level;
414        self.actor_states.insert(actor_id.to_string(), state);
415        level
416    }
417
418    /// Reset actor to no challenge
419    pub fn reset(&self, actor_id: &str) {
420        self.actor_states.remove(actor_id);
421    }
422
423    /// Get current level for actor
424    pub fn get_level(&self, actor_id: &str) -> ChallengeLevel {
425        self.actor_states
426            .get(actor_id)
427            .map(|s| s.current_level)
428            .unwrap_or(ChallengeLevel::None)
429    }
430
431    /// Get actor state
432    pub fn get_actor_state(&self, actor_id: &str) -> Option<ActorChallengeState> {
433        self.actor_states.get(actor_id).map(|s| s.clone())
434    }
435
436    /// List actors at a specific challenge level
437    pub fn list_actors_at_level(&self, level: ChallengeLevel) -> Vec<ActorChallengeState> {
438        self.actor_states
439            .iter()
440            .filter(|e| e.value().current_level == level)
441            .map(|e| e.value().clone())
442            .collect()
443    }
444
445    /// List all tracked actors
446    pub fn list_all_actors(&self) -> Vec<ActorChallengeState> {
447        self.actor_states
448            .iter()
449            .map(|e| e.value().clone())
450            .collect()
451    }
452
453    /// Start background tasks (de-escalation, cleanup)
454    ///
455    /// Spawns a background task that periodically runs maintenance.
456    /// The task will exit cleanly when `shutdown()` is called.
457    pub fn start_background_tasks(self: Arc<Self>) {
458        let manager = self.clone();
459        let interval = Duration::from_secs(self.config.cleanup_interval_secs);
460        let shutdown = self.shutdown.clone();
461        let shutdown_flag = self.shutdown_flag.clone();
462
463        tokio::spawn(async move {
464            let mut interval_timer = tokio::time::interval(interval);
465
466            loop {
467                tokio::select! {
468                    _ = interval_timer.tick() => {
469                        // Check shutdown flag before running maintenance
470                        if shutdown_flag.load(Ordering::Relaxed) {
471                            log::info!("Progression manager background tasks shutting down (flag)");
472                            break;
473                        }
474                        manager.run_maintenance();
475                    }
476                    _ = shutdown.notified() => {
477                        log::info!("Progression manager background tasks shutting down");
478                        break;
479                    }
480                }
481            }
482        });
483    }
484
485    /// Signal shutdown for background tasks.
486    ///
487    /// This method signals the background maintenance task to stop.
488    /// The task will exit after completing any in-progress work.
489    pub fn shutdown(&self) {
490        self.shutdown_flag.store(true, Ordering::Relaxed);
491        self.shutdown.notify_one();
492    }
493
494    /// Run maintenance tasks (de-escalation, cleanup)
495    pub fn run_maintenance(&self) {
496        let now = now_ms();
497
498        // Auto de-escalate eligible actors
499        let mut to_de_escalate = Vec::new();
500        for entry in self.actor_states.iter() {
501            let state = entry.value();
502            let idle_time = now.saturating_sub(state.last_challenge_time);
503            let de_escalate_threshold_ms = self.config.auto_de_escalate_secs * 1000;
504
505            if idle_time > de_escalate_threshold_ms && state.current_level > ChallengeLevel::None {
506                to_de_escalate.push(entry.key().clone());
507            }
508        }
509
510        for actor_id in to_de_escalate {
511            self.de_escalate(&actor_id);
512        }
513
514        // Cleanup old states if over capacity
515        if self.actor_states.len() > self.config.max_states {
516            // Find oldest states
517            let mut actors: Vec<_> = self
518                .actor_states
519                .iter()
520                .map(|e| (e.key().clone(), e.value().last_challenge_time))
521                .collect();
522            actors.sort_by_key(|(_, time)| *time);
523
524            // Remove oldest 10%
525            let to_remove = self.config.max_states / 10;
526            for (actor_id, _) in actors.into_iter().take(to_remove) {
527                self.actor_states.remove(&actor_id);
528            }
529        }
530    }
531
532    /// Get number of tracked actors
533    pub fn len(&self) -> usize {
534        self.actor_states.len()
535    }
536
537    /// Get cookie challenge name for validation.
538    pub fn cookie_name(&self) -> &str {
539        self.cookie_manager.config().cookie_name.as_str()
540    }
541
542    /// Check if no actors are tracked
543    pub fn is_empty(&self) -> bool {
544        self.actor_states.is_empty()
545    }
546
547    /// Clear all state
548    pub fn clear(&self) {
549        self.actor_states.clear();
550    }
551
552    /// Validate a challenge response for an actor
553    pub fn validate_challenge(&self, actor_id: &str, response: &str) -> ValidationResult {
554        let level = self.get_level(actor_id);
555
556        let result = match level {
557            ChallengeLevel::Cookie => self.cookie_manager.validate_cookie(actor_id, response),
558            ChallengeLevel::JsChallenge => self.js_manager.validate_pow(actor_id, response),
559            ChallengeLevel::Captcha => self.captcha_manager.validate_response(actor_id, response),
560            _ => ValidationResult::NotFound,
561        };
562
563        match &result {
564            ValidationResult::Valid => self.record_success(actor_id),
565            ValidationResult::Invalid(_) | ValidationResult::Expired => {
566                self.record_failure(actor_id)
567            }
568            ValidationResult::NotFound => {}
569        }
570
571        result
572    }
573
574    // --- Private helpers ---
575
576    /// Get or create actor state atomically.
577    ///
578    /// Uses DashMap's entry API to avoid race conditions between checking
579    /// for existence and creating a new state.
580    fn get_or_create_state(&self, actor_id: &str) -> ActorChallengeState {
581        // Use entry API for atomic get-or-insert to prevent race conditions
582        let entry = self.actor_states.entry(actor_id.to_string());
583        match entry {
584            dashmap::mapref::entry::Entry::Occupied(occupied) => occupied.get().clone(),
585            dashmap::mapref::entry::Entry::Vacant(vacant) => {
586                self.stats.actors_tracked.fetch_add(1, Ordering::Relaxed);
587                let state = ActorChallengeState::new(actor_id.to_string());
588                vacant.insert(state.clone());
589                state
590            }
591        }
592    }
593
594    /// Check and apply auto de-escalation
595    fn check_auto_de_escalate(&self, state: &mut ActorChallengeState, now: u64) {
596        if state.current_level == ChallengeLevel::None {
597            return;
598        }
599
600        let idle_time = now.saturating_sub(state.last_challenge_time);
601        let de_escalate_threshold_ms = self.config.auto_de_escalate_secs * 1000;
602
603        if idle_time > de_escalate_threshold_ms {
604            let prev_level = self.prev_level(state.current_level);
605            if prev_level != state.current_level {
606                self.push_escalation_history(state, prev_level, now);
607                state.current_level = prev_level;
608                state.failures_at_level = 0;
609                self.stats.de_escalations.fetch_add(1, Ordering::Relaxed);
610            }
611        }
612    }
613
614    /// Determine effective level based on state and risk score
615    fn determine_effective_level(
616        &self,
617        state: &ActorChallengeState,
618        risk_score: f64,
619    ) -> ChallengeLevel {
620        // Determine level from risk score
621        let risk_level = self.determine_initial_level(risk_score);
622
623        // Use the higher of current level or risk-based level
624        // (actors can be escalated above their risk score due to behavior)
625        std::cmp::max(state.current_level, risk_level)
626    }
627
628    /// Determine initial level from risk score
629    fn determine_initial_level(&self, risk_score: f64) -> ChallengeLevel {
630        if risk_score >= self.config.risk_threshold_block {
631            ChallengeLevel::Block
632        } else if risk_score >= self.config.risk_threshold_captcha {
633            if self.config.enable_captcha {
634                ChallengeLevel::Captcha
635            } else if self.config.enable_tarpit {
636                ChallengeLevel::Tarpit
637            } else {
638                ChallengeLevel::JsChallenge
639            }
640        } else if risk_score >= self.config.risk_threshold_js {
641            if self.config.enable_js_challenge {
642                ChallengeLevel::JsChallenge
643            } else if self.config.enable_cookie {
644                ChallengeLevel::Cookie
645            } else {
646                ChallengeLevel::None
647            }
648        } else if risk_score >= self.config.risk_threshold_cookie {
649            if self.config.enable_cookie {
650                ChallengeLevel::Cookie
651            } else {
652                ChallengeLevel::None
653            }
654        } else {
655            ChallengeLevel::None
656        }
657    }
658
659    /// Get next level (escalate)
660    fn next_level(&self, current: ChallengeLevel) -> ChallengeLevel {
661        match current {
662            ChallengeLevel::None => {
663                if self.config.enable_cookie {
664                    ChallengeLevel::Cookie
665                } else if self.config.enable_js_challenge {
666                    ChallengeLevel::JsChallenge
667                } else if self.config.enable_captcha {
668                    ChallengeLevel::Captcha
669                } else if self.config.enable_tarpit {
670                    ChallengeLevel::Tarpit
671                } else {
672                    ChallengeLevel::Block
673                }
674            }
675            ChallengeLevel::Cookie => {
676                if self.config.enable_js_challenge {
677                    ChallengeLevel::JsChallenge
678                } else if self.config.enable_captcha {
679                    ChallengeLevel::Captcha
680                } else if self.config.enable_tarpit {
681                    ChallengeLevel::Tarpit
682                } else {
683                    ChallengeLevel::Block
684                }
685            }
686            ChallengeLevel::JsChallenge => {
687                if self.config.enable_captcha {
688                    ChallengeLevel::Captcha
689                } else if self.config.enable_tarpit {
690                    ChallengeLevel::Tarpit
691                } else {
692                    ChallengeLevel::Block
693                }
694            }
695            ChallengeLevel::Captcha => {
696                if self.config.enable_tarpit {
697                    ChallengeLevel::Tarpit
698                } else {
699                    ChallengeLevel::Block
700                }
701            }
702            ChallengeLevel::Tarpit => ChallengeLevel::Block,
703            ChallengeLevel::Block => ChallengeLevel::Block, // Can't escalate beyond block
704        }
705    }
706
707    /// Get previous level (de-escalate)
708    fn prev_level(&self, current: ChallengeLevel) -> ChallengeLevel {
709        match current {
710            ChallengeLevel::Block => {
711                if self.config.enable_tarpit {
712                    ChallengeLevel::Tarpit
713                } else if self.config.enable_captcha {
714                    ChallengeLevel::Captcha
715                } else if self.config.enable_js_challenge {
716                    ChallengeLevel::JsChallenge
717                } else if self.config.enable_cookie {
718                    ChallengeLevel::Cookie
719                } else {
720                    ChallengeLevel::None
721                }
722            }
723            ChallengeLevel::Tarpit => {
724                if self.config.enable_captcha {
725                    ChallengeLevel::Captcha
726                } else if self.config.enable_js_challenge {
727                    ChallengeLevel::JsChallenge
728                } else if self.config.enable_cookie {
729                    ChallengeLevel::Cookie
730                } else {
731                    ChallengeLevel::None
732                }
733            }
734            ChallengeLevel::Captcha => {
735                if self.config.enable_js_challenge {
736                    ChallengeLevel::JsChallenge
737                } else if self.config.enable_cookie {
738                    ChallengeLevel::Cookie
739                } else {
740                    ChallengeLevel::None
741                }
742            }
743            ChallengeLevel::JsChallenge => {
744                if self.config.enable_cookie {
745                    ChallengeLevel::Cookie
746                } else {
747                    ChallengeLevel::None
748                }
749            }
750            ChallengeLevel::Cookie => ChallengeLevel::None,
751            ChallengeLevel::None => ChallengeLevel::None, // Can't de-escalate below none
752        }
753    }
754
755    /// Get challenge response for a level
756    fn get_challenge_for_level(&self, actor_id: &str, level: ChallengeLevel) -> ChallengeResponse {
757        match level {
758            ChallengeLevel::None => ChallengeResponse::Allow,
759
760            ChallengeLevel::Cookie => {
761                let challenge = self.cookie_manager.generate_tracking_cookie(actor_id);
762                ChallengeResponse::Cookie {
763                    name: challenge.cookie_name,
764                    value: challenge.cookie_value,
765                    max_age: self.cookie_manager.config().cookie_max_age_secs,
766                    http_only: self.cookie_manager.config().http_only,
767                    secure: self.cookie_manager.config().secure_only,
768                }
769            }
770
771            ChallengeLevel::JsChallenge => {
772                let challenge = self.js_manager.generate_pow_challenge(actor_id);
773                let html = self.js_manager.generate_challenge_page(&challenge);
774                ChallengeResponse::JsChallenge {
775                    html,
776                    expected_solution: challenge.expected_hash_prefix,
777                    expires_at: challenge.expires_at,
778                }
779            }
780
781            ChallengeLevel::Captcha => {
782                let challenge = self.captcha_manager.issue_challenge(actor_id);
783                ChallengeResponse::Captcha {
784                    html: challenge.html,
785                    session_id: challenge.session_id,
786                }
787            }
788
789            ChallengeLevel::Tarpit => {
790                let decision = self.tarpit_manager.tarpit(actor_id);
791                ChallengeResponse::Tarpit {
792                    delay_ms: decision.delay_ms,
793                }
794            }
795
796            ChallengeLevel::Block => ChallengeResponse::Block {
797                html: self.config.block_page_html.clone(),
798                status_code: self.config.block_status_code,
799            },
800        }
801    }
802}
803
804/// Get current time in milliseconds since Unix epoch
805#[inline]
806fn now_ms() -> u64 {
807    SystemTime::now()
808        .duration_since(UNIX_EPOCH)
809        .map(|d| d.as_millis() as u64)
810        .unwrap_or(0)
811}
812
813/// Default block page HTML
814const DEFAULT_BLOCK_PAGE: &str = r#"<!DOCTYPE html>
815<html>
816<head>
817    <meta charset="UTF-8">
818    <meta name="viewport" content="width=device-width, initial-scale=1.0">
819    <title>Access Denied</title>
820    <style>
821        body {
822            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
823            display: flex;
824            justify-content: center;
825            align-items: center;
826            min-height: 100vh;
827            margin: 0;
828            background: linear-gradient(135deg, #e53e3e 0%, #9b2c2c 100%);
829        }
830        .container {
831            background: white;
832            padding: 2rem;
833            border-radius: 8px;
834            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
835            text-align: center;
836            max-width: 400px;
837        }
838        h1 { color: #e53e3e; margin-bottom: 1rem; }
839        p { color: #666; }
840        .icon {
841            font-size: 4rem;
842            margin-bottom: 1rem;
843        }
844    </style>
845</head>
846<body>
847    <div class="container">
848        <div class="icon">&#128683;</div>
849        <h1>Access Denied</h1>
850        <p>Your request has been blocked due to suspicious activity.</p>
851        <p>If you believe this is an error, please contact support.</p>
852    </div>
853</body>
854</html>"#;
855
856/// Default CAPTCHA page HTML (stub)
857const DEFAULT_CAPTCHA_PAGE: &str = r#"<!DOCTYPE html>
858<html>
859<head>
860    <meta charset="UTF-8">
861    <meta name="viewport" content="width=device-width, initial-scale=1.0">
862    <title>Verification Required</title>
863    <style>
864        body {
865            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
866            display: flex;
867            justify-content: center;
868            align-items: center;
869            min-height: 100vh;
870            margin: 0;
871            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
872        }
873        .container {
874            background: white;
875            padding: 2rem;
876            border-radius: 8px;
877            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
878            text-align: center;
879            max-width: 400px;
880        }
881        h2 { color: #333; }
882        p { color: #666; }
883        .placeholder {
884            background: #f0f0f0;
885            padding: 2rem;
886            margin: 1rem 0;
887            border-radius: 4px;
888            color: #999;
889        }
890    </style>
891</head>
892<body>
893    <div class="container">
894        <h2>Human Verification Required</h2>
895        <p>Please complete the verification below to continue.</p>
896        <div class="placeholder">
897            [CAPTCHA Placeholder - Integration Required]
898        </div>
899        <p><small>This is a stub implementation.</small></p>
900    </div>
901</body>
902</html>"#;
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use crate::interrogator::{CaptchaConfig, CookieConfig, JsChallengeConfig};
908    use crate::tarpit::TarpitConfig;
909
910    fn test_managers() -> (
911        Arc<CookieManager>,
912        Arc<JsChallengeManager>,
913        Arc<CaptchaManager>,
914        Arc<TarpitManager>,
915    ) {
916        let cookie_config = CookieConfig {
917            cookie_name: "__test".to_string(),
918            cookie_max_age_secs: 3600,
919            secret_key: [0x01; 32],
920            secure_only: true,
921            http_only: true,
922            same_site: "Strict".to_string(),
923        };
924        let js_config = JsChallengeConfig {
925            difficulty: 1, // Low for fast tests
926            ..Default::default()
927        };
928        let captcha_config = CaptchaConfig {
929            secret: "test_captcha_secret".to_string(),
930            expiry_secs: 300,
931            max_challenges: 100,
932            cleanup_interval_secs: 60,
933        };
934        let tarpit_config = TarpitConfig {
935            base_delay_ms: 10, // Low for fast tests
936            ..Default::default()
937        };
938
939        (
940            Arc::new(CookieManager::new(cookie_config).expect("valid test config")),
941            Arc::new(JsChallengeManager::new(js_config)),
942            Arc::new(CaptchaManager::new(captcha_config)),
943            Arc::new(TarpitManager::new(tarpit_config)),
944        )
945    }
946
947    fn test_config() -> ProgressionConfig {
948        ProgressionConfig {
949            failures_before_escalate: 3,
950            auto_de_escalate_secs: 1, // Fast for tests
951            skip_to_block_threshold: 10,
952            ..Default::default()
953        }
954    }
955
956    #[test]
957    fn test_level_from_risk_score() {
958        let (cookie, js, captcha, tarpit) = test_managers();
959        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
960
961        // Low risk -> None
962        assert_eq!(manager.determine_initial_level(0.1), ChallengeLevel::None);
963
964        // Medium-low risk -> Cookie
965        assert_eq!(manager.determine_initial_level(0.3), ChallengeLevel::Cookie);
966
967        // Medium risk -> JS Challenge
968        assert_eq!(
969            manager.determine_initial_level(0.5),
970            ChallengeLevel::JsChallenge
971        );
972
973        // Medium-high risk -> Tarpit (CAPTCHA disabled by default)
974        assert_eq!(manager.determine_initial_level(0.7), ChallengeLevel::Tarpit);
975
976        // High risk -> Block
977        assert_eq!(manager.determine_initial_level(0.9), ChallengeLevel::Block);
978    }
979
980    #[test]
981    fn test_escalation() {
982        let (cookie, js, captcha, tarpit) = test_managers();
983        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
984
985        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::None);
986
987        // Escalate through levels
988        let level = manager.escalate("actor_123");
989        assert_eq!(level, ChallengeLevel::Cookie);
990
991        let level = manager.escalate("actor_123");
992        assert_eq!(level, ChallengeLevel::JsChallenge);
993
994        let level = manager.escalate("actor_123");
995        assert_eq!(level, ChallengeLevel::Tarpit); // CAPTCHA disabled
996
997        let level = manager.escalate("actor_123");
998        assert_eq!(level, ChallengeLevel::Block);
999
1000        // Can't escalate beyond block
1001        let level = manager.escalate("actor_123");
1002        assert_eq!(level, ChallengeLevel::Block);
1003    }
1004
1005    #[test]
1006    fn test_de_escalation() {
1007        let (cookie, js, captcha, tarpit) = test_managers();
1008        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1009
1010        // Start at block
1011        for _ in 0..5 {
1012            manager.escalate("actor_123");
1013        }
1014        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Block);
1015
1016        // De-escalate through levels
1017        let level = manager.de_escalate("actor_123");
1018        assert_eq!(level, ChallengeLevel::Tarpit);
1019
1020        let level = manager.de_escalate("actor_123");
1021        assert_eq!(level, ChallengeLevel::JsChallenge);
1022
1023        let level = manager.de_escalate("actor_123");
1024        assert_eq!(level, ChallengeLevel::Cookie);
1025
1026        let level = manager.de_escalate("actor_123");
1027        assert_eq!(level, ChallengeLevel::None);
1028
1029        // Can't de-escalate below none
1030        let level = manager.de_escalate("actor_123");
1031        assert_eq!(level, ChallengeLevel::None);
1032    }
1033
1034    #[test]
1035    fn test_failure_escalation() {
1036        let (cookie, js, captcha, tarpit) = test_managers();
1037        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1038
1039        // Start a challenge to get state
1040        manager.get_challenge("actor_123", 0.3);
1041        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Cookie);
1042
1043        // Record failures - should escalate after 3
1044        manager.record_failure("actor_123");
1045        manager.record_failure("actor_123");
1046        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Cookie);
1047
1048        manager.record_failure("actor_123"); // 3rd failure
1049        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::JsChallenge);
1050    }
1051
1052    #[test]
1053    fn test_skip_to_block() {
1054        let (cookie, js, captcha, tarpit) = test_managers();
1055        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1056
1057        // Start a challenge
1058        manager.get_challenge("actor_123", 0.3);
1059
1060        // Record many failures - should skip to block after 10
1061        for _ in 0..10 {
1062            manager.record_failure("actor_123");
1063        }
1064
1065        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Block);
1066    }
1067
1068    #[test]
1069    fn test_get_challenge_response() {
1070        let (cookie, js, captcha, tarpit) = test_managers();
1071        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1072
1073        // Low risk -> Allow
1074        let response = manager.get_challenge("actor_1", 0.1);
1075        assert!(matches!(response, ChallengeResponse::Allow));
1076
1077        // Medium-low risk -> Cookie
1078        let response = manager.get_challenge("actor_2", 0.3);
1079        assert!(matches!(response, ChallengeResponse::Cookie { .. }));
1080
1081        // Medium risk -> JS Challenge
1082        let response = manager.get_challenge("actor_3", 0.5);
1083        assert!(matches!(response, ChallengeResponse::JsChallenge { .. }));
1084
1085        // High risk -> Block
1086        let response = manager.get_challenge("actor_4", 0.9);
1087        assert!(matches!(response, ChallengeResponse::Block { .. }));
1088    }
1089
1090    #[test]
1091    fn test_actor_state_tracking() {
1092        let (cookie, js, captcha, tarpit) = test_managers();
1093        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1094
1095        manager.get_challenge("actor_123", 0.5);
1096
1097        let state = manager.get_actor_state("actor_123").unwrap();
1098        assert_eq!(state.actor_id, "actor_123");
1099        assert_eq!(state.current_level, ChallengeLevel::JsChallenge);
1100        assert_eq!(state.total_failures, 0);
1101
1102        manager.record_failure("actor_123");
1103        let state = manager.get_actor_state("actor_123").unwrap();
1104        assert_eq!(state.total_failures, 1);
1105        assert_eq!(state.failures_at_level, 1);
1106    }
1107
1108    #[test]
1109    fn test_list_actors_at_level() {
1110        let (cookie, js, captcha, tarpit) = test_managers();
1111        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1112
1113        manager.get_challenge("actor_1", 0.3); // Cookie
1114        manager.get_challenge("actor_2", 0.5); // JS
1115        manager.get_challenge("actor_3", 0.5); // JS
1116        manager.get_challenge("actor_4", 0.9); // Block
1117
1118        let cookie_actors = manager.list_actors_at_level(ChallengeLevel::Cookie);
1119        assert_eq!(cookie_actors.len(), 1);
1120
1121        let js_actors = manager.list_actors_at_level(ChallengeLevel::JsChallenge);
1122        assert_eq!(js_actors.len(), 2);
1123
1124        let block_actors = manager.list_actors_at_level(ChallengeLevel::Block);
1125        assert_eq!(block_actors.len(), 1);
1126    }
1127
1128    #[test]
1129    fn test_reset() {
1130        let (cookie, js, captcha, tarpit) = test_managers();
1131        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1132
1133        manager.get_challenge("actor_123", 0.5);
1134        assert!(manager.get_actor_state("actor_123").is_some());
1135
1136        manager.reset("actor_123");
1137        assert!(manager.get_actor_state("actor_123").is_none());
1138    }
1139
1140    #[test]
1141    fn test_stats_tracking() {
1142        let (cookie, js, captcha, tarpit) = test_managers();
1143        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1144
1145        manager.get_challenge("actor_1", 0.5);
1146        manager.get_challenge("actor_2", 0.5);
1147
1148        let stats = manager.stats().snapshot();
1149        assert_eq!(stats.actors_tracked, 2);
1150        assert_eq!(stats.challenges_issued, 2);
1151
1152        manager.record_failure("actor_1");
1153        let stats = manager.stats().snapshot();
1154        assert_eq!(stats.failures, 1);
1155
1156        manager.record_success("actor_1");
1157        let stats = manager.stats().snapshot();
1158        assert_eq!(stats.successes, 1);
1159    }
1160
1161    #[test]
1162    fn test_challenge_level_display() {
1163        assert_eq!(ChallengeLevel::None.name(), "none");
1164        assert_eq!(ChallengeLevel::Cookie.name(), "cookie");
1165        assert_eq!(ChallengeLevel::JsChallenge.name(), "js_challenge");
1166        assert_eq!(ChallengeLevel::Captcha.name(), "captcha");
1167        assert_eq!(ChallengeLevel::Tarpit.name(), "tarpit");
1168        assert_eq!(ChallengeLevel::Block.name(), "block");
1169
1170        assert_eq!(format!("{}", ChallengeLevel::Cookie), "cookie");
1171    }
1172
1173    #[test]
1174    fn test_challenge_level_from_u8() {
1175        assert_eq!(ChallengeLevel::from_u8(0), ChallengeLevel::None);
1176        assert_eq!(ChallengeLevel::from_u8(1), ChallengeLevel::Cookie);
1177        assert_eq!(ChallengeLevel::from_u8(2), ChallengeLevel::JsChallenge);
1178        assert_eq!(ChallengeLevel::from_u8(3), ChallengeLevel::Captcha);
1179        assert_eq!(ChallengeLevel::from_u8(4), ChallengeLevel::Tarpit);
1180        assert_eq!(ChallengeLevel::from_u8(5), ChallengeLevel::Block);
1181        assert_eq!(ChallengeLevel::from_u8(100), ChallengeLevel::Block); // Out of range
1182    }
1183
1184    #[test]
1185    fn test_risk_higher_than_current_level() {
1186        let (cookie, js, captcha, tarpit) = test_managers();
1187        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1188
1189        // Start with cookie level
1190        manager.get_challenge("actor_123", 0.3);
1191        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Cookie);
1192
1193        // Higher risk should increase effective level
1194        let response = manager.get_challenge("actor_123", 0.9);
1195        assert!(matches!(response, ChallengeResponse::Block { .. }));
1196        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Block);
1197    }
1198
1199    #[test]
1200    fn test_behavior_escalates_above_risk() {
1201        let (cookie, js, captcha, tarpit) = test_managers();
1202        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1203
1204        // Low risk, but manual escalation
1205        manager.get_challenge("actor_123", 0.1);
1206        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::None);
1207
1208        manager.escalate("actor_123");
1209        manager.escalate("actor_123");
1210        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::JsChallenge);
1211
1212        // Even with low risk, behavior-based level persists
1213        let response = manager.get_challenge("actor_123", 0.1);
1214        assert!(matches!(response, ChallengeResponse::JsChallenge { .. }));
1215    }
1216
1217    #[test]
1218    fn test_escalation_history() {
1219        let (cookie, js, captcha, tarpit) = test_managers();
1220        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1221
1222        manager.get_challenge("actor_123", 0.3);
1223        manager.escalate("actor_123");
1224        manager.escalate("actor_123");
1225
1226        let state = manager.get_actor_state("actor_123").unwrap();
1227        assert_eq!(state.escalation_history.len(), 2);
1228        assert_eq!(state.escalation_history[0].0, ChallengeLevel::JsChallenge);
1229        assert_eq!(state.escalation_history[1].0, ChallengeLevel::Tarpit);
1230    }
1231
1232    #[test]
1233    fn test_clear() {
1234        let (cookie, js, captcha, tarpit) = test_managers();
1235        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1236
1237        manager.get_challenge("actor_1", 0.5);
1238        manager.get_challenge("actor_2", 0.5);
1239        assert_eq!(manager.len(), 2);
1240
1241        manager.clear();
1242        assert!(manager.is_empty());
1243    }
1244
1245    #[test]
1246    fn test_disabled_levels_skipped() {
1247        let (cookie, js, captcha, tarpit) = test_managers();
1248        let config = ProgressionConfig {
1249            enable_cookie: false,
1250            enable_js_challenge: false,
1251            enable_captcha: false,
1252            enable_tarpit: true,
1253            ..test_config()
1254        };
1255        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, config);
1256
1257        // Escalation should skip disabled levels
1258        let level = manager.escalate("actor_123");
1259        assert_eq!(level, ChallengeLevel::Tarpit);
1260
1261        let level = manager.escalate("actor_123");
1262        assert_eq!(level, ChallengeLevel::Block);
1263    }
1264
1265    #[test]
1266    fn test_tarpit_challenge() {
1267        let (cookie, js, captcha, tarpit) = test_managers();
1268        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, test_config());
1269
1270        // Get tarpit challenge
1271        let response = manager.get_challenge("actor_123", 0.7);
1272        assert!(matches!(response, ChallengeResponse::Tarpit { delay_ms } if delay_ms > 0));
1273    }
1274
1275    #[test]
1276    fn test_captcha_challenge() {
1277        let (cookie, js, captcha, tarpit) = test_managers();
1278        let config = ProgressionConfig {
1279            enable_captcha: true,
1280            ..test_config()
1281        };
1282        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, config);
1283
1284        // Get CAPTCHA challenge at medium-high risk
1285        let response = manager.get_challenge("actor_123", 0.65);
1286        match response {
1287            ChallengeResponse::Captcha { html, session_id } => {
1288                assert!(html.contains("Human Verification Required"));
1289                assert!(html.contains("What is"));
1290                assert!(!session_id.is_empty());
1291            }
1292            _ => panic!("Expected CAPTCHA challenge, got {:?}", response),
1293        }
1294    }
1295
1296    #[test]
1297    fn test_captcha_validation() {
1298        let (cookie, js, captcha, tarpit) = test_managers();
1299        let config = ProgressionConfig {
1300            enable_captcha: true,
1301            ..test_config()
1302        };
1303        let manager = ProgressionManager::new(cookie, js, captcha.clone(), tarpit, config);
1304
1305        // Get CAPTCHA challenge
1306        let response = manager.get_challenge("actor_123", 0.65);
1307        let session_id = match response {
1308            ChallengeResponse::Captcha { session_id, .. } => session_id,
1309            _ => panic!("Expected CAPTCHA challenge"),
1310        };
1311
1312        // Get the challenge details from the captcha manager to know the answer
1313        let challenge = captcha.issue_challenge("actor_123");
1314        // Parse the question to get the answer (e.g., "What is 5 + 3?")
1315        let parts: Vec<&str> = challenge.question.split_whitespace().collect();
1316        let a: i32 = parts[2].parse().unwrap();
1317        let b: i32 = parts[4].trim_end_matches('?').parse().unwrap();
1318        let answer = a + b;
1319
1320        // Validate with correct answer
1321        let validation_response = format!("{}:{}", challenge.session_id, answer);
1322        let result = captcha.validate_response("actor_123", &validation_response);
1323        assert_eq!(result, ValidationResult::Valid);
1324    }
1325
1326    #[test]
1327    fn test_captcha_escalation_with_enabled() {
1328        let (cookie, js, captcha, tarpit) = test_managers();
1329        let config = ProgressionConfig {
1330            enable_captcha: true,
1331            ..test_config()
1332        };
1333        let manager = ProgressionManager::new(cookie, js, captcha, tarpit, config);
1334
1335        // Start at Cookie
1336        manager.get_challenge("actor_123", 0.3);
1337        assert_eq!(manager.get_level("actor_123"), ChallengeLevel::Cookie);
1338
1339        // Escalate through levels
1340        let level = manager.escalate("actor_123");
1341        assert_eq!(level, ChallengeLevel::JsChallenge);
1342
1343        let level = manager.escalate("actor_123");
1344        assert_eq!(level, ChallengeLevel::Captcha); // Now enabled!
1345
1346        let level = manager.escalate("actor_123");
1347        assert_eq!(level, ChallengeLevel::Tarpit);
1348
1349        let level = manager.escalate("actor_123");
1350        assert_eq!(level, ChallengeLevel::Block);
1351    }
1352}