// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package cloud import ( "context" "path/filepath" "strings" "testing" tfe "github.com/hashicorp/go-tfe" "github.com/opentofu/opentofu/internal/plans" ) // A brief discourse on the theory of testing for this feature. Doing // `tofu show cloudplan.tfplan` relies on the correctness of the following // behaviors: // // 1. TFC API returns redacted or unredacted plan JSON on request, if permission // requirements are met and the run is in a condition where that JSON exists. // 2. Cloud.ShowPlanForRun() makes correct API calls, calculates metadata // properly given a tfe.Run, and returns either a cloudplan.RemotePlanJSON or an err. // 3. The Show command instantiates Cloud backend when given a cloud planfile, // calls .ShowPlanForRun() on it, and passes result to Display() impls. // 4. Display() impls yield the correct output when given a cloud plan json biscuit. // // 1 is axiomatic and outside our domain. 3 is regrettably totally untestable // unless we refactor the Meta command to enable stubbing out a backend factory // or something, which seems inadvisable at this juncture. 4 is exercised over // in internal/command/views/show_test.go. And thus, this file only cares about // item 2. // 404 on run: special error message func TestCloud_showMissingRun(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() mockSROWorkspace(t, b, testBackendSingleWorkspaceName) absentRunID := "run-WwwwXxxxYyyyZzzz" _, err := b.ShowPlanForRun(context.Background(), absentRunID, tfeHost, true) if !strings.Contains(err.Error(), "tofu login") { t.Fatalf("expected error message to suggest checking your login status, instead got: %s", err) } } // If redacted json is available but unredacted is not func TestCloud_showMissingUnredactedJson(t *testing.T) { b, mc, bCleanup := testBackendAndMocksWithName(t) defer bCleanup() mockSROWorkspace(t, b, testBackendSingleWorkspaceName) ctx := context.Background() runID, err := testCloudRunForShow(mc, "./testdata/plan-json-basic-no-unredacted", tfe.RunPlannedAndSaved, tfe.PlanFinished) if err != nil { t.Fatalf("failed to init test data: %s", err) } // Showing the human-formatted plan should still work as expected! redacted, err := b.ShowPlanForRun(ctx, runID, tfeHost, true) if err != nil { t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err) } if !strings.Contains(string(redacted.JSONBytes), `"plan_format_version":`) { t.Fatalf("show for human doesn't include expected redacted json content") } // Should be marked as containing changes and non-errored canNotApply := false errored := false for _, opt := range redacted.Qualities { if opt == plans.NoChanges { canNotApply = true } if opt == plans.Errored { errored = true } } if canNotApply || errored { t.Fatalf("expected neither errored nor can't-apply in opts, instead got: %#v", redacted.Qualities) } // But show -json should result in a special error. _, err = b.ShowPlanForRun(ctx, runID, tfeHost, false) if err == nil { t.Fatalf("unexpected success: reading unredacted json without admin permissions should have errored") } if !strings.Contains(err.Error(), "admin") { t.Fatalf("expected error message to suggest your permissions are wrong, instead got: %s", err) } } // If both kinds of json are available, both kinds of show should work func TestCloud_showIncludesUnredactedJson(t *testing.T) { b, mc, bCleanup := testBackendAndMocksWithName(t) defer bCleanup() mockSROWorkspace(t, b, testBackendSingleWorkspaceName) ctx := context.Background() runID, err := testCloudRunForShow(mc, "./testdata/plan-json-basic", tfe.RunPlannedAndSaved, tfe.PlanFinished) if err != nil { t.Fatalf("failed to init test data: %s", err) } // Showing the human-formatted plan should work as expected: redacted, err := b.ShowPlanForRun(ctx, runID, tfeHost, true) if err != nil { t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err) } if !strings.Contains(string(redacted.JSONBytes), `"plan_format_version":`) { t.Fatalf("show for human doesn't include expected redacted json content") } // Showing the external json plan format should work as expected: unredacted, err := b.ShowPlanForRun(ctx, runID, tfeHost, false) if err != nil { t.Fatalf("failed to show plan for robot, even though unredacted json should be present: %s", err) } if !strings.Contains(string(unredacted.JSONBytes), `"format_version":`) { t.Fatalf("show for robot doesn't include expected unredacted json content") } } func TestCloud_showNoChanges(t *testing.T) { b, mc, bCleanup := testBackendAndMocksWithName(t) defer bCleanup() mockSROWorkspace(t, b, testBackendSingleWorkspaceName) ctx := context.Background() runID, err := testCloudRunForShow(mc, "./testdata/plan-json-no-changes", tfe.RunPlannedAndSaved, tfe.PlanFinished) if err != nil { t.Fatalf("failed to init test data: %s", err) } // Showing the human-formatted plan should work as expected: redacted, err := b.ShowPlanForRun(ctx, runID, tfeHost, true) if err != nil { t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err) } // Should be marked as no changes canNotApply := false for _, opt := range redacted.Qualities { if opt == plans.NoChanges { canNotApply = true } } if !canNotApply { t.Fatalf("expected opts to include CanNotApply, instead got: %#v", redacted.Qualities) } } func TestCloud_showFooterNotConfirmable(t *testing.T) { b, mc, bCleanup := testBackendAndMocksWithName(t) defer bCleanup() mockSROWorkspace(t, b, testBackendSingleWorkspaceName) ctx := context.Background() runID, err := testCloudRunForShow(mc, "./testdata/plan-json-full", tfe.RunDiscarded, tfe.PlanFinished) if err != nil { t.Fatalf("failed to init test data: %s", err) } // A little more custom run tweaking: mc.Runs.Runs[runID].Actions.IsConfirmable = false // Showing the human-formatted plan should work as expected: redacted, err := b.ShowPlanForRun(ctx, runID, tfeHost, true) if err != nil { t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err) } // Footer should mention that you can't apply it: if !strings.Contains(redacted.RunFooter, "not confirmable") { t.Fatalf("footer should call out that run isn't confirmable, instead got: %s", redacted.RunFooter) } } func testCloudRunForShow(mc *MockClient, configDir string, runStatus tfe.RunStatus, planStatus tfe.PlanStatus) (string, error) { ctx := context.Background() // get workspace ID wsID := mc.Workspaces.workspaceNames[testBackendSingleWorkspaceName].ID // create and upload config version cvOpts := tfe.ConfigurationVersionCreateOptions{ AutoQueueRuns: tfe.Bool(false), Speculative: tfe.Bool(false), } cv, err := mc.ConfigurationVersions.Create(ctx, wsID, cvOpts) if err != nil { return "", err } absDir, err := filepath.Abs(configDir) if err != nil { return "", err } err = mc.ConfigurationVersions.Upload(ctx, cv.UploadURL, absDir) if err != nil { return "", err } // create run rOpts := tfe.RunCreateOptions{ PlanOnly: tfe.Bool(false), IsDestroy: tfe.Bool(false), RefreshOnly: tfe.Bool(false), ConfigurationVersion: cv, Workspace: &tfe.Workspace{ID: wsID}, } r, err := mc.Runs.Create(ctx, rOpts) if err != nil { return "", err } // mess with statuses (this is what requires full access to mock client) mc.Runs.Runs[r.ID].Status = runStatus mc.Plans.plans[r.Plan.ID].Status = planStatus // return the ID return r.ID, nil }