Skip to main content

ntex_basicauth/
utils.rs

1//! Utility functions and helper types
2
3use crate::{AuthError, AuthResult, BasicAuth, BasicAuthConfig, Credentials, UserValidator};
4use ntex::web::{HttpRequest, WebRequest};
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[cfg(feature = "regex")]
10use regex::Regex;
11
12#[cfg(feature = "cache")]
13use crate::cache::CacheConfig;
14
15#[cfg(feature = "regex")]
16use std::sync::OnceLock;
17
18#[cfg(feature = "regex")]
19static REGEX_CACHE: OnceLock<dashmap::DashMap<String, Regex>> = OnceLock::new();
20
21/// Extract authenticated user credentials from request
22pub fn extract_credentials(req: &HttpRequest) -> Option<Credentials> {
23    req.extensions().get::<Credentials>().cloned()
24}
25
26/// Extract authenticated user credentials from WebRequest
27pub fn extract_credentials_web<T>(req: &WebRequest<T>) -> Option<Credentials> {
28    req.extensions().get::<Credentials>().cloned()
29}
30
31/// Get authenticated username from request
32pub fn get_username(req: &HttpRequest) -> Option<String> {
33    extract_credentials(req).map(|creds| creds.username.clone())
34}
35
36/// Check if current user matches a specific username
37pub fn is_user(req: &HttpRequest, username: &str) -> bool {
38    get_username(req).is_some_and(|user| user == username)
39}
40
41/// Path filter for conditional authentication
42#[derive(Debug, Clone)]
43pub struct PathFilter {
44    patterns: Vec<PathPattern>,
45}
46
47#[derive(Debug, Clone)]
48enum PathPattern {
49    Exact(String),
50    Prefix(String),
51    Suffix(String),
52    #[cfg(feature = "regex")]
53    Regex(Regex),
54}
55
56impl PathFilter {
57    /// Create a new PathFilter instance
58    pub fn new() -> Self {
59        Self {
60            patterns: Vec::new(),
61        }
62    }
63
64    /// Skip authentication for exact path
65    pub fn skip_exact<P: Into<String>>(mut self, path: P) -> Self {
66        self.patterns.push(PathPattern::Exact(path.into()));
67        self
68    }
69
70    /// Skip authentication for paths with prefix
71    pub fn skip_prefix<P: Into<String>>(mut self, prefix: P) -> Self {
72        self.patterns.push(PathPattern::Prefix(prefix.into()));
73        self
74    }
75
76    /// Skip authentication for paths with suffix
77    pub fn skip_suffix<P: Into<String>>(mut self, suffix: P) -> Self {
78        self.patterns.push(PathPattern::Suffix(suffix.into()));
79        self
80    }
81
82    /// Skip authentication for paths matching regex pattern
83    /// Requires "regex" feature
84    #[cfg(feature = "regex")]
85    pub fn skip_regex<P: AsRef<str>>(mut self, pattern: P) -> Result<Self, regex::Error> {
86        let regex = Self::get_cached_regex(pattern.as_ref())?;
87        self.patterns.push(PathPattern::Regex(regex));
88        Ok(self)
89    }
90
91    /// Get cached regex pattern for better performance
92    #[cfg(feature = "regex")]
93    fn get_cached_regex(pattern: &str) -> Result<Regex, regex::Error> {
94        let cache = REGEX_CACHE.get_or_init(dashmap::DashMap::new);
95
96        if let Some(regex) = cache.get(pattern) {
97            Ok(regex.clone())
98        } else {
99            let regex = Regex::new(pattern)?;
100            cache.insert(pattern.to_string(), regex.clone());
101            Ok(regex)
102        }
103    }
104
105    /// Skip authentication for regex pattern (feature disabled version)
106    #[cfg(not(feature = "regex"))]
107    pub fn skip_regex<P: Into<String>>(self, _pattern: P) -> Result<Self, AuthError> {
108        Err(AuthError::ConfigError(
109            "regex feature not enabled. Please use: features = [\"regex\"]".to_string(),
110        ))
111    }
112
113    /// Skip authentication for multiple exact paths
114    pub fn skip_paths<I, P>(mut self, paths: I) -> Self
115    where
116        I: IntoIterator<Item = P>,
117        P: Into<String>,
118    {
119        for path in paths {
120            self.patterns.push(PathPattern::Exact(path.into()));
121        }
122        self
123    }
124
125    /// Skip authentication for multiple prefixes
126    pub fn skip_prefixes<I, P>(mut self, prefixes: I) -> Self
127    where
128        I: IntoIterator<Item = P>,
129        P: Into<String>,
130    {
131        for prefix in prefixes {
132            self.patterns.push(PathPattern::Prefix(prefix.into()));
133        }
134        self
135    }
136
137    /// Skip authentication for multiple suffixes
138    pub fn skip_suffixes<I, P>(mut self, suffixes: I) -> Self
139    where
140        I: IntoIterator<Item = P>,
141        P: Into<String>,
142    {
143        for suffix in suffixes {
144            self.patterns.push(PathPattern::Suffix(suffix.into()));
145        }
146        self
147    }
148
149    /// Check if path should skip authentication
150    pub fn should_skip(&self, path: &str) -> bool {
151        self.patterns.iter().any(|pattern| match pattern {
152            PathPattern::Exact(exact) => path == exact,
153            PathPattern::Prefix(prefix) => path.starts_with(prefix),
154            PathPattern::Suffix(suffix) => path.ends_with(suffix),
155            #[cfg(feature = "regex")]
156            PathPattern::Regex(regex) => regex.is_match(path),
157        })
158    }
159
160    /// Get number of patterns
161    pub fn pattern_count(&self) -> usize {
162        self.patterns.len()
163    }
164
165    /// Check if filter is empty
166    pub fn is_empty(&self) -> bool {
167        self.patterns.is_empty()
168    }
169
170    /// Get all exact match paths
171    pub fn exact_paths(&self) -> Vec<&str> {
172        self.patterns
173            .iter()
174            .filter_map(|pattern| match pattern {
175                PathPattern::Exact(path) => Some(path.as_str()),
176                _ => None,
177            })
178            .collect()
179    }
180
181    /// Get all prefixes
182    pub fn prefixes(&self) -> Vec<&str> {
183        self.patterns
184            .iter()
185            .filter_map(|pattern| match pattern {
186                PathPattern::Prefix(prefix) => Some(prefix.as_str()),
187                _ => None,
188            })
189            .collect()
190    }
191
192    /// Clear all patterns
193    pub fn clear(mut self) -> Self {
194        self.patterns.clear();
195        self
196    }
197
198    /// Remove a specific exact path pattern
199    pub fn remove_exact_path(mut self, path: &str) -> Self {
200        self.patterns
201            .retain(|pattern| !matches!(pattern, PathPattern::Exact(p) if p == path));
202        self
203    }
204}
205
206impl Default for PathFilter {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212/// Builder for BasicAuth, provides extra convenience methods
213pub struct BasicAuthBuilder {
214    users: Option<HashMap<String, String>>,
215    validator: Option<Arc<dyn UserValidator>>,
216    realm: Option<String>,
217    #[cfg(feature = "cache")]
218    cache_config: Option<CacheConfig>,
219    path_filter: Option<PathFilter>,
220    max_header_size: Option<usize>,
221    log_failures: bool,
222    case_sensitive: bool,
223    // New enhanced configuration fields
224    max_concurrent_validations: Option<usize>,
225    validation_timeout: Option<Duration>,
226    rate_limit_per_ip: Option<(usize, Duration)>,
227    client_ip_header: Option<String>,
228    enable_metrics: bool,
229    log_usernames_in_production: bool,
230}
231
232impl BasicAuthBuilder {
233    /// Create a new BasicAuthBuilder instance
234    pub fn new() -> Self {
235        Self {
236            users: None,
237            validator: None,
238            realm: None,
239            #[cfg(feature = "cache")]
240            cache_config: None,
241            path_filter: None,
242            max_header_size: None,
243            log_failures: false,
244            case_sensitive: true,
245            max_concurrent_validations: None,
246            validation_timeout: None,
247            rate_limit_per_ip: None,
248            client_ip_header: None,
249            enable_metrics: true,
250            log_usernames_in_production: false,
251        }
252    }
253
254    /// Add a single user
255    pub fn user<U: Into<String>, P: Into<String>>(mut self, username: U, password: P) -> Self {
256        let users = self.users.get_or_insert_with(HashMap::new);
257        users.insert(username.into(), password.into());
258        self
259    }
260
261    /// Add multiple users from HashMap
262    pub fn users(mut self, users: HashMap<String, String>) -> Self {
263        match &mut self.users {
264            Some(existing) => existing.extend(users),
265            None => self.users = Some(users),
266        }
267        self
268    }
269
270    /// Add users from iterator
271    pub fn users_from_iter<I, U, P>(mut self, users: I) -> Self
272    where
273        I: IntoIterator<Item = (U, P)>,
274        U: Into<String>,
275        P: Into<String>,
276    {
277        let users_map = self.users.get_or_insert_with(HashMap::new);
278        for (username, password) in users {
279            users_map.insert(username.into(), password.into());
280        }
281        self
282    }
283
284    /// Load users from file (format: username:password, one per line)
285    pub fn users_from_file<P: AsRef<std::path::Path>>(mut self, path: P) -> AuthResult<Self> {
286        let content = std::fs::read_to_string(path)
287            .map_err(|e| AuthError::ConfigError(format!("Failed to read user file: {}", e)))?;
288
289        let users_map = self.users.get_or_insert_with(HashMap::new);
290
291        for (line_num, line) in content.lines().enumerate() {
292            let line = line.trim();
293            if line.is_empty() || line.starts_with('#') {
294                continue; // Skip empty lines and comments
295            }
296
297            let colon_pos = line.find(':').ok_or_else(|| {
298                AuthError::ConfigError(format!(
299                    "User file line {} format error: missing colon separator",
300                    line_num + 1
301                ))
302            })?;
303
304            let username = line[..colon_pos].trim().to_string();
305            let password = line[colon_pos + 1..].trim().to_string();
306
307            // Allow empty usernames per RFC 7617
308            if !is_valid_username(&username) {
309                return Err(AuthError::ConfigError(format!(
310                    "User file line {} format error: invalid username format",
311                    line_num + 1
312                )));
313            }
314
315            users_map.insert(username, password);
316        }
317
318        Ok(self)
319    }
320
321    /// Set custom validator
322    pub fn validator(mut self, validator: Arc<dyn UserValidator>) -> Self {
323        self.validator = Some(validator);
324        self
325    }
326
327    /// Set authentication realm
328    pub fn realm<R: Into<String>>(mut self, realm: R) -> Self {
329        self.realm = Some(realm.into());
330        self
331    }
332
333    /// Set username case sensitivity
334    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
335        self.case_sensitive = sensitive;
336        self
337    }
338
339    /// Enable authentication cache
340    #[cfg(feature = "cache")]
341    pub fn with_cache(mut self, config: CacheConfig) -> Self {
342        self.cache_config = Some(config);
343        self
344    }
345
346    /// Disable authentication cache
347    #[cfg(feature = "cache")]
348    pub fn disable_cache(mut self) -> Self {
349        self.cache_config = None;
350        self
351    }
352
353    /// Set cache TTL (seconds)
354    #[cfg(feature = "cache")]
355    pub fn cache_ttl_seconds(mut self, seconds: u64) -> Self {
356        let config = self.cache_config.take().unwrap_or_default();
357        self.cache_config = Some(config.ttl_seconds(seconds));
358        self
359    }
360
361    /// Set cache TTL (minutes, convenience)
362    #[cfg(feature = "cache")]
363    pub fn cache_ttl_minutes(self, minutes: u64) -> Self {
364        self.cache_ttl_seconds(minutes * 60)
365    }
366
367    /// Set cache TTL (hours, convenience)
368    #[cfg(feature = "cache")]
369    pub fn cache_ttl_hours(self, hours: u64) -> Self {
370        self.cache_ttl_seconds(hours * 3600)
371    }
372
373    /// Set cache size limit
374    #[cfg(feature = "cache")]
375    pub fn cache_size_limit(mut self, limit: usize) -> Self {
376        let config = self.cache_config.take().unwrap_or_default();
377        self.cache_config = Some(config.max_size(limit));
378        self
379    }
380
381    /// Set path filter
382    pub fn path_filter(mut self, filter: PathFilter) -> Self {
383        self.path_filter = Some(filter);
384        self
385    }
386
387    /// Configure path filter (builder pattern)
388    pub fn configure_paths<F>(mut self, configure: F) -> Self
389    where
390        F: FnOnce(PathFilter) -> PathFilter,
391    {
392        let filter = self.path_filter.take().unwrap_or_default();
393        self.path_filter = Some(configure(filter));
394        self
395    }
396
397    /// Add skip paths (convenience)
398    pub fn skip_paths<I, P>(mut self, paths: I) -> Self
399    where
400        I: IntoIterator<Item = P>,
401        P: Into<String>,
402    {
403        let mut filter = self.path_filter.take().unwrap_or_default();
404        filter = filter.skip_paths(paths);
405        self.path_filter = Some(filter);
406        self
407    }
408
409    /// Set request header size limit
410    pub fn max_header_size(mut self, size: usize) -> Self {
411        self.max_header_size = Some(size);
412        self
413    }
414
415    /// Enable authentication failure logging
416    pub fn log_failures(mut self, enabled: bool) -> Self {
417        self.log_failures = enabled;
418        self
419    }
420
421    /// Set maximum concurrent validations
422    pub fn max_concurrent_validations(mut self, max: usize) -> Self {
423        self.max_concurrent_validations = Some(max);
424        self
425    }
426
427    /// Set validation timeout
428    pub fn validation_timeout(mut self, timeout: Duration) -> Self {
429        self.validation_timeout = Some(timeout);
430        self
431    }
432
433    /// Set rate limiting per IP
434    pub fn rate_limit_per_ip(mut self, max_requests: usize, window: Duration) -> Self {
435        self.rate_limit_per_ip = Some((max_requests, window));
436        self
437    }
438
439    /// Set the header to read the client IP from for rate limiting (e.g.
440    /// `"x-forwarded-for"`). Only enable this behind a trusted proxy, since
441    /// clients can otherwise spoof the header to evade or forge rate limits.
442    pub fn client_ip_header(mut self, header: impl Into<String>) -> Self {
443        self.client_ip_header = Some(header.into());
444        self
445    }
446
447    /// Enable or disable metrics collection
448    pub fn enable_metrics(mut self, enabled: bool) -> Self {
449        self.enable_metrics = enabled;
450        self
451    }
452
453    /// Enable or disable logging usernames in production (security risk)
454    pub fn log_usernames_in_production(mut self, enabled: bool) -> Self {
455        self.log_usernames_in_production = enabled;
456        self
457    }
458
459    /// Build BasicAuth instance
460    pub fn build(self) -> AuthResult<BasicAuth> {
461        let validator = if let Some(validator) = self.validator {
462            validator
463        } else if let Some(users) = self.users {
464            use crate::auth::StaticUserValidator;
465            Arc::new(if self.case_sensitive {
466                StaticUserValidator::from_map(users)
467            } else {
468                StaticUserValidator::from_map_case_insensitive(users)
469            })
470        } else {
471            return Err(AuthError::ConfigError(
472                "A validator or user list must be provided".to_string(),
473            ));
474        };
475
476        let mut config = BasicAuthConfig::new(validator);
477
478        if let Some(realm) = self.realm {
479            config = config.realm(realm);
480        }
481
482        #[cfg(feature = "cache")]
483        {
484            if let Some(cache_config) = self.cache_config {
485                config = config.with_cache(cache_config)?;
486            }
487        }
488
489        if let Some(filter) = self.path_filter {
490            config = config.path_filter(filter);
491        }
492
493        if let Some(size) = self.max_header_size {
494            config = config.max_header_size(size);
495        }
496
497        config = config.log_failures(self.log_failures);
498
499        // Apply new enhanced configuration options
500        if let Some(max_concurrent) = self.max_concurrent_validations {
501            config = config.max_concurrent_validations(max_concurrent);
502        }
503
504        if let Some(timeout) = self.validation_timeout {
505            config = config.validation_timeout(timeout);
506        }
507
508        if let Some((max_requests, window)) = self.rate_limit_per_ip {
509            config = config.rate_limit_per_ip(max_requests, window);
510        }
511
512        if let Some(header) = self.client_ip_header {
513            config = config.client_ip_header(header);
514        }
515
516        config = config.enable_metrics(self.enable_metrics);
517        config = config.log_usernames_in_production(self.log_usernames_in_production);
518
519        BasicAuth::new(config)
520    }
521
522    /// Build and wrap error handling
523    pub fn try_build(self) -> AuthResult<BasicAuth> {
524        self.build()
525    }
526}
527
528impl Default for BasicAuthBuilder {
529    fn default() -> Self {
530        Self::new()
531    }
532}
533
534/// Convenience macro for creating PathFilter
535#[macro_export]
536macro_rules! path_filter {
537    // Only exact match
538    (exact: [$($path:expr),* $(,)?]) => {
539        {
540            let mut filter = $crate::PathFilter::new();
541            $(
542                filter = filter.skip_exact($path);
543            )*
544            filter
545        }
546    };
547
548    // Only prefix match
549    (prefix: [$($prefix:expr),* $(,)?]) => {
550        {
551            let mut filter = $crate::PathFilter::new();
552            $(
553                filter = filter.skip_prefix($prefix);
554            )*
555            filter
556        }
557    };
558
559    // Only suffix match
560    (suffix: [$($suffix:expr),* $(,)?]) => {
561        {
562            let mut filter = $crate::PathFilter::new();
563            $(
564                filter = filter.skip_suffix($suffix);
565            )*
566            filter
567        }
568    };
569
570    // Mixed mode
571    (
572        $(exact: [$($exact:expr),* $(,)?])?
573        $(prefix: [$($prefix:expr),* $(,)?])?
574        $(suffix: [$($suffix:expr),* $(,)?])?
575        $(regex: [$($regex:expr),* $(,)?])?
576    ) => {
577        {
578            let mut filter = $crate::PathFilter::new();
579
580            $($(
581                filter = filter.skip_exact($exact);
582            )*)?
583
584            $($(
585                filter = filter.skip_prefix($prefix);
586            )*)?
587
588            $($(
589                filter = filter.skip_suffix($suffix);
590            )*)?
591
592            #[cfg(feature = "regex")]
593            $($(
594                filter = filter.skip_regex($regex).expect("Invalid regex pattern");
595            )*)?
596
597            filter
598        }
599    };
600}
601
602/// Validate if username format is valid
603pub fn is_valid_username(username: &str) -> bool {
604    !username.contains(':') && !username.contains('\n') &&
605    !username.contains('\r') &&
606    username.len() <= 255 && // Reasonable length limit
607    username.chars().all(|c| c.is_ascii_graphic() || c == ' ')
608}
609
610/// Create a PathFilter with common skip paths.
611///
612/// Exempts liveness/readiness probes (`/health`, `/healthcheck`, `/ping`) and
613/// static assets from authentication. Observability endpoints such as
614/// `/metrics` and `/status` are intentionally **not** exempted, since they can
615/// leak internal state and should stay behind auth and per-IP rate limiting.
616/// If a deployment genuinely needs them public, opt in explicitly with
617/// [`observability_skip_paths`] or `.skip_paths([...])`.
618pub fn common_skip_paths() -> PathFilter {
619    PathFilter::new()
620        .skip_paths(["/health", "/healthcheck", "/ping", "/favicon.ico"])
621        .skip_prefixes(["/static/", "/assets/", "/public/", "/.well-known/"])
622        .skip_suffixes([
623            ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2",
624            ".ttf", ".eot",
625        ])
626}
627
628/// Create a PathFilter that exempts observability endpoints (`/metrics`,
629/// `/status`) from authentication.
630///
631/// Opt in only when these routes are meant to be public. Prefer keeping them
632/// authenticated; if you must expose them, restrict access at the network
633/// layer as well.
634pub fn observability_skip_paths() -> PathFilter {
635    PathFilter::new().skip_paths(["/metrics", "/status"])
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn test_path_filter_comprehensive() {
644        let filter = PathFilter::new()
645            .skip_exact("/health")
646            .skip_prefix("/public/")
647            .skip_suffix(".css")
648            .skip_paths(["/api/status", "/metrics"]);
649
650        assert!(filter.should_skip("/health"));
651        assert!(filter.should_skip("/public/images/logo.png"));
652        assert!(filter.should_skip("/assets/style.css"));
653        assert!(filter.should_skip("/api/status"));
654        assert!(filter.should_skip("/metrics"));
655        assert!(!filter.should_skip("/api/users"));
656        assert!(!filter.should_skip("/healthcheck"));
657    }
658
659    #[cfg(feature = "regex")]
660    #[test]
661    fn test_regex_path_filter() {
662        let filter = PathFilter::new()
663            .skip_regex(r"^/api/v\d+/public/.*$")
664            .expect("Valid regex");
665
666        assert!(filter.should_skip("/api/v1/public/data"));
667        assert!(filter.should_skip("/api/v2/public/files"));
668        assert!(!filter.should_skip("/api/v1/private/data"));
669        assert!(!filter.should_skip("/api/public/data"));
670    }
671
672    #[test]
673    fn test_builder_comprehensive() {
674        let auth = BasicAuthBuilder::new()
675            .user("admin", "secret")
676            .user("user", "password")
677            .users_from_iter([("guest", "guest123")])
678            .realm("Test Application")
679            .skip_paths(["/health", "/metrics"])
680            .log_failures(true)
681            .case_sensitive(false)
682            .max_header_size(4096)
683            .build()
684            .expect("Valid configuration");
685
686        assert_eq!(auth.config.realm, "Test Application");
687        assert_eq!(auth.config.max_header_size, 4096);
688        assert!(auth.config.log_failures);
689    }
690
691    #[test]
692    fn test_common_skip_paths() {
693        let filter = common_skip_paths();
694
695        // Health/readiness probes and static assets remain public.
696        assert!(filter.should_skip("/health"));
697        assert!(filter.should_skip("/healthcheck"));
698        assert!(filter.should_skip("/ping"));
699        assert!(filter.should_skip("/static/css/main.css"));
700        assert!(filter.should_skip("/favicon.ico"));
701        assert!(filter.should_skip("/public/images/logo.png"));
702        assert!(filter.should_skip("/.well-known/acme-challenge/test"));
703
704        // Each supported font suffix is treated as a static asset.
705        assert!(filter.should_skip("/assets/icons.woff"));
706        assert!(filter.should_skip("/assets/icons.woff2"));
707        assert!(filter.should_skip("/assets/icons.ttf"));
708        assert!(filter.should_skip("/assets/icons.eot"));
709
710        // Observability endpoints are NOT exempt: they must pass auth and
711        // per-IP rate limiting.
712        assert!(!filter.should_skip("/status"));
713        assert!(!filter.should_skip("/metrics"));
714
715        // Near-matches / unrelated routes still require auth.
716        assert!(!filter.should_skip("/api/users"));
717        assert!(!filter.should_skip("/health/live")); // exact "/health" only, not a prefix
718        assert!(!filter.should_skip("/metrics.json")); // not the exact "/metrics"
719
720        // The opt-in helper re-exempts observability endpoints when needed.
721        let obs = observability_skip_paths();
722        assert!(obs.should_skip("/metrics"));
723        assert!(obs.should_skip("/status"));
724    }
725
726    #[test]
727    fn test_username_validation() {
728        assert!(is_valid_username("admin"));
729        assert!(is_valid_username("user123"));
730        assert!(is_valid_username("test user")); // contains space
731
732        // Now allows empty username per RFC 7617
733        assert!(is_valid_username(""));
734        assert!(!is_valid_username("user:name")); // contains colon
735        assert!(!is_valid_username("user\nname")); // contains newline
736        assert!(!is_valid_username(&"a".repeat(256))); // too long
737    }
738
739    #[test]
740    fn test_builder_from_file() -> std::io::Result<()> {
741        use std::io::Write;
742
743        // Create temporary file
744        let mut temp_file = tempfile::NamedTempFile::new()?;
745        writeln!(temp_file, "# This is a comment")?;
746        writeln!(temp_file, "admin:secret")?;
747        writeln!(temp_file, "user:password:with:colons")?;
748        writeln!(temp_file)?; // empty line
749        writeln!(temp_file, "guest:guest123")?;
750
751        let builder = BasicAuthBuilder::new()
752            .users_from_file(temp_file.path())
753            .expect("Failed to load users from file");
754
755        let auth = builder.build().expect("Failed to build authentication");
756
757        // Verify users are loaded correctly
758        let validator = auth.config.validator.as_ref();
759        assert_eq!(validator.user_count(), 3);
760
761        Ok(())
762    }
763
764    #[test]
765    fn test_path_filter_modification() {
766        let filter = PathFilter::new()
767            .skip_exact("/health")
768            .skip_exact("/status");
769
770        assert_eq!(filter.pattern_count(), 2);
771
772        let filter = filter.remove_exact_path("/health");
773        assert_eq!(filter.pattern_count(), 1);
774
775        let filter = filter.clear();
776        assert_eq!(filter.pattern_count(), 0);
777        assert!(filter.is_empty());
778    }
779}