Skip to main content

synapse_pingora/interrogator/
js_challenge_manager.rs

1//! JavaScript Proof-of-Work Challenge Manager
2//!
3//! Implements a computational proof-of-work challenge that requires client-side
4//! JavaScript execution. This helps distinguish real browsers from simple bots
5//! that don't execute JavaScript.
6//!
7//! # PoW Mechanism
8//!
9//! The client must find a nonce such that `SHA256(prefix + nonce)` has the
10//! required number of leading hex zeros (difficulty). This is computationally
11//! expensive for clients but cheap to verify server-side.
12//!
13//! # Challenge Flow
14//!
15//! 1. Server generates challenge with random prefix and difficulty
16//! 2. Server returns HTML page with embedded JavaScript solver
17//! 3. Client JavaScript computes SHA256 hashes until solution found
18//! 4. Client submits form with nonce
19//! 5. Server verifies solution
20//!
21//! # Security Properties
22//!
23//! - Each challenge has unique prefix (no precomputation attacks)
24//! - Challenges expire after TTL (no replay attacks)
25//! - Max attempts prevent infinite retries
26//! - Difficulty can be tuned for security/UX balance
27
28use dashmap::DashMap;
29use sha2::{Digest, Sha256};
30use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
31use std::sync::Arc;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33use tokio::sync::Notify;
34
35use super::{ChallengeResponse, Interrogator, ValidationResult};
36
37/// A JavaScript PoW challenge instance
38#[derive(Debug, Clone)]
39pub struct JsChallenge {
40    /// Unique challenge ID
41    pub challenge_id: String,
42    /// Actor this challenge is for
43    pub actor_id: String,
44    /// Number of leading hex zeros required
45    pub difficulty: u32,
46    /// Random prefix for PoW computation
47    pub prefix: String,
48    /// When challenge was created (unix timestamp ms)
49    pub created_at: u64,
50    /// When challenge expires (unix timestamp ms)
51    pub expires_at: u64,
52    /// Expected hash prefix (leading zeros)
53    pub expected_hash_prefix: String,
54}
55
56/// Configuration for JavaScript challenges
57#[derive(Debug, Clone)]
58pub struct JsChallengeConfig {
59    /// Number of leading hex zeros required (default: 4)
60    /// Higher = harder, each +1 roughly doubles computation time
61    pub difficulty: u32,
62    /// Challenge time-to-live in seconds (default: 300 = 5 min)
63    pub challenge_ttl_secs: u64,
64    /// Maximum validation attempts per actor (default: 3)
65    pub max_attempts: u32,
66    /// Background cleanup interval in seconds (default: 60)
67    pub cleanup_interval_secs: u64,
68    /// Challenge page title
69    pub page_title: String,
70    /// Challenge page message
71    pub page_message: String,
72}
73
74impl Default for JsChallengeConfig {
75    fn default() -> Self {
76        Self {
77            difficulty: 4, // 4 leading hex zeros = ~65K iterations average
78            challenge_ttl_secs: 300,
79            max_attempts: 3,
80            cleanup_interval_secs: 60,
81            page_title: "Verifying your browser".to_string(),
82            page_message: "Please wait while we verify your browser...".to_string(),
83        }
84    }
85}
86
87/// Statistics for JavaScript challenge operations
88#[derive(Debug, Default)]
89pub struct JsChallengeStats {
90    /// Total challenges issued
91    pub challenges_issued: AtomicU64,
92    /// Successfully passed challenges
93    pub challenges_passed: AtomicU64,
94    /// Failed challenges (wrong solution)
95    pub challenges_failed: AtomicU64,
96    /// Expired challenges
97    pub challenges_expired: AtomicU64,
98    /// Max attempts exceeded
99    pub max_attempts_exceeded: AtomicU64,
100}
101
102impl JsChallengeStats {
103    /// Create a snapshot of current stats
104    pub fn snapshot(&self) -> JsChallengeStatsSnapshot {
105        JsChallengeStatsSnapshot {
106            challenges_issued: self.challenges_issued.load(Ordering::Relaxed),
107            challenges_passed: self.challenges_passed.load(Ordering::Relaxed),
108            challenges_failed: self.challenges_failed.load(Ordering::Relaxed),
109            challenges_expired: self.challenges_expired.load(Ordering::Relaxed),
110            max_attempts_exceeded: self.max_attempts_exceeded.load(Ordering::Relaxed),
111        }
112    }
113}
114
115/// Snapshot of JS challenge stats for serialization
116#[derive(Debug, Clone, serde::Serialize)]
117pub struct JsChallengeStatsSnapshot {
118    pub challenges_issued: u64,
119    pub challenges_passed: u64,
120    pub challenges_failed: u64,
121    pub challenges_expired: u64,
122    pub max_attempts_exceeded: u64,
123}
124
125/// Thread-safe JavaScript challenge manager
126pub struct JsChallengeManager {
127    /// Active challenges by actor ID
128    challenges: DashMap<String, JsChallenge>,
129    /// Attempt counts by actor ID
130    attempt_counts: DashMap<String, u32>,
131    /// Configuration
132    config: JsChallengeConfig,
133    /// Statistics
134    stats: JsChallengeStats,
135    /// Shutdown signal for background tasks
136    shutdown: Arc<Notify>,
137    /// Shutdown flag to check if shutdown was requested
138    shutdown_flag: Arc<AtomicBool>,
139}
140
141impl JsChallengeManager {
142    /// Create a new JS challenge manager with the given configuration
143    pub fn new(config: JsChallengeConfig) -> Self {
144        Self {
145            challenges: DashMap::new(),
146            attempt_counts: DashMap::new(),
147            config,
148            stats: JsChallengeStats::default(),
149            shutdown: Arc::new(Notify::new()),
150            shutdown_flag: Arc::new(AtomicBool::new(false)),
151        }
152    }
153
154    /// Get the configuration
155    pub fn config(&self) -> &JsChallengeConfig {
156        &self.config
157    }
158
159    /// Generate a PoW challenge for an actor
160    pub fn generate_pow_challenge(&self, actor_id: &str) -> JsChallenge {
161        let now = now_ms();
162        let expires_at = now + (self.config.challenge_ttl_secs * 1000);
163
164        // Generate random prefix (16 hex chars)
165        let prefix = generate_random_hex(16);
166
167        // Generate unique challenge ID
168        let challenge_id = generate_random_hex(32);
169
170        // Expected hash prefix is `difficulty` zeros
171        let expected_hash_prefix = "0".repeat(self.config.difficulty as usize);
172
173        let challenge = JsChallenge {
174            challenge_id,
175            actor_id: actor_id.to_string(),
176            difficulty: self.config.difficulty,
177            prefix,
178            created_at: now,
179            expires_at,
180            expected_hash_prefix,
181        };
182
183        // Store challenge
184        self.challenges
185            .insert(actor_id.to_string(), challenge.clone());
186        self.stats.challenges_issued.fetch_add(1, Ordering::Relaxed);
187
188        challenge
189    }
190
191    /// Validate a PoW solution
192    pub fn validate_pow(&self, actor_id: &str, nonce: &str) -> ValidationResult {
193        // SECURITY: Validate nonce length to prevent memory exhaustion attacks.
194        // Valid nonces are numeric strings; even 2^64 is only 20 digits.
195        // Allow up to 32 chars to be safe with potential future formats.
196        const MAX_NONCE_LENGTH: usize = 32;
197        if nonce.len() > MAX_NONCE_LENGTH {
198            return ValidationResult::Invalid(format!(
199                "Nonce too long ({} > {} chars)",
200                nonce.len(),
201                MAX_NONCE_LENGTH
202            ));
203        }
204
205        // Validate nonce is numeric (expected from JS client)
206        if !nonce.chars().all(|c| c.is_ascii_digit()) {
207            return ValidationResult::Invalid("Nonce must be numeric".to_string());
208        }
209
210        // Get challenge for actor
211        let challenge = match self.challenges.get(actor_id) {
212            Some(c) => c.clone(),
213            None => return ValidationResult::NotFound,
214        };
215
216        // Check expiration
217        let now = now_ms();
218        if challenge.expires_at < now {
219            self.challenges.remove(actor_id);
220            self.stats
221                .challenges_expired
222                .fetch_add(1, Ordering::Relaxed);
223            return ValidationResult::Expired;
224        }
225
226        // Increment attempt count
227        let attempts = {
228            let mut entry = self.attempt_counts.entry(actor_id.to_string()).or_insert(0);
229            *entry += 1;
230            *entry
231        };
232
233        // Check max attempts
234        if attempts > self.config.max_attempts {
235            self.stats
236                .max_attempts_exceeded
237                .fetch_add(1, Ordering::Relaxed);
238            return ValidationResult::Invalid(format!(
239                "Max attempts ({}) exceeded",
240                self.config.max_attempts
241            ));
242        }
243
244        // Verify PoW: SHA256(prefix + nonce) must have required leading zeros
245        let data = format!("{}{}", challenge.prefix, nonce);
246        let hash = compute_sha256_hex(&data);
247
248        if hash.starts_with(&challenge.expected_hash_prefix) {
249            // Success - remove challenge and attempts
250            self.challenges.remove(actor_id);
251            self.attempt_counts.remove(actor_id);
252            self.stats.challenges_passed.fetch_add(1, Ordering::Relaxed);
253            ValidationResult::Valid
254        } else {
255            self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
256            ValidationResult::Invalid(format!(
257                "Hash {} does not have {} leading zeros",
258                &hash[..8],
259                self.config.difficulty
260            ))
261        }
262    }
263
264    /// Generate the challenge HTML page
265    pub fn generate_challenge_page(&self, challenge: &JsChallenge) -> String {
266        format!(
267            r#"<!DOCTYPE html>
268<html>
269<head>
270    <meta charset="UTF-8">
271    <meta name="viewport" content="width=device-width, initial-scale=1.0">
272    <title>{title}</title>
273    <style>
274        body {{
275            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
276            display: flex;
277            justify-content: center;
278            align-items: center;
279            min-height: 100vh;
280            margin: 0;
281            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
282        }}
283        .container {{
284            background: white;
285            padding: 2rem;
286            border-radius: 8px;
287            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
288            text-align: center;
289            max-width: 400px;
290        }}
291        .spinner {{
292            border: 4px solid #f3f3f3;
293            border-top: 4px solid #667eea;
294            border-radius: 50%;
295            width: 40px;
296            height: 40px;
297            animation: spin 1s linear infinite;
298            margin: 1rem auto;
299        }}
300        @keyframes spin {{
301            0% {{ transform: rotate(0deg); }}
302            100% {{ transform: rotate(360deg); }}
303        }}
304        .progress {{
305            margin: 1rem 0;
306            color: #666;
307            font-size: 0.9rem;
308        }}
309        .error {{
310            color: #e53e3e;
311            margin-top: 1rem;
312        }}
313        noscript {{
314            color: #e53e3e;
315        }}
316    </style>
317</head>
318<body>
319    <div class="container">
320        <h2>{message}</h2>
321        <div class="spinner" id="spinner"></div>
322        <div class="progress" id="progress">Computing challenge...</div>
323        <noscript>
324            <p class="error">JavaScript is required to complete this verification.</p>
325        </noscript>
326        <form id="challengeForm" method="GET" style="display: none;">
327            <input type="hidden" name="synapse_challenge" value="js">
328            <input type="hidden" name="challenge_id" value="{challenge_id}">
329            <input type="hidden" name="synapse_nonce" id="synapse_nonce" value="">
330        </form>
331    </div>
332    <script>
333        (function() {{
334            const PREFIX = '{prefix}';
335            const DIFFICULTY = {difficulty};
336            const EXPECTED_PREFIX = '{expected_prefix}';
337
338            let nonce = 0;
339            let startTime = Date.now();
340            let lastUpdate = startTime;
341
342            // SHA-256 implementation using Web Crypto API
343            async function sha256(message) {{
344                const msgBuffer = new TextEncoder().encode(message);
345                const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
346                const hashArray = Array.from(new Uint8Array(hashBuffer));
347                return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
348            }}
349
350            async function solve() {{
351                const progressEl = document.getElementById('progress');
352
353                while (true) {{
354                    const data = PREFIX + nonce.toString();
355                    const hash = await sha256(data);
356
357                    // Update progress every 100ms
358                    const now = Date.now();
359                    if (now - lastUpdate > 100) {{
360                        const elapsed = ((now - startTime) / 1000).toFixed(1);
361                        progressEl.textContent = `Computed ${{nonce.toLocaleString()}} hashes (${{elapsed}}s)...`;
362                        lastUpdate = now;
363                    }}
364
365                    if (hash.startsWith(EXPECTED_PREFIX)) {{
366                        // Found solution!
367                        document.getElementById('synapse_nonce').value = nonce.toString();
368                        document.getElementById('spinner').style.display = 'none';
369                        progressEl.textContent = 'Verification complete! Redirecting...';
370                        document.getElementById('challengeForm').submit();
371                        return;
372                    }}
373
374                    nonce++;
375
376                    // Yield to browser every 1000 iterations for responsiveness
377                    if (nonce % 1000 === 0) {{
378                        await new Promise(resolve => setTimeout(resolve, 0));
379                    }}
380                }}
381            }}
382
383            // Start solving
384            solve().catch(err => {{
385                document.getElementById('spinner').style.display = 'none';
386                document.getElementById('progress').innerHTML =
387                    '<span class="error">Verification failed: ' + err.message + '</span>';
388            }});
389        }})();
390    </script>
391</body>
392</html>"#,
393            title = self.config.page_title,
394            message = self.config.page_message,
395            challenge_id = challenge.challenge_id,
396            prefix = challenge.prefix,
397            difficulty = challenge.difficulty,
398            expected_prefix = challenge.expected_hash_prefix,
399        )
400    }
401
402    /// Get attempt count for an actor
403    pub fn get_attempts(&self, actor_id: &str) -> u32 {
404        self.attempt_counts.get(actor_id).map(|v| *v).unwrap_or(0)
405    }
406
407    /// Check if actor has active challenge
408    pub fn has_challenge(&self, actor_id: &str) -> bool {
409        self.challenges.contains_key(actor_id)
410    }
411
412    /// Get active challenge for actor
413    pub fn get_challenge(&self, actor_id: &str) -> Option<JsChallenge> {
414        self.challenges.get(actor_id).map(|c| c.clone())
415    }
416
417    /// Start background cleanup task.
418    ///
419    /// Spawns a background task that periodically removes expired challenges.
420    /// The task will exit cleanly when `shutdown()` is called.
421    pub fn start_cleanup(self: Arc<Self>) {
422        let manager = self.clone();
423        let interval = Duration::from_secs(self.config.cleanup_interval_secs);
424        let shutdown = self.shutdown.clone();
425        let shutdown_flag = self.shutdown_flag.clone();
426
427        tokio::spawn(async move {
428            let mut interval_timer = tokio::time::interval(interval);
429
430            loop {
431                tokio::select! {
432                    _ = interval_timer.tick() => {
433                        // Check shutdown flag before running cleanup
434                        if shutdown_flag.load(Ordering::Relaxed) {
435                            log::info!("JS challenge manager cleanup task shutting down (flag)");
436                            break;
437                        }
438                        manager.cleanup_expired();
439                    }
440                    _ = shutdown.notified() => {
441                        log::info!("JS challenge manager cleanup task shutting down");
442                        break;
443                    }
444                }
445            }
446        });
447    }
448
449    /// Signal shutdown for background tasks.
450    ///
451    /// This method signals the background cleanup task to stop.
452    /// The task will exit after completing any in-progress work.
453    pub fn shutdown(&self) {
454        self.shutdown_flag.store(true, Ordering::Relaxed);
455        self.shutdown.notify_one();
456    }
457
458    /// Remove expired challenges
459    pub fn cleanup_expired(&self) -> usize {
460        let now = now_ms();
461        let mut removed = 0;
462
463        self.challenges.retain(|_, challenge| {
464            if challenge.expires_at < now {
465                removed += 1;
466                false
467            } else {
468                true
469            }
470        });
471
472        // Also clean up attempt counts for actors without challenges
473        let actor_ids: Vec<String> = self
474            .attempt_counts
475            .iter()
476            .map(|e| e.key().clone())
477            .collect();
478        for actor_id in actor_ids {
479            if !self.challenges.contains_key(&actor_id) {
480                self.attempt_counts.remove(&actor_id);
481            }
482        }
483
484        removed
485    }
486
487    /// Get statistics
488    pub fn stats(&self) -> &JsChallengeStats {
489        &self.stats
490    }
491
492    /// Get number of active challenges
493    pub fn len(&self) -> usize {
494        self.challenges.len()
495    }
496
497    /// Check if no challenges are active
498    pub fn is_empty(&self) -> bool {
499        self.challenges.is_empty()
500    }
501
502    /// Clear all challenges
503    pub fn clear(&self) {
504        self.challenges.clear();
505        self.attempt_counts.clear();
506    }
507}
508
509impl Interrogator for JsChallengeManager {
510    fn name(&self) -> &'static str {
511        "js_challenge"
512    }
513
514    fn challenge_level(&self) -> u8 {
515        2
516    }
517
518    fn generate_challenge(&self, actor_id: &str) -> ChallengeResponse {
519        let challenge = self.generate_pow_challenge(actor_id);
520        let html = self.generate_challenge_page(&challenge);
521        ChallengeResponse::JsChallenge {
522            html,
523            expected_solution: challenge.expected_hash_prefix.clone(),
524            expires_at: challenge.expires_at,
525        }
526    }
527
528    fn validate_response(&self, actor_id: &str, response: &str) -> ValidationResult {
529        self.validate_pow(actor_id, response)
530    }
531
532    fn should_escalate(&self, actor_id: &str) -> bool {
533        self.get_attempts(actor_id) >= self.config.max_attempts
534    }
535}
536
537/// Get current time in milliseconds since Unix epoch
538#[inline]
539fn now_ms() -> u64 {
540    SystemTime::now()
541        .duration_since(UNIX_EPOCH)
542        .map(|d| d.as_millis() as u64)
543        .unwrap_or(0)
544}
545
546/// Compute SHA256 hash of data, return hex string
547fn compute_sha256_hex(data: &str) -> String {
548    let mut hasher = Sha256::new();
549    hasher.update(data.as_bytes());
550    let result = hasher.finalize();
551    hex::encode(result)
552}
553
554/// Generate random hex string of given length using cryptographically secure random
555fn generate_random_hex(len: usize) -> String {
556    // Calculate number of bytes needed (2 hex chars per byte)
557    let byte_len = len.div_ceil(2);
558    let mut bytes = vec![0u8; byte_len];
559    getrandom::getrandom(&mut bytes).expect("Failed to get random bytes");
560
561    let mut result = hex::encode(&bytes);
562    result.truncate(len);
563    result
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn test_config() -> JsChallengeConfig {
571        JsChallengeConfig {
572            difficulty: 2, // Low difficulty for fast tests
573            challenge_ttl_secs: 300,
574            max_attempts: 3,
575            cleanup_interval_secs: 60,
576            page_title: "Test Challenge".to_string(),
577            page_message: "Testing...".to_string(),
578        }
579    }
580
581    #[test]
582    fn test_challenge_generation() {
583        let manager = JsChallengeManager::new(test_config());
584        let challenge = manager.generate_pow_challenge("actor_123");
585
586        assert_eq!(challenge.actor_id, "actor_123");
587        assert_eq!(challenge.difficulty, 2);
588        assert_eq!(challenge.prefix.len(), 16);
589        assert_eq!(challenge.challenge_id.len(), 32);
590        assert_eq!(challenge.expected_hash_prefix, "00");
591        assert!(challenge.expires_at > challenge.created_at);
592    }
593
594    #[test]
595    fn test_pow_verification_valid() {
596        let manager = JsChallengeManager::new(test_config());
597        let challenge = manager.generate_pow_challenge("actor_123");
598
599        // Find valid nonce (brute force - okay for low difficulty)
600        let mut nonce = 0u64;
601        loop {
602            let data = format!("{}{}", challenge.prefix, nonce);
603            let hash = compute_sha256_hex(&data);
604            if hash.starts_with(&challenge.expected_hash_prefix) {
605                break;
606            }
607            nonce += 1;
608            if nonce > 100_000 {
609                panic!("Could not find solution in reasonable time");
610            }
611        }
612
613        let result = manager.validate_pow("actor_123", &nonce.to_string());
614        assert_eq!(result, ValidationResult::Valid);
615    }
616
617    #[test]
618    fn test_pow_verification_invalid() {
619        let manager = JsChallengeManager::new(test_config());
620        manager.generate_pow_challenge("actor_123");
621
622        // Use invalid nonce (very unlikely to work)
623        let result = manager.validate_pow("actor_123", "invalid_nonce");
624        assert!(matches!(result, ValidationResult::Invalid(_)));
625    }
626
627    #[test]
628    fn test_pow_verification_not_found() {
629        let manager = JsChallengeManager::new(test_config());
630
631        // No challenge generated
632        let result = manager.validate_pow("actor_123", "12345");
633        assert_eq!(result, ValidationResult::NotFound);
634    }
635
636    #[test]
637    fn test_pow_verification_expired() {
638        let config = JsChallengeConfig {
639            challenge_ttl_secs: 0, // Immediate expiration
640            ..test_config()
641        };
642        let manager = JsChallengeManager::new(config);
643        manager.generate_pow_challenge("actor_123");
644
645        // Sleep to ensure expiration
646        std::thread::sleep(std::time::Duration::from_millis(10));
647
648        let result = manager.validate_pow("actor_123", "12345");
649        assert_eq!(result, ValidationResult::Expired);
650    }
651
652    #[test]
653    fn test_max_attempts() {
654        let manager = JsChallengeManager::new(test_config());
655        manager.generate_pow_challenge("actor_123");
656
657        // Make 3 attempts (max)
658        for _ in 0..3 {
659            let _ = manager.validate_pow("actor_123", "99999999");
660        }
661
662        // 4th attempt should fail with max attempts
663        let result = manager.validate_pow("actor_123", "99999999");
664        assert!(matches!(result, ValidationResult::Invalid(msg) if msg.contains("Max attempts")));
665    }
666
667    #[test]
668    fn test_attempt_counting() {
669        let manager = JsChallengeManager::new(test_config());
670        manager.generate_pow_challenge("actor_123");
671
672        assert_eq!(manager.get_attempts("actor_123"), 0);
673
674        manager.validate_pow("actor_123", "99999999");
675        assert_eq!(manager.get_attempts("actor_123"), 1);
676
677        manager.validate_pow("actor_123", "99999999");
678        assert_eq!(manager.get_attempts("actor_123"), 2);
679    }
680
681    #[test]
682    fn test_should_escalate() {
683        let manager = JsChallengeManager::new(test_config());
684        manager.generate_pow_challenge("actor_123");
685
686        assert!(!manager.should_escalate("actor_123"));
687
688        // Make max attempts
689        for _ in 0..3 {
690            let _ = manager.validate_pow("actor_123", "99999999");
691        }
692
693        assert!(manager.should_escalate("actor_123"));
694    }
695
696    #[test]
697    fn test_challenge_page_generation() {
698        let manager = JsChallengeManager::new(test_config());
699        let challenge = manager.generate_pow_challenge("actor_123");
700        let html = manager.generate_challenge_page(&challenge);
701
702        assert!(html.contains("Test Challenge")); // Page title
703        assert!(html.contains("Testing...")); // Message
704        assert!(html.contains(&challenge.prefix)); // Prefix in JS
705        assert!(html.contains(&challenge.challenge_id)); // Challenge ID in form
706        assert!(html.contains("sha256")); // Uses sha256
707    }
708
709    #[test]
710    fn test_sha256_computation() {
711        // Known SHA256 test vector
712        let hash = compute_sha256_hex("test");
713        assert_eq!(
714            hash,
715            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
716        );
717    }
718
719    #[test]
720    fn test_cleanup_expired() {
721        let config = JsChallengeConfig {
722            challenge_ttl_secs: 0, // Immediate expiration
723            ..test_config()
724        };
725        let manager = JsChallengeManager::new(config);
726
727        manager.generate_pow_challenge("actor_1");
728        manager.generate_pow_challenge("actor_2");
729        assert_eq!(manager.len(), 2);
730
731        std::thread::sleep(std::time::Duration::from_millis(10));
732
733        let removed = manager.cleanup_expired();
734        assert_eq!(removed, 2);
735        assert!(manager.is_empty());
736    }
737
738    #[test]
739    fn test_interrogator_trait() {
740        let manager = JsChallengeManager::new(test_config());
741
742        assert_eq!(manager.name(), "js_challenge");
743        assert_eq!(manager.challenge_level(), 2);
744
745        // Generate challenge via trait
746        let response = manager.generate_challenge("actor_123");
747        match response {
748            ChallengeResponse::JsChallenge {
749                html,
750                expected_solution,
751                expires_at,
752            } => {
753                assert!(!html.is_empty());
754                assert_eq!(expected_solution, "00");
755                assert!(expires_at > now_ms());
756            }
757            _ => panic!("Expected JsChallenge response"),
758        }
759    }
760
761    #[test]
762    fn test_stats_tracking() {
763        let manager = JsChallengeManager::new(test_config());
764
765        // Generate challenges
766        manager.generate_pow_challenge("actor_1");
767        manager.generate_pow_challenge("actor_2");
768
769        let stats = manager.stats().snapshot();
770        assert_eq!(stats.challenges_issued, 2);
771
772        // Failed validation (numeric nonce that won't solve PoW)
773        manager.validate_pow("actor_1", "99999999");
774        let stats = manager.stats().snapshot();
775        assert_eq!(stats.challenges_failed, 1);
776    }
777
778    #[test]
779    fn test_random_hex_generation() {
780        let hex1 = generate_random_hex(16);
781        let hex2 = generate_random_hex(16);
782
783        assert_eq!(hex1.len(), 16);
784        assert_eq!(hex2.len(), 16);
785        // Very unlikely to be the same
786        assert_ne!(hex1, hex2);
787
788        // Should be valid hex
789        assert!(hex1.chars().all(|c| c.is_ascii_hexdigit()));
790    }
791
792    #[test]
793    fn test_has_challenge() {
794        let manager = JsChallengeManager::new(test_config());
795
796        assert!(!manager.has_challenge("actor_123"));
797
798        manager.generate_pow_challenge("actor_123");
799        assert!(manager.has_challenge("actor_123"));
800
801        manager.clear();
802        assert!(!manager.has_challenge("actor_123"));
803    }
804
805    #[test]
806    fn test_successful_validation_clears_state() {
807        let config = JsChallengeConfig {
808            difficulty: 4, // Higher difficulty to ensure "99999999" won't accidentally pass
809            ..test_config()
810        };
811        let manager = JsChallengeManager::new(config);
812        let challenge = manager.generate_pow_challenge("actor_123");
813
814        // Make some failed attempts - verify they actually fail
815        let result1 = manager.validate_pow("actor_123", "99999999");
816        assert!(matches!(result1, ValidationResult::Invalid(_)));
817        let result2 = manager.validate_pow("actor_123", "99999998");
818        assert!(matches!(result2, ValidationResult::Invalid(_)));
819        assert_eq!(manager.get_attempts("actor_123"), 2);
820        assert!(manager.has_challenge("actor_123"));
821
822        // Find valid solution (need 4 leading zeros)
823        let mut nonce = 0u64;
824        loop {
825            let data = format!("{}{}", challenge.prefix, nonce);
826            let hash = compute_sha256_hex(&data);
827            if hash.starts_with("0000") {
828                break;
829            }
830            nonce += 1;
831        }
832
833        // Successful validation
834        let result = manager.validate_pow("actor_123", &nonce.to_string());
835        assert_eq!(result, ValidationResult::Valid);
836
837        // State should be cleared
838        assert!(!manager.has_challenge("actor_123"));
839        assert_eq!(manager.get_attempts("actor_123"), 0);
840    }
841}