diff --git a/test/e2e/v2/cmd/create-guests/main.go b/test/e2e/v2/cmd/create-guests/main.go index 803d6b635a9b..6aef6bf9c3c2 100644 --- a/test/e2e/v2/cmd/create-guests/main.go +++ b/test/e2e/v2/cmd/create-guests/main.go @@ -66,6 +66,7 @@ const defaultNamespace = "clusters" // envConfig captures the common environment configuration. type envConfig struct { + testPlanPath string prowJobID string sharedDir string artifactDir string @@ -104,6 +105,7 @@ func loadEnvConfig() envConfig { } cfg := envConfig{ + testPlanPath: os.Getenv("TEST_PLAN"), prowJobID: mustGetenv("PROW_JOB_ID"), sharedDir: sharedDir, artifactDir: mustGetenv("ARTIFACT_DIR"), @@ -130,7 +132,17 @@ func loadEnvConfig() envConfig { } func run(ctx context.Context, cfg envConfig) error { - specs := cfg.platform.ClusterSpecs(cfg.releaseImage, cfg.n1Image) + plan, err := lifecycle.ResolveTestPlan(cfg.testPlanPath, cfg.platform) + if err != nil { + return fmt.Errorf("resolving test plan: %w", err) + } + log.Printf("Using test plan %q", plan.Name) + + allSpecs := cfg.platform.ClusterSpecs(cfg.releaseImage, cfg.n1Image) + if err := plan.Validate(allSpecs); err != nil { + return err + } + specs := plan.FilterClusterSpecs(allSpecs) // Phase 0: The manifest must exist before any infra is provisioned so // destroy-guests can always clean up, even if create-guests fails diff --git a/test/e2e/v2/cmd/run-tests/main.go b/test/e2e/v2/cmd/run-tests/main.go index f88099c216e1..5573549fe91e 100644 --- a/test/e2e/v2/cmd/run-tests/main.go +++ b/test/e2e/v2/cmd/run-tests/main.go @@ -42,7 +42,6 @@ func main() { sharedDir := requireEnv("SHARED_DIR") artifactDir := requireEnv("ARTIFACT_DIR") - releaseImage := os.Getenv("RELEASE_IMAGE_LATEST") eventuallyVerbose := os.Getenv("EVENTUALLY_VERBOSE") if eventuallyVerbose == "" { @@ -50,6 +49,10 @@ func main() { } os.Setenv("EVENTUALLY_VERBOSE", eventuallyVerbose) + if v := os.Getenv("RELEASE_IMAGE_LATEST"); v != "" { + os.Setenv("E2E_LATEST_RELEASE_IMAGE", v) + } + manifest, err := lifecycle.ReadManifest(sharedDir) if err != nil { log.Fatalf("Failed to read cluster manifest: %v", err) @@ -63,9 +66,18 @@ func main() { // Let the platform set up any env vars it needs for tests. platform.SetupTestEnv(sharedDir) - matrix := platform.TestMatrix(releaseImage) + testPlanPath := os.Getenv("TEST_PLAN") + plan, err := lifecycle.ResolveTestPlan(testPlanPath, platform) + if err != nil { + log.Fatalf("Failed to resolve test plan: %v", err) + } + log.Printf("Using test plan %q", plan.Name) + + if err := plan.TestMatrix.Validate(); err != nil { + log.Fatalf("Invalid test plan: %v", err) + } - clustersByVariant, err := matrix.ResolveVariants(manifest) + clustersByVariant, err := plan.TestMatrix.ResolveVariants(manifest) if err != nil { log.Fatalf("Manifest/test matrix mismatch: %v", err) } @@ -77,7 +89,7 @@ func main() { ) // Launch parallel test groups. - for _, g := range matrix.Parallel { + for _, g := range plan.TestMatrix.Parallel { g := g entry := clustersByVariant[g.Variant] wg.Add(1) @@ -85,7 +97,7 @@ func main() { defer wg.Done() log.Printf("Running %s tests against %s...", g.Name, entry.Name) err := runTestBinary(testBinary, entry.Name, entry.Namespace, g.LabelFilter, g.Skip, - filepath.Join(artifactDir, g.JUnitFile), g.ExtraEnv) + filepath.Join(artifactDir, g.JUnitFile())) mu.Lock() results = append(results, testResult{name: g.Name, err: err}) mu.Unlock() @@ -99,7 +111,7 @@ func main() { // Launch sequential groups (each group runs in its own goroutine, // but steps within a group run one after another). - for _, sg := range matrix.Sequential { + for _, sg := range plan.TestMatrix.Sequential { sg := sg wg.Add(1) go func() { @@ -108,7 +120,7 @@ func main() { entry := clustersByVariant[step.Variant] log.Printf("Running %s tests against %s...", step.Name, entry.Name) err := runTestBinary(testBinary, entry.Name, entry.Namespace, step.LabelFilter, step.Skip, - filepath.Join(artifactDir, step.JUnitFile), step.ExtraEnv) + filepath.Join(artifactDir, step.JUnitFile())) mu.Lock() results = append(results, testResult{name: step.Name, err: err}) mu.Unlock() @@ -143,7 +155,7 @@ func main() { log.Println("All test groups passed") } -func runTestBinary(testBinary, clusterName, namespace, labelFilter, skip, junitPath string, extraEnv []string) error { +func runTestBinary(testBinary, clusterName, namespace, labelFilter, skip, junitPath string) error { ginkgoTimeout := os.Getenv("GINKGO_TIMEOUT") if ginkgoTimeout == "" { ginkgoTimeout = defaultGinkgoTimeout @@ -167,7 +179,6 @@ func runTestBinary(testBinary, clusterName, namespace, labelFilter, skip, junitP fmt.Sprintf("E2E_HOSTED_CLUSTER_NAME=%s", clusterName), fmt.Sprintf("E2E_HOSTED_CLUSTER_NAMESPACE=%s", namespace), ) - cmd.Env = append(cmd.Env, extraEnv...) return cmd.Run() } diff --git a/test/e2e/v2/lifecycle/aws.go b/test/e2e/v2/lifecycle/aws.go index 756058d446d3..ba44d0e6115a 100644 --- a/test/e2e/v2/lifecycle/aws.go +++ b/test/e2e/v2/lifecycle/aws.go @@ -119,20 +119,26 @@ func (a *AWSPlatformConfig) PostVersionRollout(ctx context.Context, cl crclient. return nil } -func (a *AWSPlatformConfig) TestMatrix(releaseImage string) TestMatrix { +func (a *AWSPlatformConfig) DefaultTestPlan() TestPlan { + return TestPlan{ + Name: "aws-full", + Platform: "aws", + TestMatrix: a.TestMatrix(), + } +} + +func (a *AWSPlatformConfig) TestMatrix() TestMatrix { return TestMatrix{ Parallel: []TestGroup{ { Name: "public", Variant: "public", LabelFilter: "!lifecycle || hosted-cluster-aws", - JUnitFile: "junit_public.xml", }, { Name: "karpenter", Variant: "karpenter", LabelFilter: "karpenter", - JUnitFile: "junit_karpenter.xml", }, }, } diff --git a/test/e2e/v2/lifecycle/azure.go b/test/e2e/v2/lifecycle/azure.go index 4c8ac18ded01..0d356f47669b 100644 --- a/test/e2e/v2/lifecycle/azure.go +++ b/test/e2e/v2/lifecycle/azure.go @@ -321,7 +321,15 @@ func (a *AzurePlatformConfig) postCreateExternalOIDC(ctx context.Context, cl crc return nil } -func (a *AzurePlatformConfig) TestMatrix(releaseImage string) TestMatrix { +func (a *AzurePlatformConfig) DefaultTestPlan() TestPlan { + return TestPlan{ + Name: "azure-full", + Platform: "azure", + TestMatrix: a.TestMatrix(), + } +} + +func (a *AzurePlatformConfig) TestMatrix() TestMatrix { return TestMatrix{ Parallel: []TestGroup{ { @@ -329,31 +337,26 @@ func (a *AzurePlatformConfig) TestMatrix(releaseImage string) TestMatrix { Variant: "public", LabelFilter: "self-managed-azure-public || nodepool-lifecycle || secret-encryption || control-plane-workloads || hosted-cluster-security || nodepool-osimagestream", Skip: "KAS allowed CIDRs", - JUnitFile: "junit_self_managed_azure_public.xml", }, { Name: "private", Variant: "private", LabelFilter: "self-managed-azure-private || hosted-cluster-compliance", - JUnitFile: "junit_self_managed_azure_private.xml", }, { Name: "oauth-lb", Variant: "oauth-lb", LabelFilter: "self-managed-azure-oauth-lb || hosted-cluster-health || hosted-cluster-metrics || hosted-cluster-image-registry", - JUnitFile: "junit_self_managed_azure_oauth_lb.xml", }, { Name: "autoscaling", Variant: "autoscaling", LabelFilter: "nodepool-autoscaling", - JUnitFile: "junit_self_managed_azure_nodepool_autoscaling.xml", }, { Name: "external-oidc", Variant: "external-oidc", LabelFilter: "external-oidc || global-pull-secret", - JUnitFile: "junit_self_managed_azure_external_oidc.xml", }, }, Sequential: []SequentialGroup{ @@ -364,20 +367,16 @@ func (a *AzurePlatformConfig) TestMatrix(releaseImage string) TestMatrix { Name: "upgrade", Variant: "upgrade", LabelFilter: "control-plane-upgrade", - JUnitFile: "junit_lifecycle_upgrade.xml", - ExtraEnv: []string{fmt.Sprintf("E2E_LATEST_RELEASE_IMAGE=%s", releaseImage)}, }, { Name: "control-plane-tls", Variant: "upgrade", LabelFilter: "control-plane-pki-operator", - JUnitFile: "junit_control_plane_tls.xml", }, { Name: "etcd-chaos", Variant: "upgrade", LabelFilter: "etcd-chaos", - JUnitFile: "junit_lifecycle_etcd_chaos.xml", }, }, }, @@ -412,7 +411,6 @@ func (a *AzurePlatformConfig) DestroyArgs() []string { } } - func envOrDefault(key, defaultVal string) string { if val := os.Getenv(key); val != "" { return val diff --git a/test/e2e/v2/lifecycle/platform.go b/test/e2e/v2/lifecycle/platform.go index 5e3f88ea7d13..9c9215887ff5 100644 --- a/test/e2e/v2/lifecycle/platform.go +++ b/test/e2e/v2/lifecycle/platform.go @@ -5,33 +5,53 @@ package lifecycle import ( "context" "crypto/sha256" + "errors" "fmt" + "strings" crclient "sigs.k8s.io/controller-runtime/pkg/client" ) // ClusterSpec describes a single cluster to create for lifecycle tests. type ClusterSpec struct { - Variant string - ExtraArgs []string - ReleaseImage string // override (empty = use default) + Variant string `json:"variant"` + ExtraArgs []string `json:"extraArgs,omitempty"` + ReleaseImage string `json:"releaseImage,omitempty"` // override (empty = use default) } // TestGroup describes one logical group of e2e tests to execute. type TestGroup struct { - Name string - Variant string - LabelFilter string - Skip string - JUnitFile string - ExtraEnv []string + Name string `json:"name"` + Variant string `json:"variant"` + LabelFilter string `json:"labelFilter"` + Skip string `json:"skip,omitempty"` +} + +// JUnitFile returns the deterministic JUnit XML filename for this +// test group, derived from the group name. It panics if the name +// contains path separators or traversal sequences; callers must +// validate the matrix before use. +func (g TestGroup) JUnitFile() string { + if err := validateGroupName(g.Name); err != nil { + panic(err.Error()) + } + return fmt.Sprintf("junit_%s.xml", g.Name) +} + +// validateGroupName checks that name is safe for use as a path +// component in JUnit filenames (no separators or traversal sequences). +func validateGroupName(name string) error { + if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { + return fmt.Errorf("invalid path component in test group name: %q", name) + } + return nil } // SequentialGroup runs its Steps one after another within a single // goroutine. If any step fails, subsequent steps are skipped. type SequentialGroup struct { - Name string - Steps []TestGroup + Name string `json:"name"` + Steps []TestGroup `json:"steps"` } // TestMatrix defines the full set of test groups for a platform. @@ -39,8 +59,52 @@ type SequentialGroup struct { // runs concurrently with everything else, but its internal Steps // run one after another. type TestMatrix struct { - Parallel []TestGroup - Sequential []SequentialGroup + Parallel []TestGroup `json:"parallel,omitempty"` + Sequential []SequentialGroup `json:"sequential,omitempty"` +} + +// Validate checks that all group names within the matrix are unique +// and safe for use as JUnit filename components. +func (m TestMatrix) Validate() error { + seen := make(map[string]bool) + var errs []error + check := func(name string) { + if err := validateGroupName(name); err != nil { + errs = append(errs, err) + } + if seen[name] { + errs = append(errs, fmt.Errorf("duplicate test group name: %q", name)) + } + seen[name] = true + } + for _, g := range m.Parallel { + check(g.Name) + } + for _, sg := range m.Sequential { + for _, step := range sg.Steps { + check(step.Name) + } + } + return errors.Join(errs...) +} + +// Variants returns the unique cluster variants referenced by the +// matrix. No ordering is guaranteed. +func (m TestMatrix) Variants() []string { + seen := make(map[string]bool) + for _, g := range m.Parallel { + seen[g.Variant] = true + } + for _, sg := range m.Sequential { + for _, step := range sg.Steps { + seen[step.Variant] = true + } + } + variants := make([]string, 0, len(seen)) + for v := range seen { + variants = append(variants, v) + } + return variants } // ResolveVariants validates that every variant referenced by the test @@ -117,8 +181,13 @@ type PlatformConfig interface { // block the initial version rollout if applied earlier. PostVersionRollout(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error + // DefaultTestPlan returns the full test plan for this platform, + // selecting all variants returned by ClusterSpecs and the complete + // test matrix using those variants. + DefaultTestPlan() TestPlan + // TestMatrix returns the test groups for this platform. - TestMatrix(releaseImage string) TestMatrix + TestMatrix() TestMatrix // SetupTestEnv sets platform-specific environment variables // before test execution (e.g., reading subnet IDs from diff --git a/test/e2e/v2/lifecycle/testplan.go b/test/e2e/v2/lifecycle/testplan.go new file mode 100644 index 000000000000..7ec9eb536180 --- /dev/null +++ b/test/e2e/v2/lifecycle/testplan.go @@ -0,0 +1,111 @@ +//go:build e2ev2 + +package lifecycle + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + sigyaml "sigs.k8s.io/yaml" +) + +// TestPlan is a declarative specification of which cluster variants to +// create and which tests to run against them. It is the external API +// for composing e2e runs — each (PlatformConfig) owns the details of +// how each variant is created, while the TestPlan selects and arranges them. +type TestPlan struct { + Name string `json:"name"` + Platform string `json:"platform"` + TestMatrix TestMatrix `json:"testMatrix"` +} + +// Validate checks that all variants referenced by the TestMatrix +// exist in allSpecs. Returns an error listing all problems. +func (p *TestPlan) Validate(allSpecs []ClusterSpec) error { + known := make(map[string]bool, len(allSpecs)) + for _, s := range allSpecs { + known[s.Variant] = true + } + + var errs []error + if err := p.TestMatrix.Validate(); err != nil { + errs = append(errs, err) + } + for _, v := range p.TestMatrix.Variants() { + if !known[v] { + errs = append(errs, fmt.Errorf("variant %q not found", v)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("test plan %q validation failed:\n%w", p.Name, errors.Join(errs...)) + } + return nil +} + +// FilterClusterSpecs returns the subset of allSpecs whose variants +// are referenced by the TestMatrix. The plan must be validated +// against allSpecs via Validate before calling this method; +// unknown variants are silently skipped. +func (p *TestPlan) FilterClusterSpecs(allSpecs []ClusterSpec) []ClusterSpec { + byVariant := make(map[string]ClusterSpec, len(allSpecs)) + for _, s := range allSpecs { + byVariant[s.Variant] = s + } + + variants := p.TestMatrix.Variants() + result := make([]ClusterSpec, 0, len(variants)) + for _, v := range variants { + if spec, ok := byVariant[v]; ok { + result = append(result, spec) + } + } + return result +} + +// ResolveTestPlan loads a TestPlan from the given path, or returns +// the platform's default plan if path is empty. Callers are +// responsible for validation appropriate to their context. +func ResolveTestPlan(path string, platform PlatformConfig) (*TestPlan, error) { + if path != "" { + return ReadTestPlan(path) + } + plan := platform.DefaultTestPlan() + return &plan, nil +} + +// ParseTestPlan deserializes a TestPlan from JSON or YAML bytes. +// Set yaml to true for YAML input. +func ParseTestPlan(data []byte, yaml bool) (*TestPlan, error) { + var plan TestPlan + if yaml { + if err := sigyaml.UnmarshalStrict(data, &plan); err != nil { + return nil, fmt.Errorf("unmarshaling YAML test plan: %w", err) + } + } else { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&plan); err != nil { + return nil, fmt.Errorf("unmarshaling JSON test plan: %w", err) + } + } + return &plan, nil +} + +// ReadTestPlan reads a TestPlan from a JSON or YAML file. The format +// is determined by the file extension (.yaml/.yml for YAML, everything +// else is treated as JSON). +func ReadTestPlan(path string) (*TestPlan, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading test plan from %s: %w", path, err) + } + ext := strings.ToLower(filepath.Ext(path)) + isYAML := ext == ".yaml" || ext == ".yml" + return ParseTestPlan(data, isYAML) +} diff --git a/test/e2e/v2/lifecycle/testplan_test.go b/test/e2e/v2/lifecycle/testplan_test.go new file mode 100644 index 000000000000..5211343519a4 --- /dev/null +++ b/test/e2e/v2/lifecycle/testplan_test.go @@ -0,0 +1,471 @@ +//go:build e2ev2 + +package lifecycle + +import ( + "reflect" + "testing" +) + +var testRegistry = []ClusterSpec{ + {Variant: "public", ExtraArgs: []string{"--public-only"}}, + {Variant: "private", ExtraArgs: []string{"--endpoint-access=Private"}}, + {Variant: "upgrade", ReleaseImage: "registry.ci.openshift.org/ocp/release:4.16.0-0.nightly"}, +} + +func TestTestPlanValidate(t *testing.T) { + tests := []struct { + name string + plan TestPlan + wantError bool + }{ + { + name: "When all matrix variants exist in the registry, it should succeed", + plan: TestPlan{ + Name: "full", + Platform: "test", + TestMatrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public", LabelFilter: "public"}, + {Name: "priv", Variant: "private", LabelFilter: "private"}, + }, + }, + }, + }, + { + name: "When a parallel matrix variant is not in the registry, it should return an error", + plan: TestPlan{ + Name: "bad-parallel", + Platform: "test", + TestMatrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public", LabelFilter: "public"}, + {Name: "missing", Variant: "nonexistent", LabelFilter: "missing"}, + }, + }, + }, + wantError: true, + }, + { + name: "When a sequential matrix variant is not in the registry, it should return an error", + plan: TestPlan{ + Name: "bad-sequential", + Platform: "test", + TestMatrix: TestMatrix{ + Sequential: []SequentialGroup{ + {Name: "seq", Steps: []TestGroup{ + {Name: "step1", Variant: "nonexistent", LabelFilter: "upgrade"}, + }}, + }, + }, + }, + wantError: true, + }, + { + name: "When test group names collide, it should return an error", + plan: TestPlan{ + Name: "dup-names", + Platform: "test", + TestMatrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "dup", Variant: "public", LabelFilter: "public"}, + {Name: "dup", Variant: "private", LabelFilter: "private"}, + }, + }, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.plan.Validate(testRegistry) + if tt.wantError && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantError && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestTestMatrixValidate(t *testing.T) { + tests := []struct { + name string + matrix TestMatrix + wantError bool + }{ + { + name: "When all group names are unique, it should succeed", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public"}, + {Name: "priv", Variant: "private"}, + }, + Sequential: []SequentialGroup{ + {Name: "seq", Steps: []TestGroup{ + {Name: "upgrade", Variant: "upgrade"}, + }}, + }, + }, + }, + { + name: "When parallel groups have duplicate names, it should return an error", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "dup", Variant: "public"}, + {Name: "dup", Variant: "private"}, + }, + }, + wantError: true, + }, + { + name: "When a sequential step duplicates a parallel name, it should return an error", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "shared", Variant: "public"}, + }, + Sequential: []SequentialGroup{ + {Name: "seq", Steps: []TestGroup{ + {Name: "shared", Variant: "upgrade"}, + }}, + }, + }, + wantError: true, + }, + { + name: "When a group name contains a path separator, it should return an error", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "foo/bar", Variant: "public"}, + }, + }, + wantError: true, + }, + { + name: "When a group name contains a traversal sequence, it should return an error", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "..sneaky", Variant: "public"}, + }, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.matrix.Validate() + if tt.wantError && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantError && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestTestMatrixVariants(t *testing.T) { + tests := []struct { + name string + matrix TestMatrix + want map[string]bool + }{ + { + name: "When the matrix has parallel groups, it should return their variants", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public"}, + {Name: "priv", Variant: "private"}, + }, + }, + want: map[string]bool{"public": true, "private": true}, + }, + { + name: "When the matrix has sequential groups, it should return their variants", + matrix: TestMatrix{ + Sequential: []SequentialGroup{ + {Name: "seq", Steps: []TestGroup{ + {Name: "s1", Variant: "upgrade"}, + {Name: "s2", Variant: "public"}, + }}, + }, + }, + want: map[string]bool{"upgrade": true, "public": true}, + }, + { + name: "When a variant appears in both parallel and sequential, it should be deduplicated", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public"}, + }, + Sequential: []SequentialGroup{ + {Name: "seq", Steps: []TestGroup{ + {Name: "s1", Variant: "public"}, + {Name: "s2", Variant: "upgrade"}, + }}, + }, + }, + want: map[string]bool{"public": true, "upgrade": true}, + }, + { + name: "When the matrix is empty, it should return no variants", + matrix: TestMatrix{}, + want: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.matrix.Variants() + gotSet := make(map[string]bool, len(got)) + for _, v := range got { + if gotSet[v] { + t.Errorf("duplicate variant %q in result", v) + } + gotSet[v] = true + } + if !reflect.DeepEqual(gotSet, tt.want) { + t.Errorf("got %v, want %v", gotSet, tt.want) + } + }) + } +} + +func TestTestGroupJUnitFile(t *testing.T) { + tests := []struct { + name string + group TestGroup + want string + }{ + { + name: "When the group name is simple, it should return junit_{name}.xml", + group: TestGroup{Name: "public", Variant: "public"}, + want: "junit_public.xml", + }, + { + name: "When the group name differs from the variant, it should use the group name", + group: TestGroup{Name: "control-plane-tls", Variant: "upgrade"}, + want: "junit_control-plane-tls.xml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.group.JUnitFile() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } + + t.Run("When the group name contains path traversal, it should panic", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic, got none") + } + }() + TestGroup{Name: "../../etc/passwd", Variant: "public"}.JUnitFile() + }) +} + +func TestTestPlanFilterClusterSpecs(t *testing.T) { + tests := []struct { + name string + matrix TestMatrix + wantVariants []string + }{ + { + name: "When the matrix references a subset of the registry, it should return only those specs", + matrix: TestMatrix{ + Parallel: []TestGroup{{Name: "pub", Variant: "public", LabelFilter: "public"}}, + }, + wantVariants: []string{"public"}, + }, + { + name: "When the matrix references multiple variants, it should return all of them", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "upg", Variant: "upgrade", LabelFilter: "upgrade"}, + {Name: "pub", Variant: "public", LabelFilter: "public"}, + }, + }, + wantVariants: []string{"public", "upgrade"}, + }, + { + name: "When the matrix references an unknown variant, it should silently skip it", + matrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "pub", Variant: "public", LabelFilter: "public"}, + {Name: "bad", Variant: "nonexistent", LabelFilter: "bad"}, + }, + }, + wantVariants: []string{"public"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := TestPlan{TestMatrix: tt.matrix} + got := plan.FilterClusterSpecs(testRegistry) + gotVariants := make(map[string]bool, len(got)) + for _, s := range got { + gotVariants[s.Variant] = true + } + wantSet := make(map[string]bool, len(tt.wantVariants)) + for _, v := range tt.wantVariants { + wantSet[v] = true + } + if !reflect.DeepEqual(gotVariants, wantSet) { + t.Errorf("got variants %v, want %v", gotVariants, wantSet) + } + }) + } +} + +func TestParseTestPlan(t *testing.T) { + tests := []struct { + name string + input string + yaml bool + want *TestPlan + wantError bool + }{ + { + name: "When parsing JSON with parallel groups, it should deserialize correctly", + input: `{ + "name": "smoke", + "platform": "aws", + "testMatrix": { + "parallel": [ + {"name": "smoke", "variant": "public", "labelFilter": "smoke"} + ] + } +}`, + want: &TestPlan{ + Name: "smoke", + Platform: "aws", + TestMatrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "smoke", Variant: "public", LabelFilter: "smoke"}, + }, + }, + }, + }, + { + name: "When parsing JSON with sequential groups, it should deserialize correctly", + input: `{ + "name": "upgrade", + "platform": "azure", + "testMatrix": { + "sequential": [ + {"name": "upgrade-flow", "steps": [ + {"name": "pre", "variant": "upgrade", "labelFilter": "upgrade-pre"}, + {"name": "run", "variant": "upgrade", "labelFilter": "upgrade-run"} + ]} + ] + } +}`, + want: &TestPlan{ + Name: "upgrade", + Platform: "azure", + TestMatrix: TestMatrix{ + Sequential: []SequentialGroup{ + {Name: "upgrade-flow", Steps: []TestGroup{ + {Name: "pre", Variant: "upgrade", LabelFilter: "upgrade-pre"}, + {Name: "run", Variant: "upgrade", LabelFilter: "upgrade-run"}, + }}, + }, + }, + }, + }, + { + name: "When parsing JSON with an unknown field, it should return an error", + input: `{ + "name": "typo", + "platform": "aws", + "testMatirx": {} +}`, + wantError: true, + }, + { + name: "When parsing YAML with an unknown field, it should return an error", + yaml: true, + input: `name: typo +platform: aws +testMatirx: {} +`, + wantError: true, + }, + { + name: "When parsing YAML with parallel groups, it should deserialize correctly", + yaml: true, + input: `name: smoke +platform: aws +testMatrix: + parallel: + - name: smoke + variant: public + labelFilter: smoke +`, + want: &TestPlan{ + Name: "smoke", + Platform: "aws", + TestMatrix: TestMatrix{ + Parallel: []TestGroup{ + {Name: "smoke", Variant: "public", LabelFilter: "smoke"}, + }, + }, + }, + }, + { + name: "When parsing YAML with sequential groups, it should deserialize correctly", + yaml: true, + input: `name: upgrade +platform: azure +testMatrix: + sequential: + - name: upgrade-flow + steps: + - name: pre + variant: upgrade + labelFilter: upgrade-pre + - name: run + variant: upgrade + labelFilter: upgrade-run +`, + want: &TestPlan{ + Name: "upgrade", + Platform: "azure", + TestMatrix: TestMatrix{ + Sequential: []SequentialGroup{ + {Name: "upgrade-flow", Steps: []TestGroup{ + {Name: "pre", Variant: "upgrade", LabelFilter: "upgrade-pre"}, + {Name: "run", Variant: "upgrade", LabelFilter: "upgrade-run"}, + }}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTestPlan([]byte(tt.input), tt.yaml) + if tt.wantError { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseTestPlan: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +}