@@ -395,3 +395,234 @@ func TestReferenceField(t *testing.T) {
395395 })
396396 }
397397}
398+
399+ func TestVolatileCriteriaMutualExclusivity (t * testing.T ) {
400+ tests := []struct {
401+ name string
402+ imageUrl string
403+ imageDigest string
404+ imageRef string
405+ componentNames []string
406+ wantValid bool
407+ }{
408+ // Valid cases - only one field set
409+ {"Only imageUrl" , "quay.io/org/repo" , "" , "" , nil , true },
410+ {"Only imageDigest" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "" , nil , true },
411+ {"Only imageRef" , "" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , nil , true },
412+ {"Only componentNames" , "" , "" , "" , []string {"component1" }, true },
413+ {"No fields set" , "" , "" , "" , nil , true },
414+
415+ // Invalid cases - multiple fields set
416+ {"imageUrl and imageDigest" , "quay.io/org/repo" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "" , nil , false },
417+ {"imageUrl and imageRef" , "quay.io/org/repo" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , nil , false },
418+ {"imageUrl and componentNames" , "quay.io/org/repo" , "" , "" , []string {"component1" }, false },
419+ {"imageDigest and imageRef" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "sha256:1f88f9fb4543eadf97afcbd417c258fdf1a02dd000a36e39e7e4649d1b083b4e" , nil , false },
420+ {"imageDigest and componentNames" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "" , []string {"component1" }, false },
421+ {"imageRef and componentNames" , "" , "" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , []string {"component1" }, false },
422+ {"Three fields set" , "quay.io/org/repo" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "" , []string {"component1" }, false },
423+ {"All fields set" , "quay.io/org/repo" , "sha256:cfe1335814d92eabecfe9802f13298539caa7bbd0a13b61f320dc45bdded473d" , "sha256:1f88f9fb4543eadf97afcbd417c258fdf1a02dd000a36e39e7e4649d1b083b4e" , []string {"component1" }, false },
424+ }
425+
426+ for _ , tt := range tests {
427+ t .Run (tt .name , func (t * testing.T ) {
428+ // Create a CRD validation schema
429+ crd := v1.CustomResourceDefinition {}
430+ bytes , err := os .ReadFile ("../../config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml" )
431+ if err != nil {
432+ t .Fatalf ("unexpected error reading CRD: %s" , err )
433+ }
434+ if err := yaml .Unmarshal (bytes , & crd ); err != nil {
435+ t .Fatalf ("unexpected error when decoding schema: %s" , err )
436+ }
437+
438+ crdv := apiextensions.CustomResourceValidation {}
439+ if err := v1 .Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation (crd .Spec .Versions [0 ].Schema , & crdv , nil ); err != nil {
440+ t .Fatalf ("failed in CRD validation conversion: %s" , err )
441+ }
442+
443+ s , err := schema .NewStructural (crdv .OpenAPIV3Schema )
444+ if err != nil {
445+ t .Fatalf ("unexpected error when creating structural: %s" , err )
446+ }
447+
448+ validator := validation .NewSchemaValidatorFromOpenAPI (s .ToKubeOpenAPI ())
449+
450+ // Build the volatile criteria map
451+ criteria := map [string ]interface {}{
452+ "value" : "test-rule" ,
453+ }
454+ if tt .imageUrl != "" {
455+ criteria ["imageUrl" ] = tt .imageUrl
456+ }
457+ if tt .imageDigest != "" {
458+ criteria ["imageDigest" ] = tt .imageDigest
459+ }
460+ if tt .imageRef != "" {
461+ criteria ["imageRef" ] = tt .imageRef
462+ }
463+ if tt .componentNames != nil {
464+ componentNamesInterface := make ([]interface {}, len (tt .componentNames ))
465+ for i , name := range tt .componentNames {
466+ componentNamesInterface [i ] = name
467+ }
468+ criteria ["componentNames" ] = componentNamesInterface
469+ }
470+
471+ // Convert policy to unstructured for validation
472+ obj := unstructured.Unstructured {}
473+ obj .SetUnstructuredContent (map [string ]interface {}{
474+ "apiVersion" : "appstudio.redhat.com/v1alpha1" ,
475+ "kind" : "EnterpriseContractPolicy" ,
476+ "metadata" : map [string ]interface {}{
477+ "name" : "test-policy" ,
478+ },
479+ "spec" : map [string ]interface {}{
480+ "sources" : []interface {}{
481+ map [string ]interface {}{
482+ "volatileConfig" : map [string ]interface {}{
483+ "exclude" : []interface {}{criteria },
484+ },
485+ },
486+ },
487+ },
488+ })
489+
490+ // Validate using custom resource strategy to include CEL validation
491+ gvk := rts.GroupVersionKind {Group : "appstudio.redhat.com" , Version : "v1alpha1" , Kind : "EnterpriseContractPolicy" }
492+ errs := customresource .NewStrategy (nil , false , gvk , validator , nil , s , nil , nil ).Validate (context .Background (), & obj )
493+
494+ isValid := len (errs ) == 0
495+ if isValid != tt .wantValid {
496+ t .Errorf ("Validation = %v, want %v. Errors: %v" , isValid , tt .wantValid , errs )
497+ }
498+ })
499+ }
500+ }
501+
502+ func TestComponentNamesField (t * testing.T ) {
503+ tests := []struct {
504+ name string
505+ componentNames []ComponentName
506+ wantValid bool
507+ omitField bool // true if the field should be omitted entirely
508+ }{
509+ // Valid cases
510+ {"Single component" , []ComponentName {"component1" }, true , false },
511+ {"Multiple components" , []ComponentName {"component1" , "component2" , "component3" }, true , false },
512+ {"Component with hyphens" , []ComponentName {"my-component" }, true , false },
513+ {"Component with numbers" , []ComponentName {"component123" }, true , false },
514+ {"Omitted field" , nil , true , true }, // Field is omitted entirely
515+
516+ // Valid edge case
517+ {"Empty array" , []ComponentName {}, true , false }, // Empty array is allowed
518+
519+ // Invalid cases
520+ {"Empty string" , []ComponentName {"" }, false , false }, // Violates MinLength=1
521+ }
522+
523+ for _ , tt := range tests {
524+ t .Run (tt .name , func (t * testing.T ) {
525+ // Create a policy with the test component names
526+ policy := EnterpriseContractPolicy {
527+ Spec : EnterpriseContractPolicySpec {
528+ Sources : []Source {
529+ {
530+ VolatileConfig : & VolatileSourceConfig {
531+ Exclude : []VolatileCriteria {
532+ {
533+ Value : "test-rule" ,
534+ },
535+ },
536+ },
537+ },
538+ },
539+ },
540+ }
541+ if ! tt .omitField {
542+ policy .Spec .Sources [0 ].VolatileConfig .Exclude [0 ].ComponentNames = tt .componentNames
543+ }
544+
545+ // Create a CRD validation schema
546+ crd := v1.CustomResourceDefinition {}
547+ bytes , err := os .ReadFile ("../../config/crd/bases/appstudio.redhat.com_enterprisecontractpolicies.yaml" )
548+ if err != nil {
549+ t .Fatalf ("unexpected error reading CRD: %s" , err )
550+ }
551+ if err := yaml .Unmarshal (bytes , & crd ); err != nil {
552+ t .Fatalf ("unexpected error when decoding schema: %s" , err )
553+ }
554+
555+ crdv := apiextensions.CustomResourceValidation {}
556+ if err := v1 .Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation (crd .Spec .Versions [0 ].Schema , & crdv , nil ); err != nil {
557+ t .Fatalf ("failed in CRD validation conversion: %s" , err )
558+ }
559+
560+ s , err := schema .NewStructural (crdv .OpenAPIV3Schema )
561+ if err != nil {
562+ t .Fatalf ("unexpected error when creating structural: %s" , err )
563+ }
564+
565+ v := validation .NewSchemaValidatorFromOpenAPI (s .ToKubeOpenAPI ())
566+
567+ // Convert policy to unstructured for validation
568+ obj := unstructured.Unstructured {}
569+ obj .SetUnstructuredContent (map [string ]interface {}{
570+ "apiVersion" : "appstudio.redhat.com/v1alpha1" ,
571+ "kind" : "EnterpriseContractPolicy" ,
572+ "spec" : map [string ]interface {}{
573+ "sources" : []interface {}{
574+ map [string ]interface {}{
575+ "volatileConfig" : map [string ]interface {}{
576+ "exclude" : []interface {}{
577+ func () map [string ]interface {} {
578+ m := map [string ]interface {}{
579+ "value" : "test-rule" ,
580+ }
581+ if ! tt .omitField {
582+ // Convert []string to []interface{}
583+ componentNamesInterface := make ([]interface {}, len (tt .componentNames ))
584+ for i , name := range tt .componentNames {
585+ componentNamesInterface [i ] = name
586+ }
587+ m ["componentNames" ] = componentNamesInterface
588+ }
589+ return m
590+ }(),
591+ },
592+ },
593+ },
594+ },
595+ },
596+ })
597+
598+ // Validate the object
599+ result := v .Validate (& obj )
600+ isValid := result .IsValid ()
601+
602+ if isValid != tt .wantValid {
603+ t .Errorf ("Validation for %v = %v, want %v. Errors: %v" , tt .componentNames , isValid , tt .wantValid , result .Errors )
604+ }
605+
606+ // Also validate the actual policy object
607+ // Note: Empty arrays with omitempty are omitted during JSON marshaling,
608+ // so they're treated as omitted fields and pass validation
609+ if tt .wantValid && ! tt .omitField {
610+ policyObj := unstructured.Unstructured {}
611+ policyBytes , err := json .Marshal (policy )
612+ if err != nil {
613+ t .Fatalf ("unexpected error marshaling policy: %s" , err )
614+ }
615+ if err := json .Unmarshal (policyBytes , & policyObj .Object ); err != nil {
616+ t .Fatalf ("unexpected error unmarshaling policy: %s" , err )
617+ }
618+
619+ policyResult := v .Validate (& policyObj )
620+ policyIsValid := policyResult .IsValid ()
621+
622+ if policyIsValid != tt .wantValid {
623+ t .Errorf ("Policy validation for %v = %v, want %v. Errors: %v" , tt .componentNames , policyIsValid , tt .wantValid , policyResult .Errors )
624+ }
625+ }
626+ })
627+ }
628+ }
0 commit comments