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> {
35 if version == "v1" {
36 Ok(())
37 } else {
38 Err(make_validation_error(
39 "invalid_version",
40 format!("version must be 'v1', found '{version}'"),
41 ))
42 }
43}
44
45pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
51 if name.len() < 3 || name.len() > 63 {
53 return Err(make_validation_error(
54 "invalid_deployment_name",
55 "deployment name must be 3-63 characters",
56 ));
57 }
58
59 if let Some(first) = name.chars().next() {
61 if !first.is_ascii_alphanumeric() {
62 return Err(make_validation_error(
63 "invalid_deployment_name",
64 "deployment name must start with alphanumeric character",
65 ));
66 }
67 }
68
69 for c in name.chars() {
71 if !c.is_ascii_alphanumeric() && c != '-' {
72 return Err(make_validation_error(
73 "invalid_deployment_name",
74 "deployment name can only contain alphanumeric characters and hyphens",
75 ));
76 }
77 }
78
79 Ok(())
80}
81
82pub fn validate_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
88 if name.is_empty() || name.trim().is_empty() {
89 Err(make_validation_error(
90 "empty_image_name",
91 "image name cannot be empty",
92 ))
93 } else {
94 Ok(())
95 }
96}
97
98pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
105 if cpu <= 0.0 {
106 Err(make_validation_error(
107 "invalid_cpu",
108 format!("CPU limit must be > 0, found {cpu}"),
109 ))
110 } else {
111 Ok(())
112 }
113}
114
115pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
122 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
123
124 let suffix_match = VALID_SUFFIXES
125 .iter()
126 .find(|&&suffix| value.ends_with(suffix));
127
128 match suffix_match {
129 Some(suffix) => {
130 let numeric_part = &value[..value.len() - suffix.len()];
131 match numeric_part.parse::<u64>() {
132 Ok(n) if n > 0 => Ok(()),
133 _ => Err(make_validation_error(
134 "invalid_memory_format",
135 format!("invalid memory format: '{value}'"),
136 )),
137 }
138 }
139 None => Err(make_validation_error(
140 "invalid_memory_format",
141 format!("invalid memory format: '{value}' (use Ki, Mi, Gi, or Ti suffix)"),
142 )),
143 }
144}
145
146pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
153 if port >= 1 {
154 Ok(())
155 } else {
156 Err(make_validation_error(
157 "invalid_port",
158 "port must be between 1-65535",
159 ))
160 }
161}
162
163pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
169 if let ScaleSpec::Adaptive { min, max, .. } = scale {
170 if *min > *max {
171 return Err(make_validation_error(
172 "invalid_scale_range",
173 format!("scale min ({min}) cannot be greater than max ({max})"),
174 ));
175 }
176 }
177 Ok(())
178}
179
180pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
187 Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
188 make_validation_error(
189 "invalid_cron_schedule",
190 format!("invalid cron schedule '{schedule}': {e}"),
191 )
192 })
193}
194
195pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
215 if !value.starts_with("$S:") {
217 return Ok(());
218 }
219
220 let secret_ref = &value[3..]; if secret_ref.is_empty() {
223 return Err(make_validation_error(
224 "invalid_secret_reference",
225 "secret reference cannot be empty after $S:",
226 ));
227 }
228
229 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
231 let parts: Vec<&str> = rest.splitn(2, '/').collect();
233 if parts.len() != 2 {
234 return Err(make_validation_error(
235 "invalid_secret_reference",
236 format!(
237 "cross-service secret reference '{value}' must have format @service/secret-name"
238 ),
239 ));
240 }
241
242 let service_name = parts[0];
243 let secret_name = parts[1];
244
245 if service_name.is_empty() {
247 return Err(make_validation_error(
248 "invalid_secret_reference",
249 format!("service name in secret reference '{value}' cannot be empty"),
250 ));
251 }
252
253 if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
254 return Err(make_validation_error(
255 "invalid_secret_reference",
256 format!("service name in secret reference '{value}' must start with a letter"),
257 ));
258 }
259
260 for c in service_name.chars() {
261 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
262 return Err(make_validation_error(
263 "invalid_secret_reference",
264 format!(
265 "service name in secret reference '{value}' contains invalid character '{c}'"
266 ),
267 ));
268 }
269 }
270
271 secret_name
272 } else {
273 secret_ref
274 };
275
276 if secret_name.is_empty() {
278 return Err(make_validation_error(
279 "invalid_secret_reference",
280 format!("secret name in '{value}' cannot be empty"),
281 ));
282 }
283
284 let first_char = secret_name.chars().next().unwrap();
286 if !first_char.is_ascii_alphabetic() {
287 return Err(make_validation_error(
288 "invalid_secret_reference",
289 format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
290 ));
291 }
292
293 for c in secret_name.chars() {
295 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
296 return Err(make_validation_error(
297 "invalid_secret_reference",
298 format!(
299 "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
300 ),
301 ));
302 }
303 }
304
305 Ok(())
306}
307
308#[allow(clippy::implicit_hasher)]
314pub fn validate_env_vars(
315 service_name: &str,
316 env: &std::collections::HashMap<String, String>,
317) -> Result<(), crate::error::ValidationError> {
318 for (key, value) in env {
319 if let Err(e) = validate_secret_reference(value) {
320 return Err(crate::error::ValidationError {
321 kind: crate::error::ValidationErrorKind::InvalidEnvVar {
322 key: key.clone(),
323 reason: e
324 .message
325 .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
326 },
327 path: format!("services.{service_name}.env.{key}"),
328 });
329 }
330 }
331 Ok(())
332}
333
334pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
344 let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
346 if !re.is_match(name) || name.len() > 63 {
347 return Err(make_validation_error(
348 "invalid_storage_name",
349 format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
350 ));
351 }
352 Ok(())
353}
354
355pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
361 validate_storage_name(name)
362}
363
364pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
374 let service_names: HashSet<&str> = spec
375 .services
376 .keys()
377 .map(std::string::String::as_str)
378 .collect();
379
380 for (service_name, service_spec) in &spec.services {
381 for dep in &service_spec.depends {
382 if !service_names.contains(dep.service.as_str()) {
383 return Err(ValidationError {
384 kind: ValidationErrorKind::UnknownDependency {
385 service: dep.service.clone(),
386 },
387 path: format!("services.{service_name}.depends"),
388 });
389 }
390 }
391 }
392
393 Ok(())
394}
395
396pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
402 for (service_name, service_spec) in &spec.services {
403 let mut seen = HashSet::new();
404 for endpoint in &service_spec.endpoints {
405 if !seen.insert(&endpoint.name) {
406 return Err(ValidationError {
407 kind: ValidationErrorKind::DuplicateEndpoint {
408 name: endpoint.name.clone(),
409 },
410 path: format!("services.{service_name}.endpoints"),
411 });
412 }
413 }
414 }
415
416 Ok(())
417}
418
419pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
425 for (service_name, service_spec) in &spec.services {
426 validate_service_schedule(service_name, service_spec)?;
427 }
428 Ok(())
429}
430
431pub fn validate_service_schedule(
437 service_name: &str,
438 spec: &ServiceSpec,
439) -> Result<(), ValidationError> {
440 if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
442 return Err(ValidationError {
443 kind: ValidationErrorKind::ScheduleOnlyForCron,
444 path: format!("services.{service_name}.schedule"),
445 });
446 }
447
448 if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
450 return Err(ValidationError {
451 kind: ValidationErrorKind::CronRequiresSchedule,
452 path: format!("services.{service_name}.schedule"),
453 });
454 }
455
456 Ok(())
457}
458
459pub fn validate_version(version: &str) -> Result<(), ValidationError> {
469 if version == "v1" {
470 Ok(())
471 } else {
472 Err(ValidationError {
473 kind: ValidationErrorKind::InvalidVersion {
474 found: version.to_string(),
475 },
476 path: "version".to_string(),
477 })
478 }
479}
480
481pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
492 if name.len() < 3 || name.len() > 63 {
494 return Err(ValidationError {
495 kind: ValidationErrorKind::EmptyDeploymentName,
496 path: "deployment".to_string(),
497 });
498 }
499
500 if let Some(first) = name.chars().next() {
502 if !first.is_ascii_alphanumeric() {
503 return Err(ValidationError {
504 kind: ValidationErrorKind::EmptyDeploymentName,
505 path: "deployment".to_string(),
506 });
507 }
508 }
509
510 for c in name.chars() {
512 if !c.is_ascii_alphanumeric() && c != '-' {
513 return Err(ValidationError {
514 kind: ValidationErrorKind::EmptyDeploymentName,
515 path: "deployment".to_string(),
516 });
517 }
518 }
519
520 Ok(())
521}
522
523pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
533 if name.is_empty() || name.trim().is_empty() {
534 Err(ValidationError {
535 kind: ValidationErrorKind::EmptyImageName,
536 path: "image.name".to_string(),
537 })
538 } else {
539 Ok(())
540 }
541}
542
543pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
552 if *cpu > 0.0 {
553 Ok(())
554 } else {
555 Err(ValidationError {
556 kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
557 path: "resources.cpu".to_string(),
558 })
559 }
560}
561
562pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
571 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
573
574 let suffix_match = VALID_SUFFIXES
576 .iter()
577 .find(|&&suffix| value.ends_with(suffix));
578
579 match suffix_match {
580 Some(suffix) => {
581 let numeric_part = &value[..value.len() - suffix.len()];
583
584 match numeric_part.parse::<u64>() {
586 Ok(n) if n > 0 => Ok(()),
587 _ => Err(ValidationError {
588 kind: ValidationErrorKind::InvalidMemoryFormat {
589 value: value.to_string(),
590 },
591 path: "resources.memory".to_string(),
592 }),
593 }
594 }
595 None => Err(ValidationError {
596 kind: ValidationErrorKind::InvalidMemoryFormat {
597 value: value.to_string(),
598 },
599 path: "resources.memory".to_string(),
600 }),
601 }
602}
603
604pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
613 if *port >= 1 {
614 Ok(())
615 } else {
616 Err(ValidationError {
617 kind: ValidationErrorKind::InvalidPort {
618 port: u32::from(*port),
619 },
620 path: "endpoints[].port".to_string(),
621 })
622 }
623}
624
625pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
631 let mut seen = HashSet::new();
632
633 for endpoint in endpoints {
634 if !seen.insert(&endpoint.name) {
635 return Err(ValidationError {
636 kind: ValidationErrorKind::DuplicateEndpoint {
637 name: endpoint.name.clone(),
638 },
639 path: "endpoints".to_string(),
640 });
641 }
642 }
643
644 Ok(())
645}
646
647pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
656 if min <= max {
657 Ok(())
658 } else {
659 Err(ValidationError {
660 kind: ValidationErrorKind::InvalidScaleRange { min, max },
661 path: "scale".to_string(),
662 })
663 }
664}
665
666pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
676 humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
677 make_validation_error(
678 "invalid_tunnel_ttl",
679 format!("invalid TTL format '{ttl}': {e}"),
680 )
681 })
682}
683
684pub fn validate_tunnel_access_config(
690 config: &TunnelAccessConfig,
691 path: &str,
692) -> Result<(), ValidationError> {
693 if let Some(ref max_ttl) = config.max_ttl {
694 validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
695 kind: ValidationErrorKind::InvalidTunnelTtl {
696 value: max_ttl.clone(),
697 reason: e
698 .message
699 .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
700 },
701 path: format!("{path}.access.max_ttl"),
702 })?;
703 }
704 Ok(())
705}
706
707pub fn validate_endpoint_tunnel_config(
713 config: &EndpointTunnelConfig,
714 path: &str,
715) -> Result<(), ValidationError> {
716 if let Some(ref access) = config.access {
721 validate_tunnel_access_config(access, path)?;
722 }
723
724 Ok(())
725}
726
727pub fn validate_tunnel_definition(
733 name: &str,
734 tunnel: &TunnelDefinition,
735) -> Result<(), ValidationError> {
736 let path = format!("tunnels.{name}");
737
738 if tunnel.local_port == 0 {
740 return Err(ValidationError {
741 kind: ValidationErrorKind::InvalidTunnelPort {
742 port: tunnel.local_port,
743 field: "local_port".to_string(),
744 },
745 path: format!("{path}.local_port"),
746 });
747 }
748
749 if tunnel.remote_port == 0 {
751 return Err(ValidationError {
752 kind: ValidationErrorKind::InvalidTunnelPort {
753 port: tunnel.remote_port,
754 field: "remote_port".to_string(),
755 },
756 path: format!("{path}.remote_port"),
757 });
758 }
759
760 Ok(())
761}
762
763pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
769 for (name, tunnel) in &spec.tunnels {
771 validate_tunnel_definition(name, tunnel)?;
772 }
773
774 for (service_name, service_spec) in &spec.services {
776 for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
777 if let Some(ref tunnel_config) = endpoint.tunnel {
778 let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
779 validate_endpoint_tunnel_config(tunnel_config, &path)?;
780 }
781 }
782 }
783
784 Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use crate::types::{ExposeType, Protocol};
791
792 #[test]
794 fn test_validate_version_valid() {
795 assert!(validate_version("v1").is_ok());
796 }
797
798 #[test]
799 fn test_validate_version_invalid_v2() {
800 let result = validate_version("v2");
801 assert!(result.is_err());
802 let err = result.unwrap_err();
803 assert!(matches!(
804 err.kind,
805 ValidationErrorKind::InvalidVersion { found } if found == "v2"
806 ));
807 }
808
809 #[test]
810 fn test_validate_version_empty() {
811 let result = validate_version("");
812 assert!(result.is_err());
813 let err = result.unwrap_err();
814 assert!(matches!(
815 err.kind,
816 ValidationErrorKind::InvalidVersion { found } if found.is_empty()
817 ));
818 }
819
820 #[test]
822 fn test_validate_deployment_name_valid() {
823 assert!(validate_deployment_name("my-app").is_ok());
824 assert!(validate_deployment_name("api").is_ok());
825 assert!(validate_deployment_name("my-service-123").is_ok());
826 assert!(validate_deployment_name("a1b").is_ok());
827 }
828
829 #[test]
830 fn test_validate_deployment_name_too_short() {
831 assert!(validate_deployment_name("ab").is_err());
832 assert!(validate_deployment_name("a").is_err());
833 assert!(validate_deployment_name("").is_err());
834 }
835
836 #[test]
837 fn test_validate_deployment_name_too_long() {
838 let long_name = "a".repeat(64);
839 assert!(validate_deployment_name(&long_name).is_err());
840 }
841
842 #[test]
843 fn test_validate_deployment_name_invalid_chars() {
844 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()); }
849
850 #[test]
851 fn test_validate_deployment_name_must_start_alphanumeric() {
852 assert!(validate_deployment_name("-myapp").is_err());
853 assert!(validate_deployment_name("_myapp").is_err());
854 }
855
856 #[test]
858 fn test_validate_image_name_valid() {
859 assert!(validate_image_name("nginx:latest").is_ok());
860 assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
861 assert!(validate_image_name("ubuntu").is_ok());
862 }
863
864 #[test]
865 fn test_validate_image_name_empty() {
866 let result = validate_image_name("");
867 assert!(result.is_err());
868 assert!(matches!(
869 result.unwrap_err().kind,
870 ValidationErrorKind::EmptyImageName
871 ));
872 }
873
874 #[test]
875 fn test_validate_image_name_whitespace_only() {
876 assert!(validate_image_name(" ").is_err());
877 assert!(validate_image_name("\t\n").is_err());
878 }
879
880 #[test]
882 fn test_validate_cpu_valid() {
883 assert!(validate_cpu(&0.5).is_ok());
884 assert!(validate_cpu(&1.0).is_ok());
885 assert!(validate_cpu(&2.0).is_ok());
886 assert!(validate_cpu(&0.001).is_ok());
887 }
888
889 #[test]
890 fn test_validate_cpu_zero() {
891 let result = validate_cpu(&0.0);
892 assert!(result.is_err());
893 assert!(matches!(
894 result.unwrap_err().kind,
895 ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
896 ));
897 }
898
899 #[test]
900 fn test_validate_cpu_negative() {
901 let result = validate_cpu(&-1.0);
902 assert!(result.is_err());
903 assert!(matches!(
904 result.unwrap_err().kind,
905 ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
906 ));
907 }
908
909 #[test]
911 fn test_validate_memory_format_valid() {
912 assert!(validate_memory_format("512Mi").is_ok());
913 assert!(validate_memory_format("1Gi").is_ok());
914 assert!(validate_memory_format("2Ti").is_ok());
915 assert!(validate_memory_format("256Ki").is_ok());
916 assert!(validate_memory_format("4096Mi").is_ok());
917 }
918
919 #[test]
920 fn test_validate_memory_format_invalid_suffix() {
921 assert!(validate_memory_format("512MB").is_err());
922 assert!(validate_memory_format("1GB").is_err());
923 assert!(validate_memory_format("512").is_err());
924 assert!(validate_memory_format("512m").is_err());
925 }
926
927 #[test]
928 fn test_validate_memory_format_no_number() {
929 assert!(validate_memory_format("Mi").is_err());
930 assert!(validate_memory_format("Gi").is_err());
931 }
932
933 #[test]
934 fn test_validate_memory_format_invalid_number() {
935 assert!(validate_memory_format("-512Mi").is_err());
936 assert!(validate_memory_format("0Mi").is_err());
937 assert!(validate_memory_format("abcMi").is_err());
938 }
939
940 #[test]
942 fn test_validate_port_valid() {
943 assert!(validate_port(&1).is_ok());
944 assert!(validate_port(&80).is_ok());
945 assert!(validate_port(&443).is_ok());
946 assert!(validate_port(&8080).is_ok());
947 assert!(validate_port(&65535).is_ok());
948 }
949
950 #[test]
951 fn test_validate_port_zero() {
952 let result = validate_port(&0);
953 assert!(result.is_err());
954 assert!(matches!(
955 result.unwrap_err().kind,
956 ValidationErrorKind::InvalidPort { port } if port == 0
957 ));
958 }
959
960 #[test]
965 fn test_validate_unique_endpoints_valid() {
966 let endpoints = vec![
967 EndpointSpec {
968 name: "http".to_string(),
969 protocol: Protocol::Http,
970 port: 8080,
971 target_port: None,
972 path: None,
973 expose: ExposeType::Public,
974 stream: None,
975 tunnel: None,
976 },
977 EndpointSpec {
978 name: "grpc".to_string(),
979 protocol: Protocol::Tcp,
980 port: 9090,
981 target_port: None,
982 path: None,
983 expose: ExposeType::Internal,
984 stream: None,
985 tunnel: None,
986 },
987 ];
988 assert!(validate_unique_endpoints(&endpoints).is_ok());
989 }
990
991 #[test]
992 fn test_validate_unique_endpoints_empty() {
993 let endpoints: Vec<EndpointSpec> = vec![];
994 assert!(validate_unique_endpoints(&endpoints).is_ok());
995 }
996
997 #[test]
998 fn test_validate_unique_endpoints_duplicates() {
999 let endpoints = vec![
1000 EndpointSpec {
1001 name: "http".to_string(),
1002 protocol: Protocol::Http,
1003 port: 8080,
1004 target_port: None,
1005 path: None,
1006 expose: ExposeType::Public,
1007 stream: None,
1008 tunnel: None,
1009 },
1010 EndpointSpec {
1011 name: "http".to_string(), protocol: Protocol::Https,
1013 port: 8443,
1014 target_port: None,
1015 path: None,
1016 expose: ExposeType::Public,
1017 stream: None,
1018 tunnel: None,
1019 },
1020 ];
1021 let result = validate_unique_endpoints(&endpoints);
1022 assert!(result.is_err());
1023 assert!(matches!(
1024 result.unwrap_err().kind,
1025 ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1026 ));
1027 }
1028
1029 #[test]
1031 fn test_validate_scale_range_valid() {
1032 assert!(validate_scale_range(1, 10).is_ok());
1033 assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
1035 assert!(validate_scale_range(5, 100).is_ok());
1036 }
1037
1038 #[test]
1039 fn test_validate_scale_range_min_greater_than_max() {
1040 let result = validate_scale_range(10, 5);
1041 assert!(result.is_err());
1042 let err = result.unwrap_err();
1043 assert!(matches!(
1044 err.kind,
1045 ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1046 ));
1047 }
1048
1049 #[test]
1050 fn test_validate_scale_range_large_gap() {
1051 assert!(validate_scale_range(1, 1000).is_ok());
1053 }
1054
1055 #[test]
1058 fn test_validate_schedule_wrapper_valid() {
1059 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());
1065 }
1067
1068 #[test]
1069 fn test_validate_schedule_wrapper_invalid() {
1070 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());
1075 }
1077
1078 #[test]
1080 fn test_validate_secret_reference_plain_values() {
1081 assert!(validate_secret_reference("my-value").is_ok());
1083 assert!(validate_secret_reference("").is_ok());
1084 assert!(validate_secret_reference("some string").is_ok());
1085 assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
1087
1088 #[test]
1089 fn test_validate_secret_reference_valid() {
1090 assert!(validate_secret_reference("$S:my-secret").is_ok());
1092 assert!(validate_secret_reference("$S:api_key").is_ok());
1093 assert!(validate_secret_reference("$S:MySecret123").is_ok());
1094 assert!(validate_secret_reference("$S:a").is_ok()); }
1096
1097 #[test]
1098 fn test_validate_secret_reference_cross_service() {
1099 assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1101 assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1102 assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1103 }
1104
1105 #[test]
1106 fn test_validate_secret_reference_empty_after_prefix() {
1107 assert!(validate_secret_reference("$S:").is_err());
1109 }
1110
1111 #[test]
1112 fn test_validate_secret_reference_must_start_with_letter() {
1113 assert!(validate_secret_reference("$S:123-secret").is_err());
1115 assert!(validate_secret_reference("$S:-my-secret").is_err());
1116 assert!(validate_secret_reference("$S:_underscore").is_err());
1117 }
1118
1119 #[test]
1120 fn test_validate_secret_reference_invalid_chars() {
1121 assert!(validate_secret_reference("$S:my.secret").is_err());
1123 assert!(validate_secret_reference("$S:my secret").is_err());
1124 assert!(validate_secret_reference("$S:my@secret").is_err());
1125 }
1126
1127 #[test]
1128 fn test_validate_secret_reference_cross_service_invalid() {
1129 assert!(validate_secret_reference("$S:@service").is_err());
1131 assert!(validate_secret_reference("$S:@/secret").is_err());
1133 assert!(validate_secret_reference("$S:@service/").is_err());
1135 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1137 }
1138
1139 #[test]
1144 fn test_validate_tunnel_ttl_valid() {
1145 assert!(validate_tunnel_ttl("30m").is_ok());
1146 assert!(validate_tunnel_ttl("4h").is_ok());
1147 assert!(validate_tunnel_ttl("1d").is_ok());
1148 assert!(validate_tunnel_ttl("1h 30m").is_ok());
1149 assert!(validate_tunnel_ttl("2h30m").is_ok());
1150 }
1151
1152 #[test]
1153 fn test_validate_tunnel_ttl_invalid() {
1154 assert!(validate_tunnel_ttl("").is_err());
1155 assert!(validate_tunnel_ttl("invalid").is_err());
1156 assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
1159
1160 #[test]
1161 fn test_validate_tunnel_definition_valid() {
1162 let tunnel = TunnelDefinition {
1163 from: "node-a".to_string(),
1164 to: "node-b".to_string(),
1165 local_port: 8080,
1166 remote_port: 9000,
1167 protocol: crate::types::TunnelProtocol::Tcp,
1168 expose: ExposeType::Internal,
1169 };
1170 assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1171 }
1172
1173 #[test]
1174 fn test_validate_tunnel_definition_local_port_zero() {
1175 let tunnel = TunnelDefinition {
1176 from: "node-a".to_string(),
1177 to: "node-b".to_string(),
1178 local_port: 0,
1179 remote_port: 9000,
1180 protocol: crate::types::TunnelProtocol::Tcp,
1181 expose: ExposeType::Internal,
1182 };
1183 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1184 assert!(result.is_err());
1185 assert!(matches!(
1186 result.unwrap_err().kind,
1187 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1188 ));
1189 }
1190
1191 #[test]
1192 fn test_validate_tunnel_definition_remote_port_zero() {
1193 let tunnel = TunnelDefinition {
1194 from: "node-a".to_string(),
1195 to: "node-b".to_string(),
1196 local_port: 8080,
1197 remote_port: 0,
1198 protocol: crate::types::TunnelProtocol::Tcp,
1199 expose: ExposeType::Internal,
1200 };
1201 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1202 assert!(result.is_err());
1203 assert!(matches!(
1204 result.unwrap_err().kind,
1205 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1206 ));
1207 }
1208
1209 #[test]
1210 fn test_validate_endpoint_tunnel_config_valid() {
1211 let config = EndpointTunnelConfig {
1212 enabled: true,
1213 from: Some("node-1".to_string()),
1214 to: Some("ingress".to_string()),
1215 remote_port: 8080,
1216 expose: Some(ExposeType::Public),
1217 access: None,
1218 };
1219 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1220 }
1221
1222 #[test]
1223 fn test_validate_endpoint_tunnel_config_with_access() {
1224 let config = EndpointTunnelConfig {
1225 enabled: true,
1226 from: None,
1227 to: None,
1228 remote_port: 0, expose: None,
1230 access: Some(TunnelAccessConfig {
1231 enabled: true,
1232 max_ttl: Some("4h".to_string()),
1233 audit: true,
1234 }),
1235 };
1236 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1237 }
1238
1239 #[test]
1240 fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1241 let config = EndpointTunnelConfig {
1242 enabled: true,
1243 from: None,
1244 to: None,
1245 remote_port: 0,
1246 expose: None,
1247 access: Some(TunnelAccessConfig {
1248 enabled: true,
1249 max_ttl: Some("invalid".to_string()),
1250 audit: false,
1251 }),
1252 };
1253 let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1254 assert!(result.is_err());
1255 assert!(matches!(
1256 result.unwrap_err().kind,
1257 ValidationErrorKind::InvalidTunnelTtl { .. }
1258 ));
1259 }
1260}