Skip to main content

cashu/nuts/auth/
nut21.rs

1//! 21 Clear Auth
2
3use std::collections::HashSet;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// NUT21 Error
10#[derive(Debug, Error)]
11pub enum Error {
12    /// Invalid pattern
13    #[error("Invalid pattern: {0}")]
14    InvalidPattern(String),
15    /// Unknown route path
16    #[error("Unknown route path: {0}. Valid paths are: /v1/mint/quote/{{method}}, /v1/mint/{{method}}, /v1/melt/quote/{{method}}, /v1/melt/{{method}}, /v1/swap, /v1/checkstate, /v1/restore, /v1/auth/blind/mint, /v1/ws")]
17    UnknownRoute(String),
18}
19
20/// Clear Auth Settings
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)]
22#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
23pub struct Settings {
24    /// Openid discovery
25    pub openid_discovery: String,
26    /// Client ID
27    pub client_id: String,
28    /// Protected endpoints
29    pub protected_endpoints: Vec<ProtectedEndpoint>,
30}
31
32impl Settings {
33    /// Create new [`Settings`]
34    pub fn new(
35        openid_discovery: String,
36        client_id: String,
37        protected_endpoints: Vec<ProtectedEndpoint>,
38    ) -> Self {
39        Self {
40            openid_discovery,
41            client_id,
42            protected_endpoints,
43        }
44    }
45}
46
47// Custom deserializer for Settings to expand patterns in protected endpoints
48impl<'de> Deserialize<'de> for Settings {
49    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
50    where
51        D: serde::Deserializer<'de>,
52    {
53        // Define a temporary struct to deserialize the raw data
54        #[derive(Deserialize)]
55        struct RawSettings {
56            openid_discovery: String,
57            client_id: String,
58            protected_endpoints: Vec<RawProtectedEndpoint>,
59        }
60
61        #[derive(Deserialize)]
62        struct RawProtectedEndpoint {
63            method: Method,
64            path: String,
65        }
66
67        // Deserialize into the temporary struct
68        let raw = RawSettings::deserialize(deserializer)?;
69
70        // Process protected endpoints, expanding patterns if present
71        let mut protected_endpoints = HashSet::new();
72
73        for raw_endpoint in raw.protected_endpoints {
74            let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
75                serde::de::Error::custom(format!("Invalid pattern '{}': {}", raw_endpoint.path, e))
76            })?;
77
78            for path in expanded_paths {
79                protected_endpoints.insert(ProtectedEndpoint::new(raw_endpoint.method, path));
80            }
81        }
82
83        // Create the final Settings struct
84        Ok(Settings {
85            openid_discovery: raw.openid_discovery,
86            client_id: raw.client_id,
87            protected_endpoints: protected_endpoints.into_iter().collect(),
88        })
89    }
90}
91
92/// List of the methods and paths that are protected
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
95pub struct ProtectedEndpoint {
96    /// HTTP Method
97    pub method: Method,
98    /// Route path
99    pub path: RoutePath,
100}
101
102impl ProtectedEndpoint {
103    /// Create [`ProtectedEndpoint`]
104    pub fn new(method: Method, path: RoutePath) -> Self {
105        Self { method, path }
106    }
107}
108
109/// HTTP method
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
111#[serde(rename_all = "UPPERCASE")]
112#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
113pub enum Method {
114    /// Get
115    Get,
116    /// POST
117    Post,
118}
119
120/// Route path
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
123pub enum RoutePath {
124    /// Mint Quote for a specific payment method
125    MintQuote(String),
126    /// Mint for a specific payment method
127    Mint(String),
128    /// Melt Quote for a specific payment method
129    MeltQuote(String),
130    /// Melt for a specific payment method
131    Melt(String),
132    /// Swap
133    Swap,
134    /// Checkstate
135    Checkstate,
136    /// Restore
137    Restore,
138    /// Mint Blind Auth
139    MintBlindAuth,
140    /// WebSocket
141    Ws,
142}
143
144impl Serialize for RoutePath {
145    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
146    where
147        S: serde::Serializer,
148    {
149        serializer.serialize_str(&self.to_string())
150    }
151}
152
153impl std::str::FromStr for RoutePath {
154    type Err = Error;
155
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        // Try to parse as a known static path first
158        match s {
159            "/v1/swap" => Ok(RoutePath::Swap),
160            "/v1/checkstate" => Ok(RoutePath::Checkstate),
161            "/v1/restore" => Ok(RoutePath::Restore),
162            "/v1/auth/blind/mint" => Ok(RoutePath::MintBlindAuth),
163            "/v1/ws" => Ok(RoutePath::Ws),
164            _ => {
165                // Try to parse as a payment method route
166                if let Some(method) = s.strip_prefix("/v1/mint/quote/") {
167                    Ok(RoutePath::MintQuote(method.to_string()))
168                } else if let Some(method) = s.strip_prefix("/v1/mint/") {
169                    Ok(RoutePath::Mint(method.to_string()))
170                } else if let Some(method) = s.strip_prefix("/v1/melt/quote/") {
171                    Ok(RoutePath::MeltQuote(method.to_string()))
172                } else if let Some(method) = s.strip_prefix("/v1/melt/") {
173                    Ok(RoutePath::Melt(method.to_string()))
174                } else {
175                    // Unknown path - this might be an old database value or config
176                    // Provide a helpful error message
177                    Err(Error::UnknownRoute(s.to_string()))
178                }
179            }
180        }
181    }
182}
183
184impl<'de> Deserialize<'de> for RoutePath {
185    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
186    where
187        D: serde::Deserializer<'de>,
188    {
189        let s = String::deserialize(deserializer)?;
190        RoutePath::from_str(&s).map_err(serde::de::Error::custom)
191    }
192}
193
194impl RoutePath {
195    /// Get all non-payment-method route paths
196    /// These are routes that don't depend on payment methods
197    pub fn static_paths() -> Vec<RoutePath> {
198        vec![
199            RoutePath::Swap,
200            RoutePath::Checkstate,
201            RoutePath::Restore,
202            RoutePath::MintBlindAuth,
203            RoutePath::Ws,
204        ]
205    }
206
207    /// Get all route paths for common payment methods (bolt11, bolt12)
208    /// This is used for pattern matching in configuration
209    pub fn common_payment_method_paths() -> Vec<RoutePath> {
210        let methods = vec!["bolt11", "bolt12"];
211        let mut paths = Vec::new();
212
213        for method in methods {
214            paths.push(RoutePath::MintQuote(method.to_string()));
215            paths.push(RoutePath::Mint(method.to_string()));
216            paths.push(RoutePath::MeltQuote(method.to_string()));
217            paths.push(RoutePath::Melt(method.to_string()));
218        }
219
220        paths
221    }
222
223    /// Get all paths for pattern matching (static + common payment methods)
224    pub fn all_known_paths() -> Vec<RoutePath> {
225        let mut paths = Self::static_paths();
226        paths.extend(Self::common_payment_method_paths());
227        paths
228    }
229}
230
231/// Returns [`RoutePath`]s that match the pattern (Exact or Prefix)
232pub fn matching_route_paths(pattern: &str) -> Result<Vec<RoutePath>, Error> {
233    // Check for wildcard
234    if let Some(prefix) = pattern.strip_suffix('*') {
235        // Prefix matching
236        // Ensure '*' is only at the end
237        if prefix.contains('*') {
238            return Err(Error::InvalidPattern(
239                "Wildcard '*' must be the last character".to_string(),
240            ));
241        }
242
243        // Filter all known paths
244        Ok(RoutePath::all_known_paths()
245            .into_iter()
246            .filter(|path| path.to_string().starts_with(prefix))
247            .collect())
248    } else {
249        // Exact matching
250        if pattern.contains('*') {
251            return Err(Error::InvalidPattern(
252                "Wildcard '*' must be the last character".to_string(),
253            ));
254        }
255
256        match RoutePath::from_str(pattern) {
257            Ok(path) => Ok(vec![path]),
258            Err(_) => Ok(vec![]), // Ignore unknown paths for matching
259        }
260    }
261}
262impl std::fmt::Display for RoutePath {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            RoutePath::MintQuote(method) => write!(f, "/v1/mint/quote/{}", method),
266            RoutePath::Mint(method) => write!(f, "/v1/mint/{}", method),
267            RoutePath::MeltQuote(method) => write!(f, "/v1/melt/quote/{}", method),
268            RoutePath::Melt(method) => write!(f, "/v1/melt/{}", method),
269            RoutePath::Swap => write!(f, "/v1/swap"),
270            RoutePath::Checkstate => write!(f, "/v1/checkstate"),
271            RoutePath::Restore => write!(f, "/v1/restore"),
272            RoutePath::MintBlindAuth => write!(f, "/v1/auth/blind/mint"),
273            RoutePath::Ws => write!(f, "/v1/ws"),
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280
281    use super::*;
282    use crate::nut00::KnownMethod;
283    use crate::PaymentMethod;
284
285    #[test]
286    fn test_matching_route_paths_root_wildcard() {
287        // Pattern that matches everything
288        let paths = matching_route_paths("*").unwrap();
289
290        // Should match all known variants
291        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
292    }
293
294    #[test]
295    fn test_matching_route_paths_middle_wildcard() {
296        // Invalid wildcard position
297        let result = matching_route_paths("/v1/*/mint");
298        assert!(result.is_err());
299        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
300    }
301
302    #[test]
303    fn test_matching_route_paths_prefix_without_slash() {
304        // "/v1/mint*" matches "/v1/mint" and "/v1/mint/..."
305        let paths = matching_route_paths("/v1/mint*").unwrap();
306
307        // Should match all mint paths + mint quote paths
308        assert_eq!(paths.len(), 4);
309
310        // Should NOT match /v1/melt...
311        assert!(!paths.contains(&RoutePath::MeltQuote(
312            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
313        )));
314    }
315
316    #[test]
317    fn test_matching_route_paths_exact_match_unknown() {
318        // Exact match for unknown path structure should return empty list
319        let paths = matching_route_paths("/v1/invalid/path").unwrap();
320        assert!(paths.is_empty());
321    }
322
323    #[test]
324    fn test_matching_route_paths_dynamic_method() {
325        // Verify that custom payment methods are parsed correctly
326        let paths = matching_route_paths("/v1/mint/custom_method").unwrap();
327        assert_eq!(paths.len(), 1);
328        assert_eq!(paths[0], RoutePath::Mint("custom_method".to_string()));
329    }
330
331    #[test]
332    fn test_matching_route_paths_all() {
333        // Prefix that matches all paths
334        let paths = matching_route_paths("/v1/*").unwrap();
335
336        // Should match all known variants
337        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
338
339        // Verify all variants are included
340        assert!(paths.contains(&RoutePath::MintQuote(
341            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
342        )));
343        assert!(paths.contains(&RoutePath::Mint(
344            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
345        )));
346        assert!(paths.contains(&RoutePath::MeltQuote(
347            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
348        )));
349        assert!(paths.contains(&RoutePath::Melt(
350            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
351        )));
352        assert!(paths.contains(&RoutePath::MintQuote(
353            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
354        )));
355        assert!(paths.contains(&RoutePath::Mint(
356            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
357        )));
358        assert!(paths.contains(&RoutePath::Swap));
359        assert!(paths.contains(&RoutePath::Checkstate));
360        assert!(paths.contains(&RoutePath::Restore));
361        assert!(paths.contains(&RoutePath::MintBlindAuth));
362    }
363
364    #[test]
365    fn test_matching_route_paths_mint_only() {
366        // Regex that matches only mint paths
367        let paths = matching_route_paths("/v1/mint/*").unwrap();
368
369        // Should match only mint paths (4 paths: mint quote and mint for bolt11 and bolt12)
370        assert_eq!(paths.len(), 4);
371        assert!(paths.contains(&RoutePath::MintQuote(
372            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
373        )));
374        assert!(paths.contains(&RoutePath::Mint(
375            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
376        )));
377        assert!(paths.contains(&RoutePath::MintQuote(
378            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
379        )));
380        assert!(paths.contains(&RoutePath::Mint(
381            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
382        )));
383
384        // Should not match other paths
385        assert!(!paths.contains(&RoutePath::MeltQuote(
386            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
387        )));
388        assert!(!paths.contains(&RoutePath::Melt(
389            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
390        )));
391        assert!(!paths.contains(&RoutePath::MeltQuote(
392            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
393        )));
394        assert!(!paths.contains(&RoutePath::Melt(
395            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
396        )));
397        assert!(!paths.contains(&RoutePath::Swap));
398    }
399
400    #[test]
401    fn test_matching_route_paths_quote_only() {
402        // Regex that matches only quote paths
403        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
404
405        // Should match only quote paths (2 paths: mint quote for bolt11 and bolt12)
406        assert_eq!(paths.len(), 2);
407        assert!(paths.contains(&RoutePath::MintQuote(
408            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
409        )));
410        assert!(paths.contains(&RoutePath::MintQuote(
411            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
412        )));
413
414        // Should not match non-quote paths
415        assert!(!paths.contains(&RoutePath::Mint(
416            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
417        )));
418        assert!(!paths.contains(&RoutePath::Melt(
419            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
420        )));
421    }
422
423    #[test]
424    fn test_matching_route_paths_no_match() {
425        // Regex that matches nothing
426        let paths = matching_route_paths("/nonexistent/path").unwrap();
427
428        // Should match nothing
429        assert!(paths.is_empty());
430    }
431
432    #[test]
433    fn test_matching_route_paths_quote_bolt11_only() {
434        // Regex that matches only mint quote bolt11 path
435        let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
436
437        // Should match only this specific path
438        assert_eq!(paths.len(), 1);
439        assert!(paths.contains(&RoutePath::MintQuote(
440            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
441        )));
442    }
443
444    #[test]
445    fn test_matching_route_paths_invalid_regex() {
446        // Invalid regex pattern
447        let result = matching_route_paths("/*unclosed parenthesis");
448
449        // Should return an error for invalid regex
450        assert!(result.is_err());
451        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
452    }
453
454    #[test]
455    fn test_route_path_to_string() {
456        // Test that to_string() returns the correct path strings
457        assert_eq!(
458            RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()).to_string(),
459            "/v1/mint/quote/bolt11"
460        );
461        assert_eq!(
462            RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt11).to_string()).to_string(),
463            "/v1/mint/bolt11"
464        );
465        assert_eq!(
466            RoutePath::MeltQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()).to_string(),
467            "/v1/melt/quote/bolt11"
468        );
469        assert_eq!(
470            RoutePath::Melt(PaymentMethod::Known(KnownMethod::Bolt11).to_string()).to_string(),
471            "/v1/melt/bolt11"
472        );
473        assert_eq!(
474            RoutePath::MintQuote("paypal".to_string()).to_string(),
475            "/v1/mint/quote/paypal"
476        );
477        assert_eq!(RoutePath::Swap.to_string(), "/v1/swap");
478        assert_eq!(RoutePath::Checkstate.to_string(), "/v1/checkstate");
479        assert_eq!(RoutePath::Restore.to_string(), "/v1/restore");
480        assert_eq!(RoutePath::MintBlindAuth.to_string(), "/v1/auth/blind/mint");
481    }
482
483    #[test]
484    fn test_route_path_serialization() {
485        // Test serialization of payment method paths
486        let json = serde_json::to_string(&RoutePath::Mint(
487            PaymentMethod::Known(KnownMethod::Bolt11).to_string(),
488        ))
489        .unwrap();
490        assert_eq!(json, "\"/v1/mint/bolt11\"");
491
492        let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap();
493        assert_eq!(json, "\"/v1/mint/quote/paypal\"");
494
495        // Test deserialization of payment method paths
496        let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap();
497        assert_eq!(
498            path,
499            RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt11).to_string())
500        );
501
502        let path: RoutePath = serde_json::from_str("\"/v1/melt/quote/venmo\"").unwrap();
503        assert_eq!(path, RoutePath::MeltQuote("venmo".to_string()));
504
505        // Test round-trip serialization
506        let original = RoutePath::Melt(PaymentMethod::Known(KnownMethod::Bolt12).to_string());
507        let json = serde_json::to_string(&original).unwrap();
508        let deserialized: RoutePath = serde_json::from_str(&json).unwrap();
509        assert_eq!(original, deserialized);
510    }
511
512    #[test]
513    fn test_settings_deserialize_direct_paths() {
514        let json = r#"{
515            "openid_discovery": "https://example.com/.well-known/openid-configuration",
516            "client_id": "client123",
517            "protected_endpoints": [
518                {
519                    "method": "GET",
520                    "path": "/v1/mint/bolt11"
521                },
522                {
523                    "method": "POST",
524                    "path": "/v1/swap"
525                }
526            ]
527        }"#;
528
529        let settings: Settings = serde_json::from_str(json).unwrap();
530
531        assert_eq!(
532            settings.openid_discovery,
533            "https://example.com/.well-known/openid-configuration"
534        );
535        assert_eq!(settings.client_id, "client123");
536        assert_eq!(settings.protected_endpoints.len(), 2);
537
538        // Check that both paths are included
539        let paths = settings
540            .protected_endpoints
541            .iter()
542            .map(|ep| (ep.method, ep.path.clone()))
543            .collect::<Vec<_>>();
544        assert!(paths.contains(&(
545            Method::Get,
546            RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt11).to_string())
547        )));
548        assert!(paths.contains(&(Method::Post, RoutePath::Swap)));
549    }
550
551    #[test]
552    fn test_settings_deserialize_with_regex() {
553        let json = r#"{
554            "openid_discovery": "https://example.com/.well-known/openid-configuration",
555            "client_id": "client123",
556            "protected_endpoints": [
557                {
558                    "method": "GET",
559                    "path": "/v1/mint/*"
560                },
561                {
562                    "method": "POST",
563                    "path": "/v1/swap"
564                }
565            ]
566        }"#;
567
568        let settings: Settings = serde_json::from_str(json).unwrap();
569
570        assert_eq!(
571            settings.openid_discovery,
572            "https://example.com/.well-known/openid-configuration"
573        );
574        assert_eq!(settings.client_id, "client123");
575        assert_eq!(settings.protected_endpoints.len(), 5); // 4 mint paths (bolt11+bolt12 quote+mint) + 1 swap path
576
577        let expected_protected: HashSet<ProtectedEndpoint> = HashSet::from_iter(vec![
578            ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
579            ProtectedEndpoint::new(
580                Method::Get,
581                RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt11).to_string()),
582            ),
583            ProtectedEndpoint::new(
584                Method::Get,
585                RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()),
586            ),
587            ProtectedEndpoint::new(
588                Method::Get,
589                RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt12).to_string()),
590            ),
591            ProtectedEndpoint::new(
592                Method::Get,
593                RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt12).to_string()),
594            ),
595        ]);
596
597        let deserlized_protected = settings.protected_endpoints.into_iter().collect();
598
599        assert_eq!(expected_protected, deserlized_protected);
600    }
601
602    #[test]
603    fn test_settings_deserialize_invalid_regex() {
604        let json = r#"{
605            "openid_discovery": "https://example.com/.well-known/openid-configuration",
606            "client_id": "client123",
607            "protected_endpoints": [
608                {
609                    "method": "GET",
610                    "path": "/*wildcard_start"
611                }
612            ]
613        }"#;
614
615        let result = serde_json::from_str::<Settings>(json);
616        assert!(result.is_err());
617    }
618
619    #[test]
620    fn test_settings_deserialize_exact_path_match() {
621        let json = r#"{
622            "openid_discovery": "https://example.com/.well-known/openid-configuration",
623            "client_id": "client123",
624            "protected_endpoints": [
625                {
626                    "method": "GET",
627                    "path": "/v1/mint/quote/bolt11"
628                }
629            ]
630        }"#;
631
632        let settings: Settings = serde_json::from_str(json).unwrap();
633        assert_eq!(settings.protected_endpoints.len(), 1);
634        assert_eq!(settings.protected_endpoints[0].method, Method::Get);
635        assert_eq!(
636            settings.protected_endpoints[0].path,
637            RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string())
638        );
639    }
640
641    #[test]
642    fn test_settings_deserialize_all_paths() {
643        let json = r#"{
644            "openid_discovery": "https://example.com/.well-known/openid-configuration",
645            "client_id": "client123",
646            "protected_endpoints": [
647                {
648                    "method": "GET",
649                    "path": "/v1/*"
650                }
651            ]
652        }"#;
653
654        let settings: Settings = serde_json::from_str(json).unwrap();
655        assert_eq!(
656            settings.protected_endpoints.len(),
657            RoutePath::all_known_paths().len()
658        );
659    }
660
661    #[test]
662    fn test_matching_route_paths_empty_pattern() {
663        // Empty pattern should return empty list (nothing matches)
664        let paths = matching_route_paths("").unwrap();
665        assert!(paths.is_empty());
666    }
667
668    #[test]
669    fn test_matching_route_paths_just_slash() {
670        // Pattern "/" should not match any known paths (all start with /v1/)
671        let paths = matching_route_paths("/").unwrap();
672        assert!(paths.is_empty());
673    }
674
675    #[test]
676    fn test_matching_route_paths_trailing_slash() {
677        // Pattern with trailing slash after wildcard: "/v1/mint/*/"
678        // The wildcard "*" is not the last character ("/" comes after it)
679        // This should be an invalid pattern according to the spec
680        let result = matching_route_paths("/v1/mint/*/");
681        assert!(result.is_err());
682        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
683    }
684
685    #[test]
686    fn test_matching_route_paths_consecutive_wildcards() {
687        // Pattern "**" - the first * is the suffix, second * is in prefix
688        // After strip_suffix('*'), we get "*" which contains '*'
689        // This should be an error because wildcard must be at the end only
690        let result = matching_route_paths("**");
691        assert!(result.is_err());
692        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
693    }
694
695    #[test]
696    fn test_matching_route_paths_method_specific() {
697        // Test that GET and POST methods are properly distinguished
698        // The matching function only returns paths, methods are handled by Settings
699        // This test verifies paths are correctly matched regardless of method
700        let paths = matching_route_paths("/v1/swap").unwrap();
701        assert_eq!(paths.len(), 1);
702        assert!(paths.contains(&RoutePath::Swap));
703    }
704
705    #[test]
706    fn test_settings_mixed_methods() {
707        // Test Settings with mixed methods for same path pattern
708        let json = r#"{
709            "openid_discovery": "https://example.com/.well-known/openid-configuration",
710            "client_id": "client123",
711            "protected_endpoints": [
712                {
713                    "method": "GET",
714                    "path": "/v1/swap"
715                },
716                {
717                    "method": "POST",
718                    "path": "/v1/swap"
719                }
720            ]
721        }"#;
722
723        let settings: Settings = serde_json::from_str(json).unwrap();
724        assert_eq!(settings.protected_endpoints.len(), 2);
725
726        // Check both methods are present
727        let methods: Vec<_> = settings
728            .protected_endpoints
729            .iter()
730            .map(|ep| ep.method)
731            .collect();
732        assert!(methods.contains(&Method::Get));
733        assert!(methods.contains(&Method::Post));
734
735        // Both should have the same path
736        for ep in &settings.protected_endpoints {
737            assert_eq!(ep.path, RoutePath::Swap);
738        }
739    }
740
741    #[test]
742    fn test_matching_route_paths_melt_prefix() {
743        // Test prefix matching for melt endpoints: "/v1/melt/*"
744        let paths = matching_route_paths("/v1/melt/*").unwrap();
745
746        // Should match 4 melt paths (bolt11/12 for melt and melt quote)
747        assert_eq!(paths.len(), 4);
748        assert!(paths.contains(&RoutePath::Melt(
749            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
750        )));
751        assert!(paths.contains(&RoutePath::MeltQuote(
752            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
753        )));
754        assert!(paths.contains(&RoutePath::Melt(
755            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
756        )));
757        assert!(paths.contains(&RoutePath::MeltQuote(
758            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
759        )));
760
761        // Should NOT match mint paths
762        assert!(!paths.contains(&RoutePath::Mint(
763            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
764        )));
765    }
766
767    #[test]
768    fn test_matching_route_paths_static_exact() {
769        // Test exact matches for static paths
770        let swap_paths = matching_route_paths("/v1/swap").unwrap();
771        assert_eq!(swap_paths.len(), 1);
772        assert_eq!(swap_paths[0], RoutePath::Swap);
773
774        let checkstate_paths = matching_route_paths("/v1/checkstate").unwrap();
775        assert_eq!(checkstate_paths.len(), 1);
776        assert_eq!(checkstate_paths[0], RoutePath::Checkstate);
777
778        let restore_paths = matching_route_paths("/v1/restore").unwrap();
779        assert_eq!(restore_paths.len(), 1);
780        assert_eq!(restore_paths[0], RoutePath::Restore);
781
782        let ws_paths = matching_route_paths("/v1/ws").unwrap();
783        assert_eq!(ws_paths.len(), 1);
784        assert_eq!(ws_paths[0], RoutePath::Ws);
785    }
786
787    #[test]
788    fn test_matching_route_paths_auth_blind_mint() {
789        // Test exact match for auth blind mint endpoint
790        let paths = matching_route_paths("/v1/auth/blind/mint").unwrap();
791        assert_eq!(paths.len(), 1);
792        assert_eq!(paths[0], RoutePath::MintBlindAuth);
793    }
794
795    #[test]
796    fn test_settings_empty_endpoints() {
797        // Test Settings with empty protected_endpoints array
798        let json = r#"{
799            "openid_discovery": "https://example.com/.well-known/openid-configuration",
800            "client_id": "client123",
801            "protected_endpoints": []
802        }"#;
803
804        let settings: Settings = serde_json::from_str(json).unwrap();
805        assert!(settings.protected_endpoints.is_empty());
806    }
807
808    #[test]
809    fn test_settings_duplicate_paths() {
810        // Test that duplicate paths are deduplicated by HashSet
811        // Using same pattern twice with same method should result in single entry
812        let json = r#"{
813            "openid_discovery": "https://example.com/.well-known/openid-configuration",
814            "client_id": "client123",
815            "protected_endpoints": [
816                {
817                    "method": "POST",
818                    "path": "/v1/swap"
819                },
820                {
821                    "method": "POST",
822                    "path": "/v1/swap"
823                }
824            ]
825        }"#;
826
827        let settings: Settings = serde_json::from_str(json).unwrap();
828        assert_eq!(settings.protected_endpoints.len(), 1);
829        assert_eq!(settings.protected_endpoints[0].method, Method::Post);
830        assert_eq!(settings.protected_endpoints[0].path, RoutePath::Swap);
831    }
832
833    #[test]
834    fn test_matching_route_paths_only_wildcard() {
835        // Pattern with just "*" matches everything
836        let paths = matching_route_paths("*").unwrap();
837        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
838    }
839
840    #[test]
841    fn test_matching_route_paths_wildcard_in_middle() {
842        // Pattern "/v1/*/bolt11" - wildcard in the middle
843        // After strip_suffix('*'), we get "/v1/*/bolt11" which contains '*'
844        // This should be an error
845        let result = matching_route_paths("/v1/*/bolt11");
846        assert!(result.is_err());
847        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
848    }
849
850    #[test]
851    fn test_exact_match_no_child_paths() {
852        // Exact match "/v1/mint" should NOT match child paths like "/v1/mint/bolt11"
853        let paths = matching_route_paths("/v1/mint").unwrap();
854
855        // "/v1/mint" is not a valid RoutePath by itself (needs payment method)
856        // So it should return empty
857        assert!(paths.is_empty());
858
859        // Also verify it doesn't match any mint paths with payment methods
860        assert!(!paths.contains(&RoutePath::Mint(
861            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
862        )));
863        assert!(!paths.contains(&RoutePath::MintQuote(
864            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
865        )));
866    }
867
868    #[test]
869    fn test_exact_match_no_extra_path() {
870        // Exact match "/v1/swap" should NOT match "/v1/swap/extra"
871        // Since "/v1/swap/extra" is not a known path, it won't be in all_known_paths
872        // But let's verify "/v1/swap" only matches the exact Swap path
873        let paths = matching_route_paths("/v1/swap").unwrap();
874        assert_eq!(paths.len(), 1);
875        assert_eq!(paths[0], RoutePath::Swap);
876
877        // Verify it doesn't match any other paths
878        assert!(!paths.contains(&RoutePath::Checkstate));
879        assert!(!paths.contains(&RoutePath::Restore));
880    }
881
882    #[test]
883    fn test_partial_prefix_matching() {
884        // Pattern "/v1/mi*" - partial prefix that matches "/v1/mint/..." but not "/v1/melt/..."
885        let paths = matching_route_paths("/v1/mi*").unwrap();
886
887        // This DOES match "/v1/mint/bolt11" because "/v1/mint/bolt11" starts with "/v1/mi"
888        assert!(paths.contains(&RoutePath::Mint(
889            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
890        )));
891        assert!(paths.contains(&RoutePath::MintQuote(
892            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
893        )));
894
895        // But it does NOT match melt paths because "/v1/melt" doesn't start with "/v1/mi"
896        assert!(!paths.contains(&RoutePath::Melt(
897            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
898        )));
899        assert!(!paths.contains(&RoutePath::MeltQuote(
900            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
901        )));
902    }
903
904    #[test]
905    fn test_exact_match_wrong_payment_method() {
906        // Pattern "/v1/mint/quote/bolt11" should NOT match "/v1/mint/quote/bolt12"
907        let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
908
909        assert_eq!(paths.len(), 1);
910        assert!(paths.contains(&RoutePath::MintQuote(
911            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
912        )));
913
914        // Should NOT contain bolt12
915        assert!(!paths.contains(&RoutePath::MintQuote(
916            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
917        )));
918
919        // Should NOT contain regular mint (non-quote)
920        assert!(!paths.contains(&RoutePath::Mint(
921            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
922        )));
923    }
924
925    #[test]
926    fn test_prefix_match_wrong_category() {
927        // Pattern "/v1/mint/*" should NOT match melt paths "/v1/melt/*"
928        let paths = matching_route_paths("/v1/mint/*").unwrap();
929
930        // Should contain mint paths
931        assert!(paths.contains(&RoutePath::Mint(
932            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
933        )));
934        assert!(paths.contains(&RoutePath::MintQuote(
935            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
936        )));
937
938        // Should NOT contain melt paths (different category)
939        assert!(!paths.contains(&RoutePath::Melt(
940            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
941        )));
942        assert!(!paths.contains(&RoutePath::MeltQuote(
943            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
944        )));
945
946        // Should NOT contain static paths
947        assert!(!paths.contains(&RoutePath::Swap));
948        assert!(!paths.contains(&RoutePath::Checkstate));
949    }
950
951    #[test]
952    fn test_case_sensitivity() {
953        // Pattern "/v1/MINT/*" should NOT match "/v1/mint/bolt11" (case sensitive)
954        let paths_upper = matching_route_paths("/v1/MINT/*").unwrap();
955        let paths_lower = matching_route_paths("/v1/mint/*").unwrap();
956
957        // Uppercase should NOT match any known paths
958        assert!(paths_upper.is_empty());
959
960        // Lowercase should match 4 mint paths
961        assert_eq!(paths_lower.len(), 4);
962    }
963
964    #[test]
965    fn test_negative_assertions_comprehensive() {
966        // Comprehensive test that verifies multiple negative cases in one place
967
968        // 1. Exact match for wrong payment method
969        let bolt11_paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
970        assert!(!bolt11_paths.contains(&RoutePath::MintQuote(
971            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
972        )));
973
974        // 2. Prefix for one category doesn't match another
975        let mint_paths = matching_route_paths("/v1/mint/*").unwrap();
976        assert!(!mint_paths.contains(&RoutePath::Melt(
977            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
978        )));
979        assert!(!mint_paths.contains(&RoutePath::MeltQuote(
980            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
981        )));
982
983        // 3. Exact match for static path doesn't match others
984        let swap_paths = matching_route_paths("/v1/swap").unwrap();
985        assert!(!swap_paths.contains(&RoutePath::Checkstate));
986        assert!(!swap_paths.contains(&RoutePath::Restore));
987        assert!(!swap_paths.contains(&RoutePath::MintBlindAuth));
988
989        // 4. Case sensitivity - wrong case matches nothing
990        assert!(matching_route_paths("/V1/SWAP").unwrap().is_empty());
991        assert!(matching_route_paths("/V1/MINT/*").unwrap().is_empty());
992
993        // 5. Invalid/unknown paths match nothing
994        assert!(matching_route_paths("/unknown/path").unwrap().is_empty());
995        assert!(matching_route_paths("/invalid").unwrap().is_empty());
996    }
997
998    #[test]
999    fn test_prefix_vs_exact_boundary() {
1000        // Pattern "/v1/mint/quote/*" should NOT match "/v1/mint/quote" itself
1001        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
1002
1003        // The pattern requires something after "/v1/mint/quote/"
1004        // So "/v1/mint/quote" (without payment method) is NOT a valid RoutePath
1005        // and won't be in the results
1006        assert!(!paths.is_empty()); // Should have bolt11 and bolt12
1007
1008        // Verify we have the quote paths with payment methods
1009        assert!(paths.contains(&RoutePath::MintQuote(
1010            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
1011        )));
1012        assert!(paths.contains(&RoutePath::MintQuote(
1013            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
1014        )));
1015
1016        // But there is no RoutePath::MintQuote without a payment method
1017        // So the list should only contain 2 items (bolt11 and bolt12), not a bare "/v1/mint/quote"
1018        assert_eq!(paths.len(), 2);
1019    }
1020}