1use crate::error::{ValidationError, ValidationErrorKind};
6use crate::types::{
7 DeploymentSpec, EndpointSpec, EndpointTunnelConfig, ResourceType, ScaleSpec, ServiceSpec,
8 TunnelAccessConfig, TunnelDefinition,
9};
10use cron::Schedule;
11use std::collections::HashSet;
12use std::str::FromStr;
13
14fn make_validation_error(
21 code: &'static str,
22 message: impl Into<std::borrow::Cow<'static, str>>,
23) -> validator::ValidationError {
24 let mut err = validator::ValidationError::new(code);
25 err.message = Some(message.into());
26 err
27}
28
29pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
31 if version == "v1" {
32 Ok(())
33 } else {
34 Err(make_validation_error(
35 "invalid_version",
36 format!("version must be 'v1', found '{}'", version),
37 ))
38 }
39}
40
41pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
43 if name.len() < 3 || name.len() > 63 {
45 return Err(make_validation_error(
46 "invalid_deployment_name",
47 "deployment name must be 3-63 characters",
48 ));
49 }
50
51 if let Some(first) = name.chars().next() {
53 if !first.is_ascii_alphanumeric() {
54 return Err(make_validation_error(
55 "invalid_deployment_name",
56 "deployment name must start with alphanumeric character",
57 ));
58 }
59 }
60
61 for c in name.chars() {
63 if !c.is_ascii_alphanumeric() && c != '-' {
64 return Err(make_validation_error(
65 "invalid_deployment_name",
66 "deployment name can only contain alphanumeric characters and hyphens",
67 ));
68 }
69 }
70
71 Ok(())
72}
73
74pub fn validate_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
76 if name.is_empty() || name.trim().is_empty() {
77 Err(make_validation_error(
78 "empty_image_name",
79 "image name cannot be empty",
80 ))
81 } else {
82 Ok(())
83 }
84}
85
86pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
89 if cpu <= 0.0 {
90 Err(make_validation_error(
91 "invalid_cpu",
92 format!("CPU limit must be > 0, found {}", cpu),
93 ))
94 } else {
95 Ok(())
96 }
97}
98
99pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
102 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
103
104 let suffix_match = VALID_SUFFIXES
105 .iter()
106 .find(|&&suffix| value.ends_with(suffix));
107
108 match suffix_match {
109 Some(suffix) => {
110 let numeric_part = &value[..value.len() - suffix.len()];
111 match numeric_part.parse::<u64>() {
112 Ok(n) if n > 0 => Ok(()),
113 _ => Err(make_validation_error(
114 "invalid_memory_format",
115 format!("invalid memory format: '{}'", value),
116 )),
117 }
118 }
119 None => Err(make_validation_error(
120 "invalid_memory_format",
121 format!(
122 "invalid memory format: '{}' (use Ki, Mi, Gi, or Ti suffix)",
123 value
124 ),
125 )),
126 }
127}
128
129pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
132 if port >= 1 {
133 Ok(())
134 } else {
135 Err(make_validation_error(
136 "invalid_port",
137 "port must be between 1-65535",
138 ))
139 }
140}
141
142pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
144 if let ScaleSpec::Adaptive { min, max, .. } = scale {
145 if *min > *max {
146 return Err(make_validation_error(
147 "invalid_scale_range",
148 format!("scale min ({}) cannot be greater than max ({})", min, max),
149 ));
150 }
151 }
152 Ok(())
153}
154
155pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
158 Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
159 make_validation_error(
160 "invalid_cron_schedule",
161 format!("invalid cron schedule '{}': {}", schedule, e),
162 )
163 })
164}
165
166pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
178 if !value.starts_with("$S:") {
180 return Ok(());
181 }
182
183 let secret_ref = &value[3..]; if secret_ref.is_empty() {
186 return Err(make_validation_error(
187 "invalid_secret_reference",
188 "secret reference cannot be empty after $S:",
189 ));
190 }
191
192 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
194 let parts: Vec<&str> = rest.splitn(2, '/').collect();
196 if parts.len() != 2 {
197 return Err(make_validation_error(
198 "invalid_secret_reference",
199 format!(
200 "cross-service secret reference '{}' must have format @service/secret-name",
201 value
202 ),
203 ));
204 }
205
206 let service_name = parts[0];
207 let secret_name = parts[1];
208
209 if service_name.is_empty() {
211 return Err(make_validation_error(
212 "invalid_secret_reference",
213 format!(
214 "service name in secret reference '{}' cannot be empty",
215 value
216 ),
217 ));
218 }
219
220 if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
221 return Err(make_validation_error(
222 "invalid_secret_reference",
223 format!(
224 "service name in secret reference '{}' must start with a letter",
225 value
226 ),
227 ));
228 }
229
230 for c in service_name.chars() {
231 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
232 return Err(make_validation_error(
233 "invalid_secret_reference",
234 format!(
235 "service name in secret reference '{}' contains invalid character '{}'",
236 value, c
237 ),
238 ));
239 }
240 }
241
242 secret_name
243 } else {
244 secret_ref
245 };
246
247 if secret_name.is_empty() {
249 return Err(make_validation_error(
250 "invalid_secret_reference",
251 format!("secret name in '{}' cannot be empty", value),
252 ));
253 }
254
255 let first_char = secret_name.chars().next().unwrap();
257 if !first_char.is_ascii_alphabetic() {
258 return Err(make_validation_error(
259 "invalid_secret_reference",
260 format!(
261 "secret name in '{}' must start with a letter, found '{}'",
262 value, first_char
263 ),
264 ));
265 }
266
267 for c in secret_name.chars() {
269 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
270 return Err(make_validation_error(
271 "invalid_secret_reference",
272 format!(
273 "secret name in '{}' contains invalid character '{}' (only alphanumeric, hyphens, underscores allowed)",
274 value, c
275 ),
276 ));
277 }
278 }
279
280 Ok(())
281}
282
283pub fn validate_env_vars(
285 service_name: &str,
286 env: &std::collections::HashMap<String, String>,
287) -> Result<(), crate::error::ValidationError> {
288 for (key, value) in env {
289 if let Err(e) = validate_secret_reference(value) {
290 return Err(crate::error::ValidationError {
291 kind: crate::error::ValidationErrorKind::InvalidEnvVar {
292 key: key.clone(),
293 reason: e
294 .message
295 .map(|m| m.to_string())
296 .unwrap_or_else(|| "invalid secret reference".to_string()),
297 },
298 path: format!("services.{}.env.{}", service_name, key),
299 });
300 }
301 }
302 Ok(())
303}
304
305pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
307 let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
309 if !re.is_match(name) || name.len() > 63 {
310 return Err(make_validation_error(
311 "invalid_storage_name",
312 format!("storage name '{}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen", name),
313 ));
314 }
315 Ok(())
316}
317
318pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
320 validate_storage_name(name)
321}
322
323pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
329 let service_names: HashSet<&str> = spec.services.keys().map(|s| s.as_str()).collect();
330
331 for (service_name, service_spec) in &spec.services {
332 for dep in &service_spec.depends {
333 if !service_names.contains(dep.service.as_str()) {
334 return Err(ValidationError {
335 kind: ValidationErrorKind::UnknownDependency {
336 service: dep.service.clone(),
337 },
338 path: format!("services.{}.depends", service_name),
339 });
340 }
341 }
342 }
343
344 Ok(())
345}
346
347pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
349 for (service_name, service_spec) in &spec.services {
350 let mut seen = HashSet::new();
351 for endpoint in &service_spec.endpoints {
352 if !seen.insert(&endpoint.name) {
353 return Err(ValidationError {
354 kind: ValidationErrorKind::DuplicateEndpoint {
355 name: endpoint.name.clone(),
356 },
357 path: format!("services.{}.endpoints", service_name),
358 });
359 }
360 }
361 }
362
363 Ok(())
364}
365
366pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
368 for (service_name, service_spec) in &spec.services {
369 validate_service_schedule(service_name, service_spec)?;
370 }
371 Ok(())
372}
373
374pub fn validate_service_schedule(
376 service_name: &str,
377 spec: &ServiceSpec,
378) -> Result<(), ValidationError> {
379 if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
381 return Err(ValidationError {
382 kind: ValidationErrorKind::ScheduleOnlyForCron,
383 path: format!("services.{}.schedule", service_name),
384 });
385 }
386
387 if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
389 return Err(ValidationError {
390 kind: ValidationErrorKind::CronRequiresSchedule,
391 path: format!("services.{}.schedule", service_name),
392 });
393 }
394
395 Ok(())
396}
397
398pub fn validate_version(version: &str) -> Result<(), ValidationError> {
404 if version == "v1" {
405 Ok(())
406 } else {
407 Err(ValidationError {
408 kind: ValidationErrorKind::InvalidVersion {
409 found: version.to_string(),
410 },
411 path: "version".to_string(),
412 })
413 }
414}
415
416pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
423 if name.len() < 3 || name.len() > 63 {
425 return Err(ValidationError {
426 kind: ValidationErrorKind::EmptyDeploymentName,
427 path: "deployment".to_string(),
428 });
429 }
430
431 if let Some(first) = name.chars().next() {
433 if !first.is_ascii_alphanumeric() {
434 return Err(ValidationError {
435 kind: ValidationErrorKind::EmptyDeploymentName,
436 path: "deployment".to_string(),
437 });
438 }
439 }
440
441 for c in name.chars() {
443 if !c.is_ascii_alphanumeric() && c != '-' {
444 return Err(ValidationError {
445 kind: ValidationErrorKind::EmptyDeploymentName,
446 path: "deployment".to_string(),
447 });
448 }
449 }
450
451 Ok(())
452}
453
454pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
460 if name.is_empty() || name.trim().is_empty() {
461 Err(ValidationError {
462 kind: ValidationErrorKind::EmptyImageName,
463 path: "image.name".to_string(),
464 })
465 } else {
466 Ok(())
467 }
468}
469
470pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
475 if *cpu > 0.0 {
476 Ok(())
477 } else {
478 Err(ValidationError {
479 kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
480 path: "resources.cpu".to_string(),
481 })
482 }
483}
484
485pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
490 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
492
493 let suffix_match = VALID_SUFFIXES
495 .iter()
496 .find(|&&suffix| value.ends_with(suffix));
497
498 match suffix_match {
499 Some(suffix) => {
500 let numeric_part = &value[..value.len() - suffix.len()];
502
503 match numeric_part.parse::<u64>() {
505 Ok(n) if n > 0 => Ok(()),
506 _ => Err(ValidationError {
507 kind: ValidationErrorKind::InvalidMemoryFormat {
508 value: value.to_string(),
509 },
510 path: "resources.memory".to_string(),
511 }),
512 }
513 }
514 None => Err(ValidationError {
515 kind: ValidationErrorKind::InvalidMemoryFormat {
516 value: value.to_string(),
517 },
518 path: "resources.memory".to_string(),
519 }),
520 }
521}
522
523pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
528 if *port >= 1 {
529 Ok(())
530 } else {
531 Err(ValidationError {
532 kind: ValidationErrorKind::InvalidPort { port: *port as u32 },
533 path: "endpoints[].port".to_string(),
534 })
535 }
536}
537
538pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
540 let mut seen = HashSet::new();
541
542 for endpoint in endpoints {
543 if !seen.insert(&endpoint.name) {
544 return Err(ValidationError {
545 kind: ValidationErrorKind::DuplicateEndpoint {
546 name: endpoint.name.clone(),
547 },
548 path: "endpoints".to_string(),
549 });
550 }
551 }
552
553 Ok(())
554}
555
556pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
561 if min <= max {
562 Ok(())
563 } else {
564 Err(ValidationError {
565 kind: ValidationErrorKind::InvalidScaleRange { min, max },
566 path: "scale".to_string(),
567 })
568 }
569}
570
571pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
577 humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
578 make_validation_error(
579 "invalid_tunnel_ttl",
580 format!("invalid TTL format '{}': {}", ttl, e),
581 )
582 })
583}
584
585pub fn validate_tunnel_access_config(
587 config: &TunnelAccessConfig,
588 path: &str,
589) -> Result<(), ValidationError> {
590 if let Some(ref max_ttl) = config.max_ttl {
591 validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
592 kind: ValidationErrorKind::InvalidTunnelTtl {
593 value: max_ttl.clone(),
594 reason: e
595 .message
596 .map(|m| m.to_string())
597 .unwrap_or_else(|| "invalid duration format".to_string()),
598 },
599 path: format!("{}.access.max_ttl", path),
600 })?;
601 }
602 Ok(())
603}
604
605pub fn validate_endpoint_tunnel_config(
607 config: &EndpointTunnelConfig,
608 path: &str,
609) -> Result<(), ValidationError> {
610 if let Some(ref access) = config.access {
615 validate_tunnel_access_config(access, path)?;
616 }
617
618 Ok(())
619}
620
621pub fn validate_tunnel_definition(
623 name: &str,
624 tunnel: &TunnelDefinition,
625) -> Result<(), ValidationError> {
626 let path = format!("tunnels.{}", name);
627
628 if tunnel.local_port == 0 {
630 return Err(ValidationError {
631 kind: ValidationErrorKind::InvalidTunnelPort {
632 port: tunnel.local_port,
633 field: "local_port".to_string(),
634 },
635 path: format!("{}.local_port", path),
636 });
637 }
638
639 if tunnel.remote_port == 0 {
641 return Err(ValidationError {
642 kind: ValidationErrorKind::InvalidTunnelPort {
643 port: tunnel.remote_port,
644 field: "remote_port".to_string(),
645 },
646 path: format!("{}.remote_port", path),
647 });
648 }
649
650 Ok(())
651}
652
653pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
655 for (name, tunnel) in &spec.tunnels {
657 validate_tunnel_definition(name, tunnel)?;
658 }
659
660 for (service_name, service_spec) in &spec.services {
662 for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
663 if let Some(ref tunnel_config) = endpoint.tunnel {
664 let path = format!("services.{}.endpoints[{}].tunnel", service_name, idx);
665 validate_endpoint_tunnel_config(tunnel_config, &path)?;
666 }
667 }
668 }
669
670 Ok(())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::types::{ExposeType, Protocol};
677
678 #[test]
680 fn test_validate_version_valid() {
681 assert!(validate_version("v1").is_ok());
682 }
683
684 #[test]
685 fn test_validate_version_invalid_v2() {
686 let result = validate_version("v2");
687 assert!(result.is_err());
688 let err = result.unwrap_err();
689 assert!(matches!(
690 err.kind,
691 ValidationErrorKind::InvalidVersion { found } if found == "v2"
692 ));
693 }
694
695 #[test]
696 fn test_validate_version_empty() {
697 let result = validate_version("");
698 assert!(result.is_err());
699 let err = result.unwrap_err();
700 assert!(matches!(
701 err.kind,
702 ValidationErrorKind::InvalidVersion { found } if found.is_empty()
703 ));
704 }
705
706 #[test]
708 fn test_validate_deployment_name_valid() {
709 assert!(validate_deployment_name("my-app").is_ok());
710 assert!(validate_deployment_name("api").is_ok());
711 assert!(validate_deployment_name("my-service-123").is_ok());
712 assert!(validate_deployment_name("a1b").is_ok());
713 }
714
715 #[test]
716 fn test_validate_deployment_name_too_short() {
717 assert!(validate_deployment_name("ab").is_err());
718 assert!(validate_deployment_name("a").is_err());
719 assert!(validate_deployment_name("").is_err());
720 }
721
722 #[test]
723 fn test_validate_deployment_name_too_long() {
724 let long_name = "a".repeat(64);
725 assert!(validate_deployment_name(&long_name).is_err());
726 }
727
728 #[test]
729 fn test_validate_deployment_name_invalid_chars() {
730 assert!(validate_deployment_name("my_app").is_err()); assert!(validate_deployment_name("my.app").is_err()); assert!(validate_deployment_name("my app").is_err()); assert!(validate_deployment_name("my@app").is_err()); }
735
736 #[test]
737 fn test_validate_deployment_name_must_start_alphanumeric() {
738 assert!(validate_deployment_name("-myapp").is_err());
739 assert!(validate_deployment_name("_myapp").is_err());
740 }
741
742 #[test]
744 fn test_validate_image_name_valid() {
745 assert!(validate_image_name("nginx:latest").is_ok());
746 assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
747 assert!(validate_image_name("ubuntu").is_ok());
748 }
749
750 #[test]
751 fn test_validate_image_name_empty() {
752 let result = validate_image_name("");
753 assert!(result.is_err());
754 assert!(matches!(
755 result.unwrap_err().kind,
756 ValidationErrorKind::EmptyImageName
757 ));
758 }
759
760 #[test]
761 fn test_validate_image_name_whitespace_only() {
762 assert!(validate_image_name(" ").is_err());
763 assert!(validate_image_name("\t\n").is_err());
764 }
765
766 #[test]
768 fn test_validate_cpu_valid() {
769 assert!(validate_cpu(&0.5).is_ok());
770 assert!(validate_cpu(&1.0).is_ok());
771 assert!(validate_cpu(&2.0).is_ok());
772 assert!(validate_cpu(&0.001).is_ok());
773 }
774
775 #[test]
776 fn test_validate_cpu_zero() {
777 let result = validate_cpu(&0.0);
778 assert!(result.is_err());
779 assert!(matches!(
780 result.unwrap_err().kind,
781 ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
782 ));
783 }
784
785 #[test]
786 fn test_validate_cpu_negative() {
787 let result = validate_cpu(&-1.0);
788 assert!(result.is_err());
789 assert!(matches!(
790 result.unwrap_err().kind,
791 ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
792 ));
793 }
794
795 #[test]
797 fn test_validate_memory_format_valid() {
798 assert!(validate_memory_format("512Mi").is_ok());
799 assert!(validate_memory_format("1Gi").is_ok());
800 assert!(validate_memory_format("2Ti").is_ok());
801 assert!(validate_memory_format("256Ki").is_ok());
802 assert!(validate_memory_format("4096Mi").is_ok());
803 }
804
805 #[test]
806 fn test_validate_memory_format_invalid_suffix() {
807 assert!(validate_memory_format("512MB").is_err());
808 assert!(validate_memory_format("1GB").is_err());
809 assert!(validate_memory_format("512").is_err());
810 assert!(validate_memory_format("512m").is_err());
811 }
812
813 #[test]
814 fn test_validate_memory_format_no_number() {
815 assert!(validate_memory_format("Mi").is_err());
816 assert!(validate_memory_format("Gi").is_err());
817 }
818
819 #[test]
820 fn test_validate_memory_format_invalid_number() {
821 assert!(validate_memory_format("-512Mi").is_err());
822 assert!(validate_memory_format("0Mi").is_err());
823 assert!(validate_memory_format("abcMi").is_err());
824 }
825
826 #[test]
828 fn test_validate_port_valid() {
829 assert!(validate_port(&1).is_ok());
830 assert!(validate_port(&80).is_ok());
831 assert!(validate_port(&443).is_ok());
832 assert!(validate_port(&8080).is_ok());
833 assert!(validate_port(&65535).is_ok());
834 }
835
836 #[test]
837 fn test_validate_port_zero() {
838 let result = validate_port(&0);
839 assert!(result.is_err());
840 assert!(matches!(
841 result.unwrap_err().kind,
842 ValidationErrorKind::InvalidPort { port } if port == 0
843 ));
844 }
845
846 #[test]
851 fn test_validate_unique_endpoints_valid() {
852 let endpoints = vec![
853 EndpointSpec {
854 name: "http".to_string(),
855 protocol: Protocol::Http,
856 port: 8080,
857 target_port: None,
858 path: None,
859 expose: ExposeType::Public,
860 stream: None,
861 tunnel: None,
862 },
863 EndpointSpec {
864 name: "grpc".to_string(),
865 protocol: Protocol::Tcp,
866 port: 9090,
867 target_port: None,
868 path: None,
869 expose: ExposeType::Internal,
870 stream: None,
871 tunnel: None,
872 },
873 ];
874 assert!(validate_unique_endpoints(&endpoints).is_ok());
875 }
876
877 #[test]
878 fn test_validate_unique_endpoints_empty() {
879 let endpoints: Vec<EndpointSpec> = vec![];
880 assert!(validate_unique_endpoints(&endpoints).is_ok());
881 }
882
883 #[test]
884 fn test_validate_unique_endpoints_duplicates() {
885 let endpoints = vec![
886 EndpointSpec {
887 name: "http".to_string(),
888 protocol: Protocol::Http,
889 port: 8080,
890 target_port: None,
891 path: None,
892 expose: ExposeType::Public,
893 stream: None,
894 tunnel: None,
895 },
896 EndpointSpec {
897 name: "http".to_string(), protocol: Protocol::Https,
899 port: 8443,
900 target_port: None,
901 path: None,
902 expose: ExposeType::Public,
903 stream: None,
904 tunnel: None,
905 },
906 ];
907 let result = validate_unique_endpoints(&endpoints);
908 assert!(result.is_err());
909 assert!(matches!(
910 result.unwrap_err().kind,
911 ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
912 ));
913 }
914
915 #[test]
917 fn test_validate_scale_range_valid() {
918 assert!(validate_scale_range(1, 10).is_ok());
919 assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
921 assert!(validate_scale_range(5, 100).is_ok());
922 }
923
924 #[test]
925 fn test_validate_scale_range_min_greater_than_max() {
926 let result = validate_scale_range(10, 5);
927 assert!(result.is_err());
928 let err = result.unwrap_err();
929 assert!(matches!(
930 err.kind,
931 ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
932 ));
933 }
934
935 #[test]
936 fn test_validate_scale_range_large_gap() {
937 assert!(validate_scale_range(1, 1000).is_ok());
939 }
940
941 #[test]
944 fn test_validate_schedule_wrapper_valid() {
945 assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
951 }
953
954 #[test]
955 fn test_validate_schedule_wrapper_invalid() {
956 assert!(validate_schedule_wrapper(&"".to_string()).is_err()); assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
961 }
963
964 #[test]
966 fn test_validate_secret_reference_plain_values() {
967 assert!(validate_secret_reference("my-value").is_ok());
969 assert!(validate_secret_reference("").is_ok());
970 assert!(validate_secret_reference("some string").is_ok());
971 assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
973
974 #[test]
975 fn test_validate_secret_reference_valid() {
976 assert!(validate_secret_reference("$S:my-secret").is_ok());
978 assert!(validate_secret_reference("$S:api_key").is_ok());
979 assert!(validate_secret_reference("$S:MySecret123").is_ok());
980 assert!(validate_secret_reference("$S:a").is_ok()); }
982
983 #[test]
984 fn test_validate_secret_reference_cross_service() {
985 assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
987 assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
988 assert!(validate_secret_reference("$S:@svc/secret").is_ok());
989 }
990
991 #[test]
992 fn test_validate_secret_reference_empty_after_prefix() {
993 assert!(validate_secret_reference("$S:").is_err());
995 }
996
997 #[test]
998 fn test_validate_secret_reference_must_start_with_letter() {
999 assert!(validate_secret_reference("$S:123-secret").is_err());
1001 assert!(validate_secret_reference("$S:-my-secret").is_err());
1002 assert!(validate_secret_reference("$S:_underscore").is_err());
1003 }
1004
1005 #[test]
1006 fn test_validate_secret_reference_invalid_chars() {
1007 assert!(validate_secret_reference("$S:my.secret").is_err());
1009 assert!(validate_secret_reference("$S:my secret").is_err());
1010 assert!(validate_secret_reference("$S:my@secret").is_err());
1011 }
1012
1013 #[test]
1014 fn test_validate_secret_reference_cross_service_invalid() {
1015 assert!(validate_secret_reference("$S:@service").is_err());
1017 assert!(validate_secret_reference("$S:@/secret").is_err());
1019 assert!(validate_secret_reference("$S:@service/").is_err());
1021 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1023 }
1024
1025 #[test]
1030 fn test_validate_tunnel_ttl_valid() {
1031 assert!(validate_tunnel_ttl("30m").is_ok());
1032 assert!(validate_tunnel_ttl("4h").is_ok());
1033 assert!(validate_tunnel_ttl("1d").is_ok());
1034 assert!(validate_tunnel_ttl("1h 30m").is_ok());
1035 assert!(validate_tunnel_ttl("2h30m").is_ok());
1036 }
1037
1038 #[test]
1039 fn test_validate_tunnel_ttl_invalid() {
1040 assert!(validate_tunnel_ttl("").is_err());
1041 assert!(validate_tunnel_ttl("invalid").is_err());
1042 assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
1045
1046 #[test]
1047 fn test_validate_tunnel_definition_valid() {
1048 let tunnel = TunnelDefinition {
1049 from: "node-a".to_string(),
1050 to: "node-b".to_string(),
1051 local_port: 8080,
1052 remote_port: 9000,
1053 protocol: crate::types::TunnelProtocol::Tcp,
1054 expose: ExposeType::Internal,
1055 };
1056 assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1057 }
1058
1059 #[test]
1060 fn test_validate_tunnel_definition_local_port_zero() {
1061 let tunnel = TunnelDefinition {
1062 from: "node-a".to_string(),
1063 to: "node-b".to_string(),
1064 local_port: 0,
1065 remote_port: 9000,
1066 protocol: crate::types::TunnelProtocol::Tcp,
1067 expose: ExposeType::Internal,
1068 };
1069 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1070 assert!(result.is_err());
1071 assert!(matches!(
1072 result.unwrap_err().kind,
1073 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1074 ));
1075 }
1076
1077 #[test]
1078 fn test_validate_tunnel_definition_remote_port_zero() {
1079 let tunnel = TunnelDefinition {
1080 from: "node-a".to_string(),
1081 to: "node-b".to_string(),
1082 local_port: 8080,
1083 remote_port: 0,
1084 protocol: crate::types::TunnelProtocol::Tcp,
1085 expose: ExposeType::Internal,
1086 };
1087 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1088 assert!(result.is_err());
1089 assert!(matches!(
1090 result.unwrap_err().kind,
1091 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1092 ));
1093 }
1094
1095 #[test]
1096 fn test_validate_endpoint_tunnel_config_valid() {
1097 let config = EndpointTunnelConfig {
1098 enabled: true,
1099 from: Some("node-1".to_string()),
1100 to: Some("ingress".to_string()),
1101 remote_port: 8080,
1102 expose: Some(ExposeType::Public),
1103 access: None,
1104 };
1105 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1106 }
1107
1108 #[test]
1109 fn test_validate_endpoint_tunnel_config_with_access() {
1110 let config = EndpointTunnelConfig {
1111 enabled: true,
1112 from: None,
1113 to: None,
1114 remote_port: 0, expose: None,
1116 access: Some(TunnelAccessConfig {
1117 enabled: true,
1118 max_ttl: Some("4h".to_string()),
1119 audit: true,
1120 }),
1121 };
1122 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1123 }
1124
1125 #[test]
1126 fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1127 let config = EndpointTunnelConfig {
1128 enabled: true,
1129 from: None,
1130 to: None,
1131 remote_port: 0,
1132 expose: None,
1133 access: Some(TunnelAccessConfig {
1134 enabled: true,
1135 max_ttl: Some("invalid".to_string()),
1136 audit: false,
1137 }),
1138 };
1139 let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1140 assert!(result.is_err());
1141 assert!(matches!(
1142 result.unwrap_err().kind,
1143 ValidationErrorKind::InvalidTunnelTtl { .. }
1144 ));
1145 }
1146}