Skip to main content

zlayer_spec/
validate.rs

1//! Validation functions for ZLayer deployment specifications
2//!
3//! This module provides validators for all spec fields with proper error reporting.
4
5use crate::error::{ValidationError, ValidationErrorKind};
6use crate::types::{DeploymentSpec, EndpointSpec, ResourceType, ScaleSpec, ServiceSpec};
7use cron::Schedule;
8use std::collections::HashSet;
9use std::str::FromStr;
10
11// =============================================================================
12// Validator crate wrapper functions
13// =============================================================================
14// These functions match the signature expected by #[validate(custom(function = "..."))]
15// They return Result<(), validator::ValidationError>
16
17fn make_validation_error(
18    code: &'static str,
19    message: impl Into<std::borrow::Cow<'static, str>>,
20) -> validator::ValidationError {
21    let mut err = validator::ValidationError::new(code);
22    err.message = Some(message.into());
23    err
24}
25
26/// Wrapper for validate_version for use with validator crate
27pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
28    if version == "v1" {
29        Ok(())
30    } else {
31        Err(make_validation_error(
32            "invalid_version",
33            format!("version must be 'v1', found '{}'", version),
34        ))
35    }
36}
37
38/// Wrapper for validate_deployment_name for use with validator crate
39pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
40    // Check length
41    if name.len() < 3 || name.len() > 63 {
42        return Err(make_validation_error(
43            "invalid_deployment_name",
44            "deployment name must be 3-63 characters",
45        ));
46    }
47
48    // Check first character is alphanumeric
49    if let Some(first) = name.chars().next() {
50        if !first.is_ascii_alphanumeric() {
51            return Err(make_validation_error(
52                "invalid_deployment_name",
53                "deployment name must start with alphanumeric character",
54            ));
55        }
56    }
57
58    // Check all characters are alphanumeric or hyphens
59    for c in name.chars() {
60        if !c.is_ascii_alphanumeric() && c != '-' {
61            return Err(make_validation_error(
62                "invalid_deployment_name",
63                "deployment name can only contain alphanumeric characters and hyphens",
64            ));
65        }
66    }
67
68    Ok(())
69}
70
71/// Wrapper for validate_image_name for use with validator crate
72pub fn validate_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
73    if name.is_empty() || name.trim().is_empty() {
74        Err(make_validation_error(
75            "empty_image_name",
76            "image name cannot be empty",
77        ))
78    } else {
79        Ok(())
80    }
81}
82
83/// Wrapper for validate_cpu for use with validator crate
84/// Note: For Option<f64> fields, validator crate unwraps and passes the inner f64
85pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
86    if cpu <= 0.0 {
87        Err(make_validation_error(
88            "invalid_cpu",
89            format!("CPU limit must be > 0, found {}", cpu),
90        ))
91    } else {
92        Ok(())
93    }
94}
95
96/// Wrapper for validate_memory_format for use with validator crate
97/// Note: For Option<String> fields, validator crate unwraps and passes &String
98pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
99    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
100
101    let suffix_match = VALID_SUFFIXES
102        .iter()
103        .find(|&&suffix| value.ends_with(suffix));
104
105    match suffix_match {
106        Some(suffix) => {
107            let numeric_part = &value[..value.len() - suffix.len()];
108            match numeric_part.parse::<u64>() {
109                Ok(n) if n > 0 => Ok(()),
110                _ => Err(make_validation_error(
111                    "invalid_memory_format",
112                    format!("invalid memory format: '{}'", value),
113                )),
114            }
115        }
116        None => Err(make_validation_error(
117            "invalid_memory_format",
118            format!(
119                "invalid memory format: '{}' (use Ki, Mi, Gi, or Ti suffix)",
120                value
121            ),
122        )),
123    }
124}
125
126/// Wrapper for validate_port for use with validator crate
127/// Note: validator crate passes primitive types by value for custom validators
128pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
129    if port >= 1 {
130        Ok(())
131    } else {
132        Err(make_validation_error(
133            "invalid_port",
134            "port must be between 1-65535",
135        ))
136    }
137}
138
139/// Validate scale range (min <= max) for ScaleSpec
140pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
141    if let ScaleSpec::Adaptive { min, max, .. } = scale {
142        if *min > *max {
143            return Err(make_validation_error(
144                "invalid_scale_range",
145                format!("scale min ({}) cannot be greater than max ({})", min, max),
146            ));
147        }
148    }
149    Ok(())
150}
151
152/// Wrapper for validate_cron_schedule for use with validator crate
153/// Note: For Option<String> fields, validator crate unwraps and passes &String
154pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
155    Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
156        make_validation_error(
157            "invalid_cron_schedule",
158            format!("invalid cron schedule '{}': {}", schedule, e),
159        )
160    })
161}
162
163/// Validate a secret reference name format
164///
165/// Secret names must:
166/// - Start with a letter (a-z, A-Z)
167/// - Contain only alphanumeric characters, hyphens, and underscores
168/// - Optionally be prefixed with `@service/` for cross-service references
169///
170/// Examples of valid secret refs:
171/// - `$S:my-secret`
172/// - `$S:api_key`
173/// - `$S:@auth-service/jwt-secret`
174pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
175    // Only validate values that start with $S:
176    if !value.starts_with("$S:") {
177        return Ok(());
178    }
179
180    let secret_ref = &value[3..]; // Remove "$S:" prefix
181
182    if secret_ref.is_empty() {
183        return Err(make_validation_error(
184            "invalid_secret_reference",
185            "secret reference cannot be empty after $S:",
186        ));
187    }
188
189    // Check for cross-service reference format: @service/secret-name
190    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
191        // Cross-service reference: @service/secret-name
192        let parts: Vec<&str> = rest.splitn(2, '/').collect();
193        if parts.len() != 2 {
194            return Err(make_validation_error(
195                "invalid_secret_reference",
196                format!(
197                    "cross-service secret reference '{}' must have format @service/secret-name",
198                    value
199                ),
200            ));
201        }
202
203        let service_name = parts[0];
204        let secret_name = parts[1];
205
206        // Validate service name part
207        if service_name.is_empty() {
208            return Err(make_validation_error(
209                "invalid_secret_reference",
210                format!(
211                    "service name in secret reference '{}' cannot be empty",
212                    value
213                ),
214            ));
215        }
216
217        if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
218            return Err(make_validation_error(
219                "invalid_secret_reference",
220                format!(
221                    "service name in secret reference '{}' must start with a letter",
222                    value
223                ),
224            ));
225        }
226
227        for c in service_name.chars() {
228            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
229                return Err(make_validation_error(
230                    "invalid_secret_reference",
231                    format!(
232                        "service name in secret reference '{}' contains invalid character '{}'",
233                        value, c
234                    ),
235                ));
236            }
237        }
238
239        secret_name
240    } else {
241        secret_ref
242    };
243
244    // Validate the secret name
245    if secret_name.is_empty() {
246        return Err(make_validation_error(
247            "invalid_secret_reference",
248            format!("secret name in '{}' cannot be empty", value),
249        ));
250    }
251
252    // Must start with a letter
253    let first_char = secret_name.chars().next().unwrap();
254    if !first_char.is_ascii_alphabetic() {
255        return Err(make_validation_error(
256            "invalid_secret_reference",
257            format!(
258                "secret name in '{}' must start with a letter, found '{}'",
259                value, first_char
260            ),
261        ));
262    }
263
264    // All characters must be alphanumeric, hyphen, or underscore
265    for c in secret_name.chars() {
266        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
267            return Err(make_validation_error(
268                "invalid_secret_reference",
269                format!(
270                    "secret name in '{}' contains invalid character '{}' (only alphanumeric, hyphens, underscores allowed)",
271                    value, c
272                ),
273            ));
274        }
275    }
276
277    Ok(())
278}
279
280/// Validate all environment variable values in a service spec
281pub fn validate_env_vars(
282    service_name: &str,
283    env: &std::collections::HashMap<String, String>,
284) -> Result<(), crate::error::ValidationError> {
285    for (key, value) in env {
286        if let Err(e) = validate_secret_reference(value) {
287            return Err(crate::error::ValidationError {
288                kind: crate::error::ValidationErrorKind::InvalidEnvVar {
289                    key: key.clone(),
290                    reason: e
291                        .message
292                        .map(|m| m.to_string())
293                        .unwrap_or_else(|| "invalid secret reference".to_string()),
294                },
295                path: format!("services.{}.env.{}", service_name, key),
296            });
297        }
298    }
299    Ok(())
300}
301
302/// Validate storage name format (lowercase alphanumeric with hyphens)
303pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
304    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
305    let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
306    if !re.is_match(name) || name.len() > 63 {
307        return Err(make_validation_error(
308            "invalid_storage_name",
309            format!("storage name '{}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen", name),
310        ));
311    }
312    Ok(())
313}
314
315/// Wrapper for validate_storage_name for use with validator crate
316pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
317    validate_storage_name(name)
318}
319
320// =============================================================================
321// Cross-field validation functions (called from lib.rs)
322// =============================================================================
323
324/// Validate that all dependency service references exist
325pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
326    let service_names: HashSet<&str> = spec.services.keys().map(|s| s.as_str()).collect();
327
328    for (service_name, service_spec) in &spec.services {
329        for dep in &service_spec.depends {
330            if !service_names.contains(dep.service.as_str()) {
331                return Err(ValidationError {
332                    kind: ValidationErrorKind::UnknownDependency {
333                        service: dep.service.clone(),
334                    },
335                    path: format!("services.{}.depends", service_name),
336                });
337            }
338        }
339    }
340
341    Ok(())
342}
343
344/// Validate that each service has unique endpoint names
345pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
346    for (service_name, service_spec) in &spec.services {
347        let mut seen = HashSet::new();
348        for endpoint in &service_spec.endpoints {
349            if !seen.insert(&endpoint.name) {
350                return Err(ValidationError {
351                    kind: ValidationErrorKind::DuplicateEndpoint {
352                        name: endpoint.name.clone(),
353                    },
354                    path: format!("services.{}.endpoints", service_name),
355                });
356            }
357        }
358    }
359
360    Ok(())
361}
362
363/// Validate schedule/rtype consistency for all services
364pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
365    for (service_name, service_spec) in &spec.services {
366        validate_service_schedule(service_name, service_spec)?;
367    }
368    Ok(())
369}
370
371/// Validate schedule/rtype consistency for a single service
372pub fn validate_service_schedule(
373    service_name: &str,
374    spec: &ServiceSpec,
375) -> Result<(), ValidationError> {
376    // If schedule is set, rtype must be Cron
377    if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
378        return Err(ValidationError {
379            kind: ValidationErrorKind::ScheduleOnlyForCron,
380            path: format!("services.{}.schedule", service_name),
381        });
382    }
383
384    // If rtype is Cron, schedule must be set
385    if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
386        return Err(ValidationError {
387            kind: ValidationErrorKind::CronRequiresSchedule,
388            path: format!("services.{}.schedule", service_name),
389        });
390    }
391
392    Ok(())
393}
394
395// =============================================================================
396// Original validation functions (for direct use)
397// =============================================================================
398
399/// Validate that the version is "v1"
400pub fn validate_version(version: &str) -> Result<(), ValidationError> {
401    if version == "v1" {
402        Ok(())
403    } else {
404        Err(ValidationError {
405            kind: ValidationErrorKind::InvalidVersion {
406                found: version.to_string(),
407            },
408            path: "version".to_string(),
409        })
410    }
411}
412
413/// Validate a deployment name
414///
415/// Requirements:
416/// - 3-63 characters
417/// - Alphanumeric + hyphens only
418/// - Must start with alphanumeric character
419pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
420    // Check length
421    if name.len() < 3 || name.len() > 63 {
422        return Err(ValidationError {
423            kind: ValidationErrorKind::EmptyDeploymentName,
424            path: "deployment".to_string(),
425        });
426    }
427
428    // Check first character is alphanumeric
429    if let Some(first) = name.chars().next() {
430        if !first.is_ascii_alphanumeric() {
431            return Err(ValidationError {
432                kind: ValidationErrorKind::EmptyDeploymentName,
433                path: "deployment".to_string(),
434            });
435        }
436    }
437
438    // Check all characters are alphanumeric or hyphens
439    for c in name.chars() {
440        if !c.is_ascii_alphanumeric() && c != '-' {
441            return Err(ValidationError {
442                kind: ValidationErrorKind::EmptyDeploymentName,
443                path: "deployment".to_string(),
444            });
445        }
446    }
447
448    Ok(())
449}
450
451/// Validate an image name
452///
453/// Requirements:
454/// - Non-empty
455/// - Not whitespace-only
456pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
457    if name.is_empty() || name.trim().is_empty() {
458        Err(ValidationError {
459            kind: ValidationErrorKind::EmptyImageName,
460            path: "image.name".to_string(),
461        })
462    } else {
463        Ok(())
464    }
465}
466
467/// Validate CPU limit
468///
469/// Requirements:
470/// - Must be > 0
471pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
472    if *cpu > 0.0 {
473        Ok(())
474    } else {
475        Err(ValidationError {
476            kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
477            path: "resources.cpu".to_string(),
478        })
479    }
480}
481
482/// Validate memory format
483///
484/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
485/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
486pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
487    // Valid suffixes
488    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
489
490    // Find which suffix is used, if any
491    let suffix_match = VALID_SUFFIXES
492        .iter()
493        .find(|&&suffix| value.ends_with(suffix));
494
495    match suffix_match {
496        Some(suffix) => {
497            // Extract the numeric part
498            let numeric_part = &value[..value.len() - suffix.len()];
499
500            // Check that numeric part is a valid positive number
501            match numeric_part.parse::<u64>() {
502                Ok(n) if n > 0 => Ok(()),
503                _ => Err(ValidationError {
504                    kind: ValidationErrorKind::InvalidMemoryFormat {
505                        value: value.to_string(),
506                    },
507                    path: "resources.memory".to_string(),
508                }),
509            }
510        }
511        None => Err(ValidationError {
512            kind: ValidationErrorKind::InvalidMemoryFormat {
513                value: value.to_string(),
514            },
515            path: "resources.memory".to_string(),
516        }),
517    }
518}
519
520/// Validate a port number
521///
522/// Requirements:
523/// - Must be 1-65535 (not 0)
524pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
525    if *port >= 1 {
526        Ok(())
527    } else {
528        Err(ValidationError {
529            kind: ValidationErrorKind::InvalidPort { port: *port as u32 },
530            path: "endpoints[].port".to_string(),
531        })
532    }
533}
534
535/// Validate that endpoint names are unique within a service
536pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
537    let mut seen = HashSet::new();
538
539    for endpoint in endpoints {
540        if !seen.insert(&endpoint.name) {
541            return Err(ValidationError {
542                kind: ValidationErrorKind::DuplicateEndpoint {
543                    name: endpoint.name.clone(),
544                },
545                path: "endpoints".to_string(),
546            });
547        }
548    }
549
550    Ok(())
551}
552
553/// Validate scale range
554///
555/// Requirements:
556/// - min <= max
557pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
558    if min <= max {
559        Ok(())
560    } else {
561        Err(ValidationError {
562            kind: ValidationErrorKind::InvalidScaleRange { min, max },
563            path: "scale".to_string(),
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::types::{ExposeType, Protocol};
572
573    // Version validation tests
574    #[test]
575    fn test_validate_version_valid() {
576        assert!(validate_version("v1").is_ok());
577    }
578
579    #[test]
580    fn test_validate_version_invalid_v2() {
581        let result = validate_version("v2");
582        assert!(result.is_err());
583        let err = result.unwrap_err();
584        assert!(matches!(
585            err.kind,
586            ValidationErrorKind::InvalidVersion { found } if found == "v2"
587        ));
588    }
589
590    #[test]
591    fn test_validate_version_empty() {
592        let result = validate_version("");
593        assert!(result.is_err());
594        let err = result.unwrap_err();
595        assert!(matches!(
596            err.kind,
597            ValidationErrorKind::InvalidVersion { found } if found.is_empty()
598        ));
599    }
600
601    // Deployment name validation tests
602    #[test]
603    fn test_validate_deployment_name_valid() {
604        assert!(validate_deployment_name("my-app").is_ok());
605        assert!(validate_deployment_name("api").is_ok());
606        assert!(validate_deployment_name("my-service-123").is_ok());
607        assert!(validate_deployment_name("a1b").is_ok());
608    }
609
610    #[test]
611    fn test_validate_deployment_name_too_short() {
612        assert!(validate_deployment_name("ab").is_err());
613        assert!(validate_deployment_name("a").is_err());
614        assert!(validate_deployment_name("").is_err());
615    }
616
617    #[test]
618    fn test_validate_deployment_name_too_long() {
619        let long_name = "a".repeat(64);
620        assert!(validate_deployment_name(&long_name).is_err());
621    }
622
623    #[test]
624    fn test_validate_deployment_name_invalid_chars() {
625        assert!(validate_deployment_name("my_app").is_err()); // underscore
626        assert!(validate_deployment_name("my.app").is_err()); // dot
627        assert!(validate_deployment_name("my app").is_err()); // space
628        assert!(validate_deployment_name("my@app").is_err()); // special char
629    }
630
631    #[test]
632    fn test_validate_deployment_name_must_start_alphanumeric() {
633        assert!(validate_deployment_name("-myapp").is_err());
634        assert!(validate_deployment_name("_myapp").is_err());
635    }
636
637    // Image name validation tests
638    #[test]
639    fn test_validate_image_name_valid() {
640        assert!(validate_image_name("nginx:latest").is_ok());
641        assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
642        assert!(validate_image_name("ubuntu").is_ok());
643    }
644
645    #[test]
646    fn test_validate_image_name_empty() {
647        let result = validate_image_name("");
648        assert!(result.is_err());
649        assert!(matches!(
650            result.unwrap_err().kind,
651            ValidationErrorKind::EmptyImageName
652        ));
653    }
654
655    #[test]
656    fn test_validate_image_name_whitespace_only() {
657        assert!(validate_image_name("   ").is_err());
658        assert!(validate_image_name("\t\n").is_err());
659    }
660
661    // CPU validation tests
662    #[test]
663    fn test_validate_cpu_valid() {
664        assert!(validate_cpu(&0.5).is_ok());
665        assert!(validate_cpu(&1.0).is_ok());
666        assert!(validate_cpu(&2.0).is_ok());
667        assert!(validate_cpu(&0.001).is_ok());
668    }
669
670    #[test]
671    fn test_validate_cpu_zero() {
672        let result = validate_cpu(&0.0);
673        assert!(result.is_err());
674        assert!(matches!(
675            result.unwrap_err().kind,
676            ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
677        ));
678    }
679
680    #[test]
681    fn test_validate_cpu_negative() {
682        let result = validate_cpu(&-1.0);
683        assert!(result.is_err());
684        assert!(matches!(
685            result.unwrap_err().kind,
686            ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
687        ));
688    }
689
690    // Memory format validation tests
691    #[test]
692    fn test_validate_memory_format_valid() {
693        assert!(validate_memory_format("512Mi").is_ok());
694        assert!(validate_memory_format("1Gi").is_ok());
695        assert!(validate_memory_format("2Ti").is_ok());
696        assert!(validate_memory_format("256Ki").is_ok());
697        assert!(validate_memory_format("4096Mi").is_ok());
698    }
699
700    #[test]
701    fn test_validate_memory_format_invalid_suffix() {
702        assert!(validate_memory_format("512MB").is_err());
703        assert!(validate_memory_format("1GB").is_err());
704        assert!(validate_memory_format("512").is_err());
705        assert!(validate_memory_format("512m").is_err());
706    }
707
708    #[test]
709    fn test_validate_memory_format_no_number() {
710        assert!(validate_memory_format("Mi").is_err());
711        assert!(validate_memory_format("Gi").is_err());
712    }
713
714    #[test]
715    fn test_validate_memory_format_invalid_number() {
716        assert!(validate_memory_format("-512Mi").is_err());
717        assert!(validate_memory_format("0Mi").is_err());
718        assert!(validate_memory_format("abcMi").is_err());
719    }
720
721    // Port validation tests
722    #[test]
723    fn test_validate_port_valid() {
724        assert!(validate_port(&1).is_ok());
725        assert!(validate_port(&80).is_ok());
726        assert!(validate_port(&443).is_ok());
727        assert!(validate_port(&8080).is_ok());
728        assert!(validate_port(&65535).is_ok());
729    }
730
731    #[test]
732    fn test_validate_port_zero() {
733        let result = validate_port(&0);
734        assert!(result.is_err());
735        assert!(matches!(
736            result.unwrap_err().kind,
737            ValidationErrorKind::InvalidPort { port } if port == 0
738        ));
739    }
740
741    // Note: u16 cannot be negative, and max value 65535 is valid,
742    // so we only need to test 0 as the invalid case
743
744    // Unique endpoints validation tests
745    #[test]
746    fn test_validate_unique_endpoints_valid() {
747        let endpoints = vec![
748            EndpointSpec {
749                name: "http".to_string(),
750                protocol: Protocol::Http,
751                port: 8080,
752                path: None,
753                expose: ExposeType::Public,
754            },
755            EndpointSpec {
756                name: "grpc".to_string(),
757                protocol: Protocol::Tcp,
758                port: 9090,
759                path: None,
760                expose: ExposeType::Internal,
761            },
762        ];
763        assert!(validate_unique_endpoints(&endpoints).is_ok());
764    }
765
766    #[test]
767    fn test_validate_unique_endpoints_empty() {
768        let endpoints: Vec<EndpointSpec> = vec![];
769        assert!(validate_unique_endpoints(&endpoints).is_ok());
770    }
771
772    #[test]
773    fn test_validate_unique_endpoints_duplicates() {
774        let endpoints = vec![
775            EndpointSpec {
776                name: "http".to_string(),
777                protocol: Protocol::Http,
778                port: 8080,
779                path: None,
780                expose: ExposeType::Public,
781            },
782            EndpointSpec {
783                name: "http".to_string(), // duplicate name
784                protocol: Protocol::Https,
785                port: 8443,
786                path: None,
787                expose: ExposeType::Public,
788            },
789        ];
790        let result = validate_unique_endpoints(&endpoints);
791        assert!(result.is_err());
792        assert!(matches!(
793            result.unwrap_err().kind,
794            ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
795        ));
796    }
797
798    // Scale range validation tests
799    #[test]
800    fn test_validate_scale_range_valid() {
801        assert!(validate_scale_range(1, 10).is_ok());
802        assert!(validate_scale_range(1, 1).is_ok()); // min == max is valid
803        assert!(validate_scale_range(0, 5).is_ok());
804        assert!(validate_scale_range(5, 100).is_ok());
805    }
806
807    #[test]
808    fn test_validate_scale_range_min_greater_than_max() {
809        let result = validate_scale_range(10, 5);
810        assert!(result.is_err());
811        let err = result.unwrap_err();
812        assert!(matches!(
813            err.kind,
814            ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
815        ));
816    }
817
818    #[test]
819    fn test_validate_scale_range_large_gap() {
820        // Large gap between min and max should still be valid
821        assert!(validate_scale_range(1, 1000).is_ok());
822    }
823
824    // Cron schedule validation tests
825    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
826    #[test]
827    fn test_validate_schedule_wrapper_valid() {
828        // Valid 7-field cron expressions (sec min hour dom month dow year)
829        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
830        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
831        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
832        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
833        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
834        // Every 10 seconds
835    }
836
837    #[test]
838    fn test_validate_schedule_wrapper_invalid() {
839        // Invalid cron expressions
840        assert!(validate_schedule_wrapper(&"".to_string()).is_err()); // Empty
841        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
842        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
843        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
844        // Invalid second (60)
845    }
846
847    // Secret reference validation tests
848    #[test]
849    fn test_validate_secret_reference_plain_values() {
850        // Plain values should pass (not secret refs)
851        assert!(validate_secret_reference("my-value").is_ok());
852        assert!(validate_secret_reference("").is_ok());
853        assert!(validate_secret_reference("some string").is_ok());
854        assert!(validate_secret_reference("$E:MY_VAR").is_ok()); // Host env ref, not secret
855    }
856
857    #[test]
858    fn test_validate_secret_reference_valid() {
859        // Valid secret references
860        assert!(validate_secret_reference("$S:my-secret").is_ok());
861        assert!(validate_secret_reference("$S:api_key").is_ok());
862        assert!(validate_secret_reference("$S:MySecret123").is_ok());
863        assert!(validate_secret_reference("$S:a").is_ok()); // Single letter is valid
864    }
865
866    #[test]
867    fn test_validate_secret_reference_cross_service() {
868        // Valid cross-service references
869        assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
870        assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
871        assert!(validate_secret_reference("$S:@svc/secret").is_ok());
872    }
873
874    #[test]
875    fn test_validate_secret_reference_empty_after_prefix() {
876        // Empty after $S:
877        assert!(validate_secret_reference("$S:").is_err());
878    }
879
880    #[test]
881    fn test_validate_secret_reference_must_start_with_letter() {
882        // Secret name must start with letter
883        assert!(validate_secret_reference("$S:123-secret").is_err());
884        assert!(validate_secret_reference("$S:-my-secret").is_err());
885        assert!(validate_secret_reference("$S:_underscore").is_err());
886    }
887
888    #[test]
889    fn test_validate_secret_reference_invalid_chars() {
890        // Invalid characters in secret name
891        assert!(validate_secret_reference("$S:my.secret").is_err());
892        assert!(validate_secret_reference("$S:my secret").is_err());
893        assert!(validate_secret_reference("$S:my@secret").is_err());
894    }
895
896    #[test]
897    fn test_validate_secret_reference_cross_service_invalid() {
898        // Missing slash in cross-service ref
899        assert!(validate_secret_reference("$S:@service").is_err());
900        // Empty service name
901        assert!(validate_secret_reference("$S:@/secret").is_err());
902        // Empty secret name
903        assert!(validate_secret_reference("$S:@service/").is_err());
904        // Service name must start with letter
905        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
906    }
907}