Skip to main content

fraiseql_core/validation/
id_policy.rs

1//! ID Policy validation for GraphQL ID scalar type
2//!
3//! This module provides validation for ID fields based on the configured ID policy.
4//!
5//! **Design Pattern**: `FraiseQL` supports two ID policies:
6//! 1. **UUID**: IDs must be valid UUIDs (`FraiseQL`'s opinionated default)
7//! 2. **OPAQUE**: IDs accept any string (GraphQL spec-compliant)
8//!
9//! This module enforces UUID format validation when `IDPolicy::UUID` is configured.
10//!
11//! # Example
12//!
13//! ```
14//! use fraiseql_core::validation::{IDPolicy, validate_id};
15//!
16//! // UUID policy: strict UUID validation
17//! let policy = IDPolicy::UUID;
18//! assert!(
19//!     validate_id("550e8400-e29b-41d4-a716-446655440000", policy).is_ok(),
20//!     "valid UUID should pass UUID policy"
21//! );
22//! assert!(
23//!     validate_id("not-a-uuid", policy).is_err(),
24//!     "non-UUID string should fail UUID policy"
25//! );
26//!
27//! // OPAQUE policy: any string accepted
28//! let policy = IDPolicy::OPAQUE;
29//! assert!(
30//!     validate_id("not-a-uuid", policy).is_ok(),
31//!     "OPAQUE policy should accept any string"
32//! );
33//! assert!(
34//!     validate_id("any-arbitrary-string", policy).is_ok(),
35//!     "OPAQUE policy should accept arbitrary strings"
36//! );
37//! ```
38
39use serde::{Deserialize, Serialize};
40
41/// ID Policy determines how GraphQL ID scalar type behaves
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[non_exhaustive]
44pub enum IDPolicy {
45    /// IDs must be valid UUIDs (`FraiseQL`'s opinionated default)
46    #[serde(rename = "uuid")]
47    #[default]
48    UUID,
49
50    /// IDs accept any string (GraphQL specification compliant)
51    #[serde(rename = "opaque")]
52    OPAQUE,
53}
54
55impl IDPolicy {
56    /// Check if this policy enforces UUID format for IDs
57    #[must_use]
58    pub fn enforces_uuid(self) -> bool {
59        self == Self::UUID
60    }
61
62    /// Get the policy name as a string
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::UUID => "uuid",
67            Self::OPAQUE => "opaque",
68        }
69    }
70}
71
72impl std::fmt::Display for IDPolicy {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.as_str())
75    }
76}
77
78/// Error type for ID validation failures
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct IDValidationError {
81    /// The invalid ID value
82    pub value:   String,
83    /// The policy that was violated
84    pub policy:  IDPolicy,
85    /// Error message
86    pub message: String,
87}
88
89impl std::fmt::Display for IDValidationError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}", self.message)
92    }
93}
94
95impl std::error::Error for IDValidationError {}
96
97/// Validate an ID string against the configured ID policy
98///
99/// # Arguments
100///
101/// * `id` - The ID value to validate
102/// * `policy` - The ID policy to enforce
103///
104/// # Returns
105///
106/// `Ok(())` if the ID is valid for the policy, `Err(IDValidationError)` otherwise
107///
108/// # Errors
109///
110/// Returns `IDValidationError` if the ID does not conform to the specified policy.
111/// For `IDPolicy::UUID`, the ID must be a valid UUID. For `IDPolicy::OPAQUE`, any string is valid.
112///
113/// # Examples
114///
115/// ```
116/// use fraiseql_core::validation::{IDPolicy, validate_id};
117///
118/// // UUID policy enforces UUID format
119/// assert!(
120///     validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID).is_ok(),
121///     "valid UUID should pass UUID policy"
122/// );
123/// assert!(
124///     validate_id("not-uuid", IDPolicy::UUID).is_err(),
125///     "non-UUID string should fail UUID policy"
126/// );
127///
128/// // OPAQUE policy accepts any string
129/// assert!(
130///     validate_id("anything", IDPolicy::OPAQUE).is_ok(),
131///     "OPAQUE policy should accept any string"
132/// );
133/// assert!(
134///     validate_id("", IDPolicy::OPAQUE).is_ok(),
135///     "OPAQUE policy should accept empty string"
136/// );
137/// ```
138///
139/// # Errors
140///
141/// Returns [`IDValidationError`] if `id` does not conform to `policy`
142/// (e.g., not a valid UUID when `IDPolicy::UUID` is used).
143pub fn validate_id(id: &str, policy: IDPolicy) -> Result<(), IDValidationError> {
144    match policy {
145        IDPolicy::UUID => validate_uuid_format(id),
146        IDPolicy::OPAQUE => Ok(()), // Opaque IDs accept any string
147    }
148}
149
150/// Validate that an ID is a valid UUID string
151///
152/// **Security Note**: This is a defense-in-depth check at the Rust runtime layer.
153/// The primary enforcement point is the CLI compiler (`fraiseql-cli compile`), which
154/// validates ID policy rules when producing `schema.compiled.json`.
155///
156/// UUID format validation requires:
157/// - 36 characters total
158/// - 8-4-4-4-12 hexadecimal digits separated by hyphens
159/// - Case-insensitive
160///
161/// # Arguments
162///
163/// * `id` - The ID string to validate
164///
165/// # Returns
166///
167/// `Ok(())` if valid UUID format, `Err(IDValidationError)` otherwise
168fn validate_uuid_format(id: &str) -> Result<(), IDValidationError> {
169    // UUID must be 36 characters: 8-4-4-4-12
170    if id.len() != 36 {
171        return Err(IDValidationError {
172            value:   id.to_string(),
173            policy:  IDPolicy::UUID,
174            message: format!(
175                "ID must be a valid UUID (36 characters), got {} characters",
176                id.len()
177            ),
178        });
179    }
180
181    // Check overall structure: 8-4-4-4-12
182    let parts: Vec<&str> = id.split('-').collect();
183    if parts.len() != 5 {
184        return Err(IDValidationError {
185            value:   id.to_string(),
186            policy:  IDPolicy::UUID,
187            message: "ID must be a valid UUID with format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
188                .to_string(),
189        });
190    }
191
192    // Validate segment lengths
193    let expected_lengths = [8, 4, 4, 4, 12];
194    for (i, (part, &expected_len)) in parts.iter().zip(&expected_lengths).enumerate() {
195        if part.len() != expected_len {
196            return Err(IDValidationError {
197                value:   id.to_string(),
198                policy:  IDPolicy::UUID,
199                message: format!(
200                    "UUID segment {} has invalid length: expected {}, got {}",
201                    i,
202                    expected_len,
203                    part.len()
204                ),
205            });
206        }
207    }
208
209    // Validate all characters are hexadecimal
210    for (i, part) in parts.iter().enumerate() {
211        if !part.chars().all(|c| c.is_ascii_hexdigit()) {
212            return Err(IDValidationError {
213                value:   id.to_string(),
214                policy:  IDPolicy::UUID,
215                message: format!("UUID segment {i} contains non-hexadecimal characters: '{part}'"),
216            });
217        }
218    }
219
220    Ok(())
221}
222
223/// Validate multiple IDs against a policy
224///
225/// # Arguments
226///
227/// * `ids` - Slice of ID strings to validate
228/// * `policy` - The ID policy to enforce
229///
230/// # Returns
231///
232/// `Ok(())` if all IDs are valid, `Err(IDValidationError)` for the first invalid ID
233///
234/// # Examples
235///
236/// ```
237/// use fraiseql_core::validation::{validate_id, IDPolicy};
238///
239/// // Validate each ID individually
240/// assert!(
241///     validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID).is_ok(),
242///     "first UUID should pass validation"
243/// );
244/// assert!(
245///     validate_id("6ba7b810-9dad-11d1-80b4-00c04fd430c8", IDPolicy::UUID).is_ok(),
246///     "second UUID should pass validation"
247/// );
248/// ```
249///
250/// # Errors
251///
252/// Returns `IDValidationError` if any ID fails validation.
253#[allow(dead_code)] // Reason: public API intended for external consumers; no in-crate callers yet
254pub fn validate_ids(ids: &[&str], policy: IDPolicy) -> Result<(), IDValidationError> {
255    for id in ids {
256        validate_id(id, policy)?;
257    }
258    Ok(())
259}
260
261// =============================================================================
262// Pluggable ID Validator Trait System
263// =============================================================================
264
265/// Trait for pluggable ID validation strategies
266///
267/// This trait enables users to implement custom ID validation logic
268/// beyond the built-in UUID and OPAQUE policies.
269///
270/// # Examples
271///
272/// ```no_run
273/// # // Requires: IdValidator and IDValidationError are not re-exported from the public API
274/// use fraiseql_core::validation::{IDPolicy, IDValidationError};
275///
276/// struct CustomIdValidator;
277///
278/// // impl IdValidator for CustomIdValidator { ... }
279/// ```
280pub trait IdValidator: Send + Sync {
281    /// Validate an ID value
282    fn validate(&self, value: &str) -> Result<(), IDValidationError>;
283
284    /// Human-readable name of the format (for error messages)
285    fn format_name(&self) -> &'static str;
286}
287
288/// UUID format validator
289#[derive(Debug, Clone, Copy)]
290pub struct UuidIdValidator;
291
292impl IdValidator for UuidIdValidator {
293    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
294        validate_uuid_format(value)
295    }
296
297    fn format_name(&self) -> &'static str {
298        "UUID"
299    }
300}
301
302/// Numeric ID validator (integers)
303#[derive(Debug, Clone, Copy)]
304pub struct NumericIdValidator;
305
306impl IdValidator for NumericIdValidator {
307    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
308        value.parse::<i64>().map_err(|_| IDValidationError {
309            value:   value.to_string(),
310            policy:  IDPolicy::OPAQUE,
311            message: format!(
312                "ID must be a valid {} (parseable as 64-bit integer)",
313                self.format_name()
314            ),
315        })?;
316        Ok(())
317    }
318
319    fn format_name(&self) -> &'static str {
320        "integer"
321    }
322}
323
324/// ULID format validator (Universally Unique Lexicographically Sortable Identifier)
325///
326/// ULIDs are 26 uppercase alphanumeric characters, providing sortable unique IDs.
327/// Example: `01ARZ3NDEKTSV4RRFFQ69G5FAV`
328#[derive(Debug, Clone, Copy)]
329pub struct UlidIdValidator;
330
331impl IdValidator for UlidIdValidator {
332    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
333        if value.len() != 26 {
334            return Err(IDValidationError {
335                value:   value.to_string(),
336                policy:  IDPolicy::OPAQUE,
337                message: format!(
338                    "ID must be a valid {} ({} characters), got {}",
339                    self.format_name(),
340                    26,
341                    value.len()
342                ),
343            });
344        }
345
346        // ULIDs use Crockford base32 encoding (0-9, A-Z except I, L, O, U)
347        if !value.chars().all(|c| {
348            c.is_ascii_digit()
349                || (c.is_ascii_uppercase() && c != 'I' && c != 'L' && c != 'O' && c != 'U')
350        }) {
351            return Err(IDValidationError {
352                value:   value.to_string(),
353                policy:  IDPolicy::OPAQUE,
354                message: format!(
355                    "ID must be a valid {} (Crockford base32: 0-9, A-Z except I, L, O, U)",
356                    self.format_name()
357                ),
358            });
359        }
360
361        Ok(())
362    }
363
364    fn format_name(&self) -> &'static str {
365        "ULID"
366    }
367}
368
369/// Opaque ID validator (accepts any string)
370#[derive(Debug, Clone, Copy)]
371pub struct OpaqueIdValidator;
372
373impl IdValidator for OpaqueIdValidator {
374    fn validate(&self, _value: &str) -> Result<(), IDValidationError> {
375        Ok(()) // Accept any string
376    }
377
378    fn format_name(&self) -> &'static str {
379        "opaque"
380    }
381}
382
383/// ID validation profile for different use cases
384///
385/// Profiles provide preset ID validation configurations for common scenarios.
386/// Each profile includes a name and a validator instance.
387///
388/// # Built-in Profiles
389///
390/// - **UUID**: Strict UUID format validation (FraiseQL default)
391/// - **Numeric**: Integer-based IDs (suitable for sequential IDs)
392/// - **ULID**: Sortable unique identifiers (recommended for distributed systems)
393/// - **Opaque**: Any string accepted (GraphQL spec compliant)
394#[derive(Debug, Clone)]
395pub struct IDValidationProfile {
396    /// Profile name (e.g., "uuid", "ulid", "numeric")
397    pub name: String,
398
399    /// Validator instance for this profile
400    pub validator: ValidationProfileType,
401}
402
403/// Type of validation profile
404#[derive(Debug, Clone)]
405#[non_exhaustive]
406pub enum ValidationProfileType {
407    /// UUID format validation
408    Uuid(UuidIdValidator),
409
410    /// Numeric (integer) validation
411    Numeric(NumericIdValidator),
412
413    /// ULID format validation
414    Ulid(UlidIdValidator),
415
416    /// Opaque (any string) validation
417    Opaque(OpaqueIdValidator),
418}
419
420impl ValidationProfileType {
421    /// Get the validator as a trait object
422    pub fn as_validator(&self) -> &dyn IdValidator {
423        match self {
424            Self::Uuid(v) => v,
425            Self::Numeric(v) => v,
426            Self::Ulid(v) => v,
427            Self::Opaque(v) => v,
428        }
429    }
430}
431
432impl IDValidationProfile {
433    /// Create a UUID validation profile (FraiseQL default)
434    #[must_use]
435    pub fn uuid() -> Self {
436        Self {
437            name:      "uuid".to_string(),
438            validator: ValidationProfileType::Uuid(UuidIdValidator),
439        }
440    }
441
442    /// Create a numeric (integer) validation profile
443    #[must_use]
444    pub fn numeric() -> Self {
445        Self {
446            name:      "numeric".to_string(),
447            validator: ValidationProfileType::Numeric(NumericIdValidator),
448        }
449    }
450
451    /// Create a ULID validation profile
452    #[must_use]
453    pub fn ulid() -> Self {
454        Self {
455            name:      "ulid".to_string(),
456            validator: ValidationProfileType::Ulid(UlidIdValidator),
457        }
458    }
459
460    /// Create an opaque (any string) validation profile
461    #[must_use]
462    pub fn opaque() -> Self {
463        Self {
464            name:      "opaque".to_string(),
465            validator: ValidationProfileType::Opaque(OpaqueIdValidator),
466        }
467    }
468
469    /// Get profile by name
470    ///
471    /// Returns a profile matching the given name, or None if not found.
472    ///
473    /// # Built-in Profile Names
474    ///
475    /// - "uuid" - UUID validation
476    /// - "numeric" - Integer validation
477    /// - "ulid" - ULID validation
478    /// - "opaque" - Any string validation
479    #[must_use]
480    pub fn by_name(name: &str) -> Option<Self> {
481        match name.to_lowercase().as_str() {
482            "uuid" => Some(Self::uuid()),
483            "numeric" | "integer" => Some(Self::numeric()),
484            "ulid" => Some(Self::ulid()),
485            "opaque" | "string" => Some(Self::opaque()),
486            _ => None,
487        }
488    }
489
490    /// Validate an ID using this profile.
491    ///
492    /// # Errors
493    ///
494    /// Returns [`IDValidationError`] if the value does not conform to this
495    /// profile's validator (e.g., not a valid UUID, ULID, or integer).
496    pub fn validate(&self, value: &str) -> Result<(), IDValidationError> {
497        self.validator.as_validator().validate(value)
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
504
505    use super::*;
506
507    // ==================== UUID Format Tests ====================
508
509    #[test]
510    fn test_validate_valid_uuid() {
511        // Standard UUID format
512        let result = validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID);
513        result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
514    }
515
516    #[test]
517    fn test_validate_valid_uuid_uppercase() {
518        // UUIDs are case-insensitive
519        let result = validate_id("550E8400-E29B-41D4-A716-446655440000", IDPolicy::UUID);
520        result.unwrap_or_else(|e| panic!("uppercase UUID should pass: {e}"));
521    }
522
523    #[test]
524    fn test_validate_valid_uuid_mixed_case() {
525        let result = validate_id("550e8400-E29b-41d4-A716-446655440000", IDPolicy::UUID);
526        result.unwrap_or_else(|e| panic!("mixed-case UUID should pass: {e}"));
527    }
528
529    #[test]
530    fn test_validate_nil_uuid() {
531        // Nil UUID (all zeros) is valid
532        let result = validate_id("00000000-0000-0000-0000-000000000000", IDPolicy::UUID);
533        result.unwrap_or_else(|e| panic!("nil UUID should pass: {e}"));
534    }
535
536    #[test]
537    fn test_validate_max_uuid() {
538        // Max UUID (all Fs) is valid
539        let result = validate_id("ffffffff-ffff-ffff-ffff-ffffffffffff", IDPolicy::UUID);
540        result.unwrap_or_else(|e| panic!("max UUID should pass: {e}"));
541    }
542
543    #[test]
544    fn test_validate_uuid_wrong_length() {
545        let result = validate_id("550e8400-e29b-41d4-a716", IDPolicy::UUID);
546        assert!(
547            matches!(
548                result,
549                Err(IDValidationError {
550                    policy: IDPolicy::UUID,
551                    ..
552                })
553            ),
554            "short UUID string should fail with Validation error, got: {result:?}"
555        );
556        let err = result.unwrap_err();
557        assert_eq!(err.policy, IDPolicy::UUID);
558        assert!(err.message.contains("36 characters"));
559    }
560
561    #[test]
562    fn test_validate_uuid_extra_chars() {
563        let result = validate_id("550e8400-e29b-41d4-a716-446655440000x", IDPolicy::UUID);
564        assert!(
565            matches!(
566                result,
567                Err(IDValidationError {
568                    policy: IDPolicy::UUID,
569                    ..
570                })
571            ),
572            "extra chars should fail UUID validation, got: {result:?}"
573        );
574    }
575
576    #[test]
577    fn test_validate_uuid_missing_hyphens() {
578        // 36 chars without hyphens - all hex digits, same length as UUID but no separators
579        let result = validate_id("550e8400e29b41d4a716446655440000", IDPolicy::UUID);
580        assert!(
581            matches!(
582                result,
583                Err(IDValidationError {
584                    policy: IDPolicy::UUID,
585                    ..
586                })
587            ),
588            "UUID without hyphens should fail, got: {result:?}"
589        );
590        let err = result.unwrap_err();
591        // Fails length check since 32 chars != 36
592        assert!(err.message.contains("36 characters"));
593    }
594
595    #[test]
596    fn test_validate_uuid_wrong_segment_lengths() {
597        // First segment too short (7 chars instead of 8)
598        // Need 36 chars total, so pad the last segment: 550e840-e29b-41d4-a716-4466554400001
599        let result = validate_id("550e840-e29b-41d4-a716-4466554400001", IDPolicy::UUID);
600        assert!(
601            matches!(
602                result,
603                Err(IDValidationError {
604                    policy: IDPolicy::UUID,
605                    ..
606                })
607            ),
608            "UUID with wrong segment lengths should fail, got: {result:?}"
609        );
610        let err = result.unwrap_err();
611        assert!(err.message.contains("segment"));
612    }
613
614    #[test]
615    fn test_validate_uuid_non_hex_chars() {
616        let result = validate_id("550e8400-e29b-41d4-a716-44665544000g", IDPolicy::UUID);
617        assert!(
618            matches!(
619                result,
620                Err(IDValidationError {
621                    policy: IDPolicy::UUID,
622                    ..
623                })
624            ),
625            "UUID with non-hex chars should fail, got: {result:?}"
626        );
627        let err = result.unwrap_err();
628        assert!(err.message.contains("non-hexadecimal"));
629    }
630
631    #[test]
632    fn test_validate_uuid_special_chars() {
633        let result = validate_id("550e8400-e29b-41d4-a716-4466554400@0", IDPolicy::UUID);
634        assert!(
635            matches!(
636                result,
637                Err(IDValidationError {
638                    policy: IDPolicy::UUID,
639                    ..
640                })
641            ),
642            "special chars should fail UUID validation, got: {result:?}"
643        );
644    }
645
646    #[test]
647    fn test_validate_uuid_empty_string() {
648        let result = validate_id("", IDPolicy::UUID);
649        assert!(
650            matches!(
651                result,
652                Err(IDValidationError {
653                    policy: IDPolicy::UUID,
654                    ..
655                })
656            ),
657            "empty string should fail UUID validation, got: {result:?}"
658        );
659    }
660
661    // ==================== OPAQUE Policy Tests ====================
662
663    #[test]
664    fn test_opaque_accepts_any_string() {
665        validate_id("not-a-uuid", IDPolicy::OPAQUE)
666            .unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
667        validate_id("anything", IDPolicy::OPAQUE)
668            .unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
669        validate_id("12345", IDPolicy::OPAQUE)
670            .unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
671        validate_id("special@chars!#$%", IDPolicy::OPAQUE)
672            .unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
673    }
674
675    #[test]
676    fn test_opaque_accepts_empty_string() {
677        validate_id("", IDPolicy::OPAQUE)
678            .unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
679    }
680
681    #[test]
682    fn test_opaque_accepts_uuid() {
683        validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::OPAQUE)
684            .unwrap_or_else(|e| panic!("opaque should accept UUID string: {e}"));
685    }
686
687    // ==================== Multiple IDs Tests ====================
688
689    #[test]
690    fn test_validate_multiple_valid_uuids() {
691        let ids = vec![
692            "550e8400-e29b-41d4-a716-446655440000",
693            "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
694            "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
695        ];
696        validate_ids(&ids, IDPolicy::UUID)
697            .unwrap_or_else(|e| panic!("all valid UUIDs should pass: {e}"));
698    }
699
700    #[test]
701    fn test_validate_multiple_fails_on_first_invalid() {
702        let ids = vec![
703            "550e8400-e29b-41d4-a716-446655440000",
704            "invalid-id",
705            "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
706        ];
707        let result = validate_ids(&ids, IDPolicy::UUID);
708        assert!(
709            matches!(
710                result,
711                Err(IDValidationError {
712                    policy: IDPolicy::UUID,
713                    ..
714                })
715            ),
716            "batch with invalid ID should fail, got: {result:?}"
717        );
718        assert_eq!(result.unwrap_err().value, "invalid-id");
719    }
720
721    #[test]
722    fn test_validate_multiple_opaque_all_pass() {
723        let ids = vec!["anything", "goes", "here", "12345"];
724        validate_ids(&ids, IDPolicy::OPAQUE)
725            .unwrap_or_else(|e| panic!("opaque should accept all strings: {e}"));
726    }
727
728    // ==================== Policy Behavior Tests ====================
729
730    #[test]
731    fn test_policy_enforces_uuid() {
732        assert!(IDPolicy::UUID.enforces_uuid());
733        assert!(!IDPolicy::OPAQUE.enforces_uuid());
734    }
735
736    #[test]
737    fn test_policy_as_str() {
738        assert_eq!(IDPolicy::UUID.as_str(), "uuid");
739        assert_eq!(IDPolicy::OPAQUE.as_str(), "opaque");
740    }
741
742    #[test]
743    fn test_policy_default() {
744        assert_eq!(IDPolicy::default(), IDPolicy::UUID);
745    }
746
747    #[test]
748    fn test_policy_display() {
749        assert_eq!(format!("{}", IDPolicy::UUID), "uuid");
750        assert_eq!(format!("{}", IDPolicy::OPAQUE), "opaque");
751    }
752
753    // ==================== Security Scenarios ====================
754
755    #[test]
756    fn test_security_prevent_sql_injection_via_uuid() {
757        // UUID validation prevents malicious IDs with SQL injection
758        let result = validate_id("'; DROP TABLE users; --", IDPolicy::UUID);
759        assert!(
760            matches!(
761                result,
762                Err(IDValidationError {
763                    policy: IDPolicy::UUID,
764                    ..
765                })
766            ),
767            "SQL injection string should fail UUID validation, got: {result:?}"
768        );
769    }
770
771    #[test]
772    fn test_security_prevent_path_traversal_via_uuid() {
773        let result = validate_id("../../etc/passwd", IDPolicy::UUID);
774        assert!(
775            matches!(
776                result,
777                Err(IDValidationError {
778                    policy: IDPolicy::UUID,
779                    ..
780                })
781            ),
782            "path traversal string should fail UUID validation, got: {result:?}"
783        );
784    }
785
786    #[test]
787    fn test_security_opaque_policy_accepts_any_format() {
788        // OPAQUE policy explicitly accepts any string
789        // Input validation and authorization must be done elsewhere
790        validate_id("'; DROP TABLE users; --", IDPolicy::OPAQUE)
791            .unwrap_or_else(|e| panic!("opaque should accept SQL injection string: {e}"));
792        validate_id("../../etc/passwd", IDPolicy::OPAQUE)
793            .unwrap_or_else(|e| panic!("opaque should accept path traversal string: {e}"));
794    }
795
796    #[test]
797    fn test_validation_error_contains_policy_info() {
798        let err = validate_id("invalid", IDPolicy::UUID).unwrap_err();
799        assert_eq!(err.policy, IDPolicy::UUID);
800        assert_eq!(err.value, "invalid");
801        assert!(!err.message.is_empty());
802    }
803
804    // ==================== UUID Validator Tests ====================
805
806    #[test]
807    fn test_uuid_validator_valid() {
808        let validator = UuidIdValidator;
809        let result = validator.validate("550e8400-e29b-41d4-a716-446655440000");
810        result.unwrap_or_else(|e| panic!("valid UUID should pass UuidIdValidator: {e}"));
811    }
812
813    #[test]
814    fn test_uuid_validator_invalid() {
815        let validator = UuidIdValidator;
816        let result = validator.validate("not-a-uuid");
817        assert!(
818            matches!(
819                result,
820                Err(IDValidationError {
821                    policy: IDPolicy::UUID,
822                    ..
823                })
824            ),
825            "invalid string should fail UuidIdValidator, got: {result:?}"
826        );
827        assert_eq!(result.unwrap_err().value, "not-a-uuid");
828    }
829
830    #[test]
831    fn test_uuid_validator_format_name() {
832        let validator = UuidIdValidator;
833        assert_eq!(validator.format_name(), "UUID");
834    }
835
836    #[test]
837    fn test_uuid_validator_nil_uuid() {
838        let validator = UuidIdValidator;
839        validator
840            .validate("00000000-0000-0000-0000-000000000000")
841            .unwrap_or_else(|e| panic!("nil UUID should pass UuidIdValidator: {e}"));
842    }
843
844    #[test]
845    fn test_uuid_validator_uppercase() {
846        let validator = UuidIdValidator;
847        validator
848            .validate("550E8400-E29B-41D4-A716-446655440000")
849            .unwrap_or_else(|e| panic!("uppercase UUID should pass UuidIdValidator: {e}"));
850    }
851
852    // ==================== Numeric Validator Tests ====================
853
854    #[test]
855    fn test_numeric_validator_valid_positive() {
856        let validator = NumericIdValidator;
857        validator
858            .validate("12345")
859            .unwrap_or_else(|e| panic!("positive int should pass: {e}"));
860        validator.validate("0").unwrap_or_else(|e| panic!("zero should pass: {e}"));
861        validator
862            .validate("9223372036854775807")
863            .unwrap_or_else(|e| panic!("i64::MAX should pass: {e}"));
864    }
865
866    #[test]
867    fn test_numeric_validator_valid_negative() {
868        let validator = NumericIdValidator;
869        validator
870            .validate("-1")
871            .unwrap_or_else(|e| panic!("negative int should pass: {e}"));
872        validator
873            .validate("-12345")
874            .unwrap_or_else(|e| panic!("negative int should pass: {e}"));
875        validator
876            .validate("-9223372036854775808")
877            .unwrap_or_else(|e| panic!("i64::MIN should pass: {e}"));
878    }
879
880    #[test]
881    fn test_numeric_validator_invalid_float() {
882        let validator = NumericIdValidator;
883        let result = validator.validate("123.45");
884        assert!(
885            matches!(result, Err(IDValidationError { .. })),
886            "float string should fail NumericIdValidator, got: {result:?}"
887        );
888        let err = result.unwrap_err();
889        assert_eq!(err.value, "123.45");
890    }
891
892    #[test]
893    fn test_numeric_validator_invalid_non_numeric() {
894        let validator = NumericIdValidator;
895        let result = validator.validate("abc123");
896        assert!(
897            matches!(result, Err(IDValidationError { .. })),
898            "non-numeric string should fail NumericIdValidator, got: {result:?}"
899        );
900    }
901
902    #[test]
903    fn test_numeric_validator_overflow() {
904        let validator = NumericIdValidator;
905        // Too large for i64
906        let result = validator.validate("9223372036854775808");
907        assert!(
908            matches!(result, Err(IDValidationError { .. })),
909            "i64 overflow should fail NumericIdValidator, got: {result:?}"
910        );
911    }
912
913    #[test]
914    fn test_numeric_validator_empty_string() {
915        let validator = NumericIdValidator;
916        let result = validator.validate("");
917        assert!(
918            matches!(result, Err(IDValidationError { .. })),
919            "empty string should fail NumericIdValidator, got: {result:?}"
920        );
921    }
922
923    #[test]
924    fn test_numeric_validator_format_name() {
925        let validator = NumericIdValidator;
926        assert_eq!(validator.format_name(), "integer");
927    }
928
929    // ==================== ULID Validator Tests ====================
930
931    #[test]
932    fn test_ulid_validator_valid() {
933        let validator = UlidIdValidator;
934        // Valid ULID: 01ARZ3NDEKTSV4RRFFQ69G5FAV
935        validator
936            .validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
937            .unwrap_or_else(|e| panic!("valid ULID should pass: {e}"));
938    }
939
940    #[test]
941    fn test_ulid_validator_valid_all_digits() {
942        let validator = UlidIdValidator;
943        // Valid ULID with all digits: 01234567890123456789012345
944        validator
945            .validate("01234567890123456789012345")
946            .unwrap_or_else(|e| panic!("all-digit ULID should pass: {e}"));
947    }
948
949    #[test]
950    fn test_ulid_validator_valid_all_uppercase() {
951        let validator = UlidIdValidator;
952        // Valid ULID with all uppercase (no I, L, O, U)
953        validator
954            .validate("ABCDEFGHJKMNPQRSTVWXYZ0123")
955            .unwrap_or_else(|e| panic!("all-uppercase ULID should pass: {e}"));
956    }
957
958    #[test]
959    fn test_ulid_validator_invalid_length_short() {
960        let validator = UlidIdValidator;
961        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5F");
962        assert!(
963            matches!(result, Err(IDValidationError { .. })),
964            "short ULID should fail UlidIdValidator, got: {result:?}"
965        );
966        let err = result.unwrap_err();
967        assert!(err.message.contains("26 characters"));
968    }
969
970    #[test]
971    fn test_ulid_validator_invalid_length_long() {
972        let validator = UlidIdValidator;
973        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAVA");
974        assert!(
975            matches!(result, Err(IDValidationError { .. })),
976            "long ULID should fail UlidIdValidator, got: {result:?}"
977        );
978        let err = result.unwrap_err();
979        assert!(err.message.contains("26 characters"));
980    }
981
982    #[test]
983    fn test_ulid_validator_invalid_lowercase() {
984        let validator = UlidIdValidator;
985        let result = validator.validate("01arz3ndektsv4rrffq69g5fav");
986        assert!(
987            matches!(result, Err(IDValidationError { .. })),
988            "lowercase should fail UlidIdValidator, got: {result:?}"
989        );
990    }
991
992    #[test]
993    fn test_ulid_validator_invalid_char_i() {
994        let validator = UlidIdValidator;
995        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAI");
996        assert!(
997            matches!(result, Err(IDValidationError { .. })),
998            "char 'I' should fail UlidIdValidator, got: {result:?}"
999        );
1000        let err = result.unwrap_err();
1001        assert!(err.message.contains("Crockford base32"));
1002    }
1003
1004    #[test]
1005    fn test_ulid_validator_invalid_char_l() {
1006        let validator = UlidIdValidator;
1007        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAL");
1008        assert!(
1009            matches!(result, Err(IDValidationError { .. })),
1010            "char 'L' should fail UlidIdValidator, got: {result:?}"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_ulid_validator_invalid_char_o() {
1016        let validator = UlidIdValidator;
1017        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAO");
1018        assert!(
1019            matches!(result, Err(IDValidationError { .. })),
1020            "char 'O' should fail UlidIdValidator, got: {result:?}"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_ulid_validator_invalid_char_u() {
1026        let validator = UlidIdValidator;
1027        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAU");
1028        assert!(
1029            matches!(result, Err(IDValidationError { .. })),
1030            "char 'U' should fail UlidIdValidator, got: {result:?}"
1031        );
1032    }
1033
1034    #[test]
1035    fn test_ulid_validator_invalid_special_chars() {
1036        let validator = UlidIdValidator;
1037        let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FA-");
1038        assert!(
1039            matches!(result, Err(IDValidationError { .. })),
1040            "special char should fail UlidIdValidator, got: {result:?}"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_ulid_validator_empty_string() {
1046        let validator = UlidIdValidator;
1047        let result = validator.validate("");
1048        assert!(
1049            matches!(result, Err(IDValidationError { .. })),
1050            "empty string should fail UlidIdValidator, got: {result:?}"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_ulid_validator_format_name() {
1056        let validator = UlidIdValidator;
1057        assert_eq!(validator.format_name(), "ULID");
1058    }
1059
1060    // ==================== Opaque Validator Tests ====================
1061
1062    #[test]
1063    fn test_opaque_validator_any_string() {
1064        let validator = OpaqueIdValidator;
1065        validator
1066            .validate("anything")
1067            .unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
1068        validator
1069            .validate("12345")
1070            .unwrap_or_else(|e| panic!("opaque should accept digits: {e}"));
1071        validator
1072            .validate("special@chars!#$%")
1073            .unwrap_or_else(|e| panic!("opaque should accept special chars: {e}"));
1074        validator
1075            .validate("")
1076            .unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
1077    }
1078
1079    #[test]
1080    fn test_opaque_validator_malicious_strings() {
1081        let validator = OpaqueIdValidator;
1082        // Opaque validator accepts anything - security is delegated to application layer
1083        validator
1084            .validate("'; DROP TABLE users; --")
1085            .unwrap_or_else(|e| panic!("opaque should accept SQL injection: {e}"));
1086        validator
1087            .validate("../../etc/passwd")
1088            .unwrap_or_else(|e| panic!("opaque should accept path traversal: {e}"));
1089        validator
1090            .validate("<script>alert('xss')</script>")
1091            .unwrap_or_else(|e| panic!("opaque should accept XSS: {e}"));
1092    }
1093
1094    #[test]
1095    fn test_opaque_validator_uuid() {
1096        let validator = OpaqueIdValidator;
1097        validator
1098            .validate("550e8400-e29b-41d4-a716-446655440000")
1099            .unwrap_or_else(|e| panic!("opaque should accept UUID: {e}"));
1100    }
1101
1102    #[test]
1103    fn test_opaque_validator_format_name() {
1104        let validator = OpaqueIdValidator;
1105        assert_eq!(validator.format_name(), "opaque");
1106    }
1107
1108    // ==================== Cross-Validator Tests ====================
1109
1110    #[test]
1111    fn test_validators_trait_object() {
1112        let validators: Vec<Box<dyn IdValidator>> = vec![
1113            Box::new(UuidIdValidator),
1114            Box::new(NumericIdValidator),
1115            Box::new(UlidIdValidator),
1116            Box::new(OpaqueIdValidator),
1117        ];
1118
1119        for validator in validators {
1120            // All validators should have format names
1121            let name = validator.format_name();
1122            assert!(!name.is_empty());
1123        }
1124    }
1125
1126    #[test]
1127    fn test_validator_selection_by_id_format() {
1128        // Demonstrate using correct validator for different ID formats
1129        let uuid = "550e8400-e29b-41d4-a716-446655440000";
1130        let numeric = "12345";
1131        let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
1132
1133        let uuid_validator = UuidIdValidator;
1134        let numeric_validator = NumericIdValidator;
1135        let ulid_validator = UlidIdValidator;
1136
1137        uuid_validator
1138            .validate(uuid)
1139            .unwrap_or_else(|e| panic!("UUID validator should accept UUID: {e}"));
1140        numeric_validator
1141            .validate(numeric)
1142            .unwrap_or_else(|e| panic!("numeric validator should accept number: {e}"));
1143        ulid_validator
1144            .validate(ulid)
1145            .unwrap_or_else(|e| panic!("ULID validator should accept ULID: {e}"));
1146
1147        // Wrong validators should fail
1148        assert!(
1149            matches!(uuid_validator.validate(numeric), Err(IDValidationError { .. })),
1150            "UUID validator should reject numeric ID"
1151        );
1152        assert!(
1153            matches!(numeric_validator.validate(uuid), Err(IDValidationError { .. })),
1154            "numeric validator should reject UUID"
1155        );
1156        assert!(
1157            matches!(ulid_validator.validate(numeric), Err(IDValidationError { .. })),
1158            "ULID validator should reject numeric ID"
1159        );
1160    }
1161
1162    // ==================== ID Validation Profile Tests ====================
1163
1164    #[test]
1165    fn test_id_validation_profile_uuid() {
1166        let profile = IDValidationProfile::uuid();
1167        assert_eq!(profile.name, "uuid");
1168        profile
1169            .validate("550e8400-e29b-41d4-a716-446655440000")
1170            .unwrap_or_else(|e| panic!("UUID profile should accept valid UUID: {e}"));
1171        assert!(
1172            matches!(profile.validate("not-a-uuid"), Err(IDValidationError { .. })),
1173            "UUID profile should reject invalid string"
1174        );
1175    }
1176
1177    #[test]
1178    fn test_id_validation_profile_numeric() {
1179        let profile = IDValidationProfile::numeric();
1180        assert_eq!(profile.name, "numeric");
1181        profile
1182            .validate("12345")
1183            .unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
1184        assert!(
1185            matches!(profile.validate("not-a-number"), Err(IDValidationError { .. })),
1186            "numeric profile should reject non-number"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_id_validation_profile_ulid() {
1192        let profile = IDValidationProfile::ulid();
1193        assert_eq!(profile.name, "ulid");
1194        profile
1195            .validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
1196            .unwrap_or_else(|e| panic!("ULID profile should accept valid ULID: {e}"));
1197        assert!(
1198            matches!(profile.validate("not-a-ulid"), Err(IDValidationError { .. })),
1199            "ULID profile should reject invalid string"
1200        );
1201    }
1202
1203    #[test]
1204    fn test_id_validation_profile_opaque() {
1205        let profile = IDValidationProfile::opaque();
1206        assert_eq!(profile.name, "opaque");
1207        profile
1208            .validate("anything")
1209            .unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
1210        profile
1211            .validate("12345")
1212            .unwrap_or_else(|e| panic!("opaque profile should accept digits: {e}"));
1213        profile
1214            .validate("special@chars!#$%")
1215            .unwrap_or_else(|e| panic!("opaque profile should accept special chars: {e}"));
1216    }
1217
1218    #[test]
1219    fn test_id_validation_profile_by_name() {
1220        // Test exact matches
1221        assert!(IDValidationProfile::by_name("uuid").is_some(), "uuid profile should exist");
1222        assert!(
1223            IDValidationProfile::by_name("numeric").is_some(),
1224            "numeric profile should exist"
1225        );
1226        assert!(IDValidationProfile::by_name("ulid").is_some(), "ulid profile should exist");
1227        assert!(IDValidationProfile::by_name("opaque").is_some(), "opaque profile should exist");
1228
1229        // Test case insensitivity
1230        assert!(
1231            IDValidationProfile::by_name("UUID").is_some(),
1232            "UUID (uppercase) should resolve"
1233        );
1234        assert!(
1235            IDValidationProfile::by_name("NUMERIC").is_some(),
1236            "NUMERIC (uppercase) should resolve"
1237        );
1238        assert!(
1239            IDValidationProfile::by_name("ULID").is_some(),
1240            "ULID (uppercase) should resolve"
1241        );
1242
1243        // Test aliases
1244        assert!(
1245            IDValidationProfile::by_name("integer").is_some(),
1246            "integer alias should resolve"
1247        );
1248        assert!(IDValidationProfile::by_name("string").is_some(), "string alias should resolve");
1249
1250        // Test invalid
1251        assert!(
1252            IDValidationProfile::by_name("invalid").is_none(),
1253            "unknown name should return None"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_id_validation_profile_by_name_uuid_validation() {
1259        let profile = IDValidationProfile::by_name("uuid").unwrap();
1260        assert_eq!(profile.name, "uuid");
1261        profile
1262            .validate("550e8400-e29b-41d4-a716-446655440000")
1263            .unwrap_or_else(|e| panic!("UUID profile by name should accept valid UUID: {e}"));
1264    }
1265
1266    #[test]
1267    fn test_id_validation_profile_by_name_numeric_validation() {
1268        let profile = IDValidationProfile::by_name("numeric").unwrap();
1269        assert_eq!(profile.name, "numeric");
1270        profile
1271            .validate("12345")
1272            .unwrap_or_else(|e| panic!("numeric profile by name should accept number: {e}"));
1273    }
1274
1275    #[test]
1276    fn test_id_validation_profile_by_name_integer_alias() {
1277        let profile_numeric = IDValidationProfile::by_name("numeric").unwrap();
1278        let profile_integer = IDValidationProfile::by_name("integer").unwrap();
1279
1280        // Both should validate the same way
1281        profile_numeric
1282            .validate("12345")
1283            .unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
1284        profile_integer
1285            .validate("12345")
1286            .unwrap_or_else(|e| panic!("integer alias should accept number: {e}"));
1287        assert!(
1288            matches!(profile_numeric.validate("not-a-number"), Err(IDValidationError { .. })),
1289            "numeric profile should reject non-number"
1290        );
1291        assert!(
1292            matches!(profile_integer.validate("not-a-number"), Err(IDValidationError { .. })),
1293            "integer alias should reject non-number"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_id_validation_profile_by_name_string_alias() {
1299        let profile_opaque = IDValidationProfile::by_name("opaque").unwrap();
1300        let profile_string = IDValidationProfile::by_name("string").unwrap();
1301
1302        // Both should validate the same way
1303        profile_opaque
1304            .validate("anything")
1305            .unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
1306        profile_string
1307            .validate("anything")
1308            .unwrap_or_else(|e| panic!("string alias should accept any string: {e}"));
1309    }
1310
1311    #[test]
1312    fn test_validation_profile_type_as_validator() {
1313        let uuid_type = ValidationProfileType::Uuid(UuidIdValidator);
1314        uuid_type
1315            .as_validator()
1316            .validate("550e8400-e29b-41d4-a716-446655440000")
1317            .unwrap_or_else(|e| panic!("UUID profile type should accept valid UUID: {e}"));
1318
1319        let numeric_type = ValidationProfileType::Numeric(NumericIdValidator);
1320        numeric_type
1321            .as_validator()
1322            .validate("12345")
1323            .unwrap_or_else(|e| panic!("numeric profile type should accept number: {e}"));
1324
1325        let ulid_type = ValidationProfileType::Ulid(UlidIdValidator);
1326        ulid_type
1327            .as_validator()
1328            .validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
1329            .unwrap_or_else(|e| panic!("ULID profile type should accept valid ULID: {e}"));
1330
1331        let opaque_type = ValidationProfileType::Opaque(OpaqueIdValidator);
1332        opaque_type
1333            .as_validator()
1334            .validate("any_value")
1335            .unwrap_or_else(|e| panic!("opaque profile type should accept any string: {e}"));
1336    }
1337
1338    #[test]
1339    fn test_id_validation_profile_clone() {
1340        let profile1 = IDValidationProfile::uuid();
1341        let profile2 = profile1.clone();
1342
1343        assert_eq!(profile1.name, profile2.name);
1344        profile1
1345            .validate("550e8400-e29b-41d4-a716-446655440000")
1346            .unwrap_or_else(|e| panic!("original profile should accept valid UUID: {e}"));
1347        profile2
1348            .validate("550e8400-e29b-41d4-a716-446655440000")
1349            .unwrap_or_else(|e| panic!("cloned profile should accept valid UUID: {e}"));
1350    }
1351
1352    #[test]
1353    fn test_all_profiles_available() {
1354        let profiles = [
1355            IDValidationProfile::uuid(),
1356            IDValidationProfile::numeric(),
1357            IDValidationProfile::ulid(),
1358            IDValidationProfile::opaque(),
1359        ];
1360
1361        assert_eq!(profiles.len(), 4);
1362        assert_eq!(profiles[0].name, "uuid");
1363        assert_eq!(profiles[1].name, "numeric");
1364        assert_eq!(profiles[2].name, "ulid");
1365        assert_eq!(profiles[3].name, "opaque");
1366    }
1367}