From 0565368def123028be14a13ff93504fc0876f95f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 05:59:20 +0000 Subject: [PATCH 1/4] Initial plan From a7dce2eba8a5152dcb23ad8f1325847e2fc408ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 06:30:04 +0000 Subject: [PATCH 2/4] Add hermetic infra test command Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/36793334-efac-47f8-ba98-b06822f35b8c Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/infra.go | 3 + cmd/wfctl/infra_test_cmd.go | 264 +++++++++++++++++++++++++++++++ cmd/wfctl/infra_test_cmd_test.go | 123 ++++++++++++++ docs/WFCTL.md | 55 ++++++- 4 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/infra_test_cmd.go create mode 100644 cmd/wfctl/infra_test_cmd_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 644ebc40..b9b162bf 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -81,6 +81,8 @@ func runInfra(args []string) error { return runInfraRefreshOutputs(args[1:]) case "align": return runInfraAlign(args[1:]) + case "test": + return runInfraTest(args[1:]) case "security-check": return runInfraSecurityCheck(args[1:]) case "cleanup": @@ -122,6 +124,7 @@ Actions: outputs Print captured resource outputs from state refresh-outputs Read live outputs and reconcile state (no cloud writes) align Validate IaC config + plan alignment (8 rule families) + test Hermetically validate expected infra config and plan outcomes security-check Scan plan.json for security policy violations cleanup Tag-based force-cleanup across providers (--tag NAME [--fix]) audit-secrets Report provider_credential anti-patterns in secrets.generate diff --git a/cmd/wfctl/infra_test_cmd.go b/cmd/wfctl/infra_test_cmd.go new file mode 100644 index 00000000..bb0264a6 --- /dev/null +++ b/cmd/wfctl/infra_test_cmd.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "reflect" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" + "gopkg.in/yaml.v3" +) + +type infraTestFile struct { + Config string `yaml:"config"` + Env string `yaml:"env"` + CurrentState []infraTestResourceState `yaml:"current_state"` + Expect infraTestExpect `yaml:"expect"` +} + +type infraTestResourceState struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Provider string `yaml:"provider"` + ProviderRef string `yaml:"provider_ref"` + ProviderID string `yaml:"provider_id"` + ConfigHash string `yaml:"config_hash"` + AppliedConfig map[string]any `yaml:"applied_config"` + AppliedConfigSource string `yaml:"applied_config_source"` + Outputs map[string]any `yaml:"outputs"` + Dependencies []string `yaml:"dependencies"` +} + +type infraTestExpect struct { + ResourcesCount *int `yaml:"resources_count"` + Resources []infraResourceExpect `yaml:"resources"` + ProviderInputs infraProviderInputExpect `yaml:"provider_inputs"` + Plan infraPlanExpect `yaml:"plan"` +} + +type infraProviderInputExpect struct { + Resources []infraResourceExpect `yaml:"resources"` +} + +type infraResourceExpect struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Config map[string]any `yaml:"config"` + DependsOn []string `yaml:"depends_on"` +} + +type infraPlanExpect struct { + ActionCounts map[string]int `yaml:"action_counts"` + Actions []infraPlanActionExpect `yaml:"actions"` +} + +type infraPlanActionExpect struct { + Action string `yaml:"action"` + Resource infraResourceExpect `yaml:"resource"` +} + +type infraTestResult struct { + Resources int + Actions int +} + +func runInfraTest(args []string) error { + fs := flag.NewFlagSet("infra test", flag.ContinueOnError) + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl infra test [test.yaml ...] + +Validate expected infrastructure config and plan outcomes without contacting +live providers. Each test file names a workflow config and expected resources, +resolved provider inputs, and/or plan actions. + +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() == 0 { + fs.Usage() + return fmt.Errorf("at least one infra test file is required") + } + failures := 0 + for _, path := range fs.Args() { + result, err := runInfraTestFile(path) + if err != nil { + failures++ + fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", path, err) + continue + } + fmt.Printf("PASS %s (%d resources, %d plan actions)\n", path, result.Resources, result.Actions) + } + if failures > 0 { + return fmt.Errorf("%d infra test(s) failed", failures) + } + return nil +} + +func runInfraTestFile(path string) (infraTestResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return infraTestResult{}, fmt.Errorf("read test file: %w", err) + } + var tf infraTestFile + if err := yaml.Unmarshal(data, &tf); err != nil { + return infraTestResult{}, fmt.Errorf("parse test file: %w", err) + } + if tf.Config == "" { + return infraTestResult{}, errors.New("config is required") + } + cfgPath := tf.Config + if !filepath.IsAbs(cfgPath) { + cfgPath = filepath.Join(filepath.Dir(path), cfgPath) + } + + currentState := infraTestStates(tf.CurrentState) + + rendered, err := parseInfraResourceSpecsForEnv(cfgPath, tf.Env) + if err != nil { + return infraTestResult{}, fmt.Errorf("render resources: %w", err) + } + if err := validateUniqueInfraResourceNames(rendered); err != nil { + return infraTestResult{}, err + } + if err := assertInfraResources("resources", tf.Expect.Resources, rendered); err != nil { + return infraTestResult{}, err + } + if tf.Expect.ResourcesCount != nil && len(rendered) != *tf.Expect.ResourcesCount { + return infraTestResult{}, fmt.Errorf("resources count: got %d, want %d", len(rendered), *tf.Expect.ResourcesCount) + } + + providerInputs := rendered + wfCfg, err := config.LoadFromFile(cfgPath) + if err != nil { + return infraTestResult{}, fmt.Errorf("load config for plan-time resolver: %w", err) + } + providerInputs, _, err = resolveSpecsAgainstState(providerInputs, currentState, wfCfg, tf.Env) + if err != nil { + return infraTestResult{}, fmt.Errorf("resolve provider inputs: %w", err) + } + if err := assertInfraResources("provider_inputs.resources", tf.Expect.ProviderInputs.Resources, providerInputs); err != nil { + return infraTestResult{}, err + } + + plan, err := computeInfraPlan(context.Background(), nil, providerInputs, currentState) + if err != nil { + return infraTestResult{}, fmt.Errorf("compute hermetic plan: %w", err) + } + if err := assertInfraPlan(tf.Expect.Plan, plan); err != nil { + return infraTestResult{}, err + } + return infraTestResult{Resources: len(rendered), Actions: len(plan.Actions)}, nil +} + +func infraTestStates(in []infraTestResourceState) []interfaces.ResourceState { + out := make([]interfaces.ResourceState, 0, len(in)) + for _, s := range in { + out = append(out, interfaces.ResourceState{ + Name: s.Name, + Type: s.Type, + Provider: s.Provider, + ProviderRef: s.ProviderRef, + ProviderID: s.ProviderID, + ConfigHash: s.ConfigHash, + AppliedConfig: s.AppliedConfig, + AppliedConfigSource: s.AppliedConfigSource, + Outputs: s.Outputs, + Dependencies: s.Dependencies, + }) + } + return out +} + +func assertInfraResources(label string, expected []infraResourceExpect, actual []interfaces.ResourceSpec) error { + for _, exp := range expected { + var match *interfaces.ResourceSpec + for i := range actual { + if actual[i].Name == exp.Name { + match = &actual[i] + break + } + } + if match == nil { + return fmt.Errorf("%s: resource %q not found", label, exp.Name) + } + if exp.Type != "" && match.Type != exp.Type { + return fmt.Errorf("%s[%s].type: got %q, want %q", label, exp.Name, match.Type, exp.Type) + } + if len(exp.DependsOn) > 0 && !reflect.DeepEqual(match.DependsOn, exp.DependsOn) { + return fmt.Errorf("%s[%s].depends_on: got %v, want %v", label, exp.Name, match.DependsOn, exp.DependsOn) + } + if err := assertMapSubset(exp.Config, match.Config); err != nil { + return fmt.Errorf("%s[%s].config: %w", label, exp.Name, err) + } + } + return nil +} + +func assertInfraPlan(expected infraPlanExpect, actual interfaces.IaCPlan) error { + if len(expected.ActionCounts) > 0 { + counts := map[string]int{} + for _, action := range actual.Actions { + counts[action.Action]++ + } + for action, want := range expected.ActionCounts { + if got := counts[action]; got != want { + return fmt.Errorf("plan action count for %s: got %d, want %d", action, got, want) + } + } + } + for _, exp := range expected.Actions { + var match *interfaces.PlanAction + for i := range actual.Actions { + action := &actual.Actions[i] + if exp.Action != "" && action.Action != exp.Action { + continue + } + if exp.Resource.Name != "" && action.Resource.Name != exp.Resource.Name { + continue + } + match = action + break + } + if match == nil { + return fmt.Errorf("plan action not found: action=%q resource=%q", exp.Action, exp.Resource.Name) + } + if exp.Resource.Type != "" && match.Resource.Type != exp.Resource.Type { + return fmt.Errorf("plan action %s resource %s type: got %q, want %q", exp.Action, exp.Resource.Name, match.Resource.Type, exp.Resource.Type) + } + if err := assertMapSubset(exp.Resource.Config, match.Resource.Config); err != nil { + return fmt.Errorf("plan action %s resource %s config: %w", exp.Action, exp.Resource.Name, err) + } + } + return nil +} + +func assertMapSubset(expected map[string]any, actual map[string]any) error { + for key, want := range expected { + got, ok := actual[key] + if !ok { + return fmt.Errorf("%s missing", key) + } + if wantMap, ok := want.(map[string]any); ok { + gotMap, ok := got.(map[string]any) + if !ok { + return fmt.Errorf("%s: got %#v, want map", key, got) + } + if err := assertMapSubset(wantMap, gotMap); err != nil { + return fmt.Errorf("%s.%w", key, err) + } + continue + } + if !reflect.DeepEqual(got, want) { + return fmt.Errorf("%s: got %#v, want %#v", key, got, want) + } + } + return nil +} diff --git a/cmd/wfctl/infra_test_cmd_test.go b/cmd/wfctl/infra_test_cmd_test.go new file mode 100644 index 00000000..e75951ca --- /dev/null +++ b/cmd/wfctl/infra_test_cmd_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/platform" +) + +func TestRunInfraTestFile_HermeticWithProviderModule(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + writeInfraTestFile(t, cfgPath, ` +modules: + - name: do + type: iac.provider + config: + provider: digitalocean + token: ${DO_TOKEN} + - name: network + type: infra.vpc + config: + provider: do + cidr: 10.10.0.0/16 + - name: subnet-a + type: infra.subnet + config: + provider: do + cidr: 10.10.1.0/24 + depends_on: [network] + - name: subnet-b + type: infra.subnet + config: + provider: do + cidr: 10.10.2.0/24 + depends_on: [network] +`) + testPath := filepath.Join(dir, "infra_test.yaml") + writeInfraTestFile(t, testPath, ` +config: infra.yaml +expect: + resources_count: 3 + resources: + - name: network + type: infra.vpc + config: + cidr: 10.10.0.0/16 + - name: subnet-a + type: infra.subnet + depends_on: [network] + provider_inputs: + resources: + - name: subnet-b + config: + provider: do + cidr: 10.10.2.0/24 + plan: + action_counts: + create: 3 + actions: + - action: create + resource: + name: network + type: infra.vpc + - action: create + resource: + name: subnet-a + config: + cidr: 10.10.1.0/24 +`) + + result, err := runInfraTestFile(testPath) + if err != nil { + t.Fatalf("runInfraTestFile: %v", err) + } + if result.Resources != 3 || result.Actions != 3 { + t.Fatalf("result = %+v, want 3 resources and 3 actions", result) + } +} + +func TestRunInfraTestFile_FailsOnPlanMismatch(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + hash := platform.ConfigHash(map[string]any{"engine": "postgres"}) + writeInfraTestFile(t, cfgPath, ` +modules: + - name: db + type: infra.database + config: + engine: mysql +`) + testPath := filepath.Join(dir, "infra_test.yaml") + writeInfraTestFile(t, testPath, ` +config: infra.yaml +current_state: + - name: db + type: infra.database + config_hash: `+hash+` + applied_config: + engine: postgres +expect: + plan: + action_counts: + create: 1 +`) + + _, err := runInfraTestFile(testPath) + if err == nil { + t.Fatal("expected plan mismatch error") + } + if !strings.Contains(err.Error(), "plan action count for create") { + t.Fatalf("error = %v, want action count mismatch", err) + } +} + +func writeInfraTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(strings.TrimPrefix(content, "\n")), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 1f47ea34..97852197 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -98,6 +98,7 @@ graph TD infra --> infra-state["state"] infra --> infra-outputs["outputs"] infra --> infra-refresh-outputs["refresh-outputs"] + infra --> infra-test["test"] infra --> infra-audit-secrets["audit-secrets"] infra --> infra-audit-state-secrets["audit-state-secrets"] @@ -171,7 +172,7 @@ graph TD | **Validation & Inspection** | `validate`, `inspect`, `schema`, `compat check`, `template validate`, `editor-schemas`, `dsl-reference` | | **API & Contract** | `api extract`, `contract test`, `diff` | | **Deployment** | `deploy docker/kubernetes/helm/cloud`, `build-ui`, `generate github-actions` | -| **Infrastructure** | `infra plan/apply/destroy/status/drift/import/bootstrap/outputs`, `infra state list/export/import` | +| **Infrastructure** | `infra plan/apply/destroy/status/drift/import/bootstrap/outputs/test`, `infra state list/export/import` | | **CI/CD** | `ci generate`, `generate github-actions` | | **Documentation** | `docs generate` | | **Plugin Management** | `plugin`, `plugin-registry`, `registry`, `publish` | @@ -1290,6 +1291,7 @@ wfctl infra [options] [config.yaml] | `state` | Manage state storage (list/export/import) | | `outputs` | Print resource outputs from state (yaml/json/env formats) | | `refresh-outputs` | Read live outputs from each provider and reconcile state (no cloud writes) | +| `test` | Hermetically validate expected infra config, resolved provider inputs, and plan actions | | `cleanup` | Tag-based force-cleanup across providers that implement `interfaces.Enumerator` | | `audit-secrets` | Report `provider_credential` anti-patterns in `secrets.generate` | | `audit-keys` | List cloud-side resources of `--type` via the provider's `interfaces.EnumeratorAll` | @@ -1326,6 +1328,7 @@ wfctl infra plan infra.yaml wfctl infra apply --auto-approve infra.yaml wfctl infra status --config infra.yaml wfctl infra drift infra.yaml +wfctl infra test tests/infra_test.yaml wfctl infra destroy --auto-approve infra.yaml wfctl infra import --config infra.yaml --env staging --name site-dns --id do-domain-123 wfctl infra import --config infra.yaml --name site-dns @@ -1340,6 +1343,56 @@ wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN wfctl infra bootstrap -c infra.yaml --force-rotate FOO --force-rotate BAR ``` +#### `infra test` + +`wfctl infra test` validates infrastructure expectations without contacting live +providers or reading cloud credentials. Test mode renders the Workflow config, +resolves environment/JIT references against the fixture state, and computes the +plan with the hermetic config-hash differ. It never calls provider `Apply` or +`Destroy`; provider/plugin contracts remain strict for normal `infra plan` and +`infra apply` paths. + +```bash +wfctl infra test tests/infra_test.yaml +``` + +Smallest useful test file: + +```yaml +config: ../infra.yaml +env: staging +current_state: + - name: existing-db + type: infra.database + config_hash: 8f2c... +expect: + resources_count: 3 + resources: + - name: network + type: infra.vpc + config: + cidr: 10.10.0.0/16 + provider_inputs: + resources: + - name: api + config: + image: ghcr.io/acme/api:sha + plan: + action_counts: + create: 2 + update: 1 + actions: + - action: create + resource: + name: network + type: infra.vpc +``` + +Assertions are partial: listed resources/actions must be present, but configs +may include additional provider-specific keys. Use `resources_count` and +`plan.action_counts` for generated collections such as N subnets or multiple app +components, and use `current_state` fixtures to cover update/delete plan shapes. + #### `infra cleanup` Tag-based force-cleanup across every provider declared in the config. For each `iac.provider` module, type-asserts to the optional `interfaces.Enumerator`; providers that implement it are queried via `EnumerateByTag`, and the matched resources are either listed (`--dry-run`, default) or deleted (`--fix`). Providers that do **not** implement `Enumerator` are skipped with `skipped : provider does not implement Enumerator` to stdout so operators see the explicit skip. From d1def450a70119fbafde546836fe9052c969a520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 06:33:43 +0000 Subject: [PATCH 3/4] Fix infra test lint findings Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/36793334-efac-47f8-ba98-b06822f35b8c Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/infra_test_cmd.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/infra_test_cmd.go b/cmd/wfctl/infra_test_cmd.go index bb0264a6..a773c416 100644 --- a/cmd/wfctl/infra_test_cmd.go +++ b/cmd/wfctl/infra_test_cmd.go @@ -160,7 +160,8 @@ func runInfraTestFile(path string) (infraTestResult, error) { func infraTestStates(in []infraTestResourceState) []interfaces.ResourceState { out := make([]interfaces.ResourceState, 0, len(in)) - for _, s := range in { + for i := range in { + s := &in[i] out = append(out, interfaces.ResourceState{ Name: s.Name, Type: s.Type, @@ -205,8 +206,8 @@ func assertInfraResources(label string, expected []infraResourceExpect, actual [ func assertInfraPlan(expected infraPlanExpect, actual interfaces.IaCPlan) error { if len(expected.ActionCounts) > 0 { counts := map[string]int{} - for _, action := range actual.Actions { - counts[action.Action]++ + for i := range actual.Actions { + counts[actual.Actions[i].Action]++ } for action, want := range expected.ActionCounts { if got := counts[action]; got != want { From 75350fc2772d98f130a47fe3ef24fc44d54d95a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 22:48:01 +0000 Subject: [PATCH 4/4] Fix assertInfraPlan: full-filter matching and used-action tracking Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/b552f30b-8c84-44fd-a053-84dd204d9e96 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/infra_test_cmd.go | 33 +++++++----- cmd/wfctl/infra_test_cmd_test.go | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/cmd/wfctl/infra_test_cmd.go b/cmd/wfctl/infra_test_cmd.go index a773c416..3b023fa9 100644 --- a/cmd/wfctl/infra_test_cmd.go +++ b/cmd/wfctl/infra_test_cmd.go @@ -215,28 +215,35 @@ func assertInfraPlan(expected infraPlanExpect, actual interfaces.IaCPlan) error } } } + // used tracks which actual action indices have already been matched so that + // two distinct expected entries cannot both claim the same actual action. + used := make([]bool, len(actual.Actions)) for _, exp := range expected.Actions { - var match *interfaces.PlanAction + matchIdx := -1 for i := range actual.Actions { - action := &actual.Actions[i] - if exp.Action != "" && action.Action != exp.Action { + if used[i] { continue } - if exp.Resource.Name != "" && action.Resource.Name != exp.Resource.Name { + a := &actual.Actions[i] + if exp.Action != "" && a.Action != exp.Action { continue } - match = action + if exp.Resource.Name != "" && a.Resource.Name != exp.Resource.Name { + continue + } + if exp.Resource.Type != "" && a.Resource.Type != exp.Resource.Type { + continue + } + if assertMapSubset(exp.Resource.Config, a.Resource.Config) != nil { + continue + } + matchIdx = i break } - if match == nil { - return fmt.Errorf("plan action not found: action=%q resource=%q", exp.Action, exp.Resource.Name) - } - if exp.Resource.Type != "" && match.Resource.Type != exp.Resource.Type { - return fmt.Errorf("plan action %s resource %s type: got %q, want %q", exp.Action, exp.Resource.Name, match.Resource.Type, exp.Resource.Type) - } - if err := assertMapSubset(exp.Resource.Config, match.Resource.Config); err != nil { - return fmt.Errorf("plan action %s resource %s config: %w", exp.Action, exp.Resource.Name, err) + if matchIdx == -1 { + return fmt.Errorf("plan action not found: action=%q resource=%q type=%q", exp.Action, exp.Resource.Name, exp.Resource.Type) } + used[matchIdx] = true } return nil } diff --git a/cmd/wfctl/infra_test_cmd_test.go b/cmd/wfctl/infra_test_cmd_test.go index e75951ca..8a209d34 100644 --- a/cmd/wfctl/infra_test_cmd_test.go +++ b/cmd/wfctl/infra_test_cmd_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/platform" ) @@ -115,6 +116,92 @@ expect: } } +// TestAssertInfraPlan_UsedTrackingPreventsDoubleClaim verifies that two +// expected actions with identical action/name/type cannot both match the same +// actual action — the second must fail if there is no distinct actual action. +func TestAssertInfraPlan_UsedTrackingPreventsDoubleClaim(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "db", Type: "infra.database"}}, + }, + } + exp := infraPlanExpect{ + Actions: []infraPlanActionExpect{ + {Action: "create", Resource: infraResourceExpect{Name: "db"}}, + {Action: "create", Resource: infraResourceExpect{Name: "db"}}, + }, + } + if err := assertInfraPlan(exp, plan); err == nil { + t.Fatal("expected error: two expected entries must not match same actual action") + } +} + +// TestAssertInfraPlan_TypeFilterPreventsWrongMatch verifies that an expected +// action specifying a type is NOT satisfied by an actual action with a +// different type even when action and name match. +func TestAssertInfraPlan_TypeFilterPreventsWrongMatch(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "store", Type: "infra.storage"}}, + {Action: "create", Resource: interfaces.ResourceSpec{Name: "cache", Type: "infra.cache"}}, + }, + } + exp := infraPlanExpect{ + Actions: []infraPlanActionExpect{ + {Action: "create", Resource: infraResourceExpect{Name: "store", Type: "infra.database"}}, + }, + } + if err := assertInfraPlan(exp, plan); err == nil { + t.Fatal("expected error: type filter must prevent wrong-type match") + } +} + +// TestAssertInfraPlan_ConfigSubsetFilterPreventsWrongMatch verifies that an +// expected action's config subset must match; a mismatching config causes the +// action to be skipped and the assertion to fail. +func TestAssertInfraPlan_ConfigSubsetFilterPreventsWrongMatch(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{ + Name: "db", Type: "infra.database", + Config: map[string]any{"engine": "postgres"}, + }}, + }, + } + exp := infraPlanExpect{ + Actions: []infraPlanActionExpect{ + {Action: "create", Resource: infraResourceExpect{ + Name: "db", + Config: map[string]any{"engine": "mysql"}, + }}, + }, + } + if err := assertInfraPlan(exp, plan); err == nil { + t.Fatal("expected error: config subset must match actual config") + } +} + +// TestAssertInfraPlan_OrderIndependent verifies that expected actions are +// satisfied regardless of their order in the expected slice relative to the +// actual actions. +func TestAssertInfraPlan_OrderIndependent(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "subnet-a", Type: "infra.subnet"}}, + {Action: "create", Resource: interfaces.ResourceSpec{Name: "network", Type: "infra.vpc"}}, + }, + } + exp := infraPlanExpect{ + Actions: []infraPlanActionExpect{ + {Action: "create", Resource: infraResourceExpect{Name: "network", Type: "infra.vpc"}}, + {Action: "create", Resource: infraResourceExpect{Name: "subnet-a", Type: "infra.subnet"}}, + }, + } + if err := assertInfraPlan(exp, plan); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + func writeInfraTestFile(t *testing.T, path string, content string) { t.Helper() if err := os.WriteFile(path, []byte(strings.TrimPrefix(content, "\n")), 0o600); err != nil {