| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package e2etest | |
| import ( | |
| "fmt" | |
| "os" | |
| "path/filepath" | |
| "strings" | |
| "testing" | |
| "github.com/google/uuid" | |
| "github.com/opentofu/opentofu/internal/e2e" | |
| ) | |
| type tofuResult struct { | |
| t *testing.T | |
| stdout string | |
| stderr string | |
| err error | |
| } | |
| func (r tofuResult) Success() tofuResult { | |
| r.t.Helper() | |
| if r.stderr != "" { | |
| r.t.Fatalf("unexpected stderr output:\n%s", r.stderr) | |
| } | |
| if r.err != nil { | |
| r.t.Fatalf("unexpected error: %s", r.err) | |
| } | |
| return r | |
| } | |
| func (r tofuResult) Failure() tofuResult { | |
| r.t.Helper() | |
| if r.err == nil { | |
| r.t.Fatal("expected error") | |
| } | |
| return r | |
| } | |
| func SanitizeStderr(msg string) string { | |
| // ANSI escape sequence regex for removing terminal color codes and control characters | |
| msg = stripAnsi(msg) | |
| // Pipe and carriage return replacement in order to correctly sanitze the stderr output | |
| msg = strings.ReplaceAll( | |
| strings.ReplaceAll(msg, "│", ""), | |
| "\n", "", | |
| ) | |
| return msg | |
| } | |
| func (r tofuResult) StderrContains(msg string) tofuResult { | |
| r.t.Helper() | |
| stdErrSanitized := SanitizeStderr(r.stderr) | |
| if !strings.Contains(stdErrSanitized, msg) { | |
| r.t.Fatalf("expected stderr output %q:\n%s", msg, stdErrSanitized) | |
| } | |
| return r | |
| } | |
| func (r tofuResult) StdoutContains(msg string) tofuResult { | |
| r.t.Helper() | |
| stdoutSanitized := SanitizeStderr(r.stdout) | |
| if !strings.Contains(stdoutSanitized, msg) { | |
| r.t.Fatalf("expected stdout output %q:\n%s", msg, stdoutSanitized) | |
| } | |
| return r | |
| } | |
| func (r tofuResult) Contains(msg string) tofuResult { | |
| r.t.Helper() | |
| if !strings.Contains(r.stdout, msg) { | |
| r.t.Fatalf("expected output %q:\n%s", msg, r.stdout) | |
| } | |
| return r | |
| } | |
| // This test covers the scenario where a user migrates an existing project | |
| // to having encryption enabled, uses it, then migrates back to encryption | |
| // disabled | |
| func TestEncryptionFlow(t *testing.T) { | |
| // This test reaches out to registry.opentofu.org to download the | |
| // mock provider, so it can only run if network access is allowed | |
| skipIfCannotAccessNetwork(t) | |
| // There is a lot of setup / helpers defined. Actual test logic is below. | |
| fixturePath := filepath.Join("testdata", "encryption-flow") | |
| tf := e2e.NewBinary(t, tofuBin, fixturePath) | |
| // tofu init | |
| _, stderr, err := tf.Run("init") | |
| if err != nil { | |
| t.Errorf("unexpected error: %s", err) | |
| } | |
| if stderr != "" { | |
| t.Errorf("unexpected stderr output:\n%s", stderr) | |
| } | |
| iter := 0 | |
| run := func(args ...string) tofuResult { | |
| stdout, stderr, err := tf.Run(args...) | |
| return tofuResult{t, stdout, stderr, err} | |
| } | |
| apply := func(args ...string) tofuResult { | |
| iter += 1 | |
| finalArgs := []string{"apply"} | |
| finalArgs = append(finalArgs, fmt.Sprintf("-var=iter=%v", iter), "-auto-approve") | |
| finalArgs = append(finalArgs, args...) | |
| return run(finalArgs...) | |
| } | |
| createPlan := func(planfile string, args ...string) tofuResult { | |
| iter += 1 | |
| args = append([]string{"plan", fmt.Sprintf("-var=iter=%v", iter), "-out=" + planfile}, args...) | |
| return run(args...) | |
| } | |
| applyPlan := func(planfile string, args ...string) tofuResult { | |
| finalArgs := []string{"apply", "-auto-approve"} | |
| finalArgs = append(finalArgs, args...) | |
| finalArgs = append(finalArgs, planfile) | |
| return run(finalArgs...) | |
| } | |
| withVarArg := func(key, value string) string { | |
| return fmt.Sprintf(`-var=%s=%s`, key, value) | |
| } | |
| requireUnencryptedState := func() { | |
| _, err = tf.LocalState() | |
| if err != nil { | |
| t.Fatalf("expected unencrypted state file: %q", err) | |
| } | |
| } | |
| requireEncryptedState := func() { | |
| _, err = tf.LocalState() | |
| if err == nil || err.Error() != "Error reading statefile: Unsupported state file format: This state file is encrypted and can not be read without an encryption configuration" { | |
| t.Fatalf("expected encrypted state file: %q", err) | |
| } | |
| } | |
| with := func(path string, fn func()) { | |
| src := tf.Path(path + ".disabled") | |
| dst := tf.Path(path) | |
| err := os.Rename(src, dst) | |
| if err != nil { | |
| t.Fatalf("%s", err.Error()) | |
| } | |
| fn() | |
| err = os.Rename(dst, src) | |
| if err != nil { | |
| t.Fatalf("%s", err.Error()) | |
| } | |
| } | |
| // Actual test begins HERE | |
| // NOTE: state plans are still readable and tests the encryption state | |
| unencryptedPlan := "unencrypted.tfplan" | |
| encryptedPlan := "encrypted.tfplan" | |
| correctPassphrase := uuid.NewString() | |
| { | |
| // Everything works before adding encryption configuration | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| // Check read/write of state file | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| // Save an unencrypted plan | |
| createPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).Success() | |
| // Validate that OpenTofu does not allow different -var value for a variable between creation of the plan and its execution. | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", "different-value-than-the-one-saved-in-the-planfile")). | |
| StderrContains(`Value saved in the plan file for variable "passphrase" is different from the one given to the current command`) | |
| // Validate unencrypted plan | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| } | |
| with("required.tf", func() { | |
| // Can't switch directly to encryption, need to migrate | |
| apply(withVarArg("passphrase", correctPassphrase)).Failure().StderrContains("encountered unencrypted payload without unencrypted method") | |
| requireUnencryptedState() | |
| }) | |
| with("migrateto.tf", func() { | |
| // Migrate to using encryption | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireEncryptedState() | |
| // Make changes and confirm it's still encrypted (even with migration enabled) | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireEncryptedState() | |
| // Save an encrypted plan | |
| createPlan(encryptedPlan, withVarArg("passphrase", correctPassphrase)).Success() | |
| // Apply encrypted plan (with migration active) | |
| applyPlan(encryptedPlan, withVarArg("passphrase", correctPassphrase)).Success() | |
| requireEncryptedState() | |
| // Apply unencrypted plan (with migration active) | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).StderrContains("Saved plan is stale") | |
| requireEncryptedState() | |
| }) | |
| { | |
| // Unconfigured encryption clearly fails on encrypted state | |
| apply(withVarArg("passphrase", correctPassphrase)).Failure().StderrContains("can not be read without an encryption configuration") | |
| } | |
| with("required.tf", func() { | |
| // Encryption works with fallback removed | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireEncryptedState() | |
| // Can't apply unencrypted plan | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).Failure().StderrContains("encountered unencrypted payload without unencrypted method") | |
| requireEncryptedState() | |
| // Apply encrypted plan | |
| applyPlan(encryptedPlan, withVarArg("passphrase", correctPassphrase)).StderrContains("Saved plan is stale") | |
| requireEncryptedState() | |
| }) | |
| with("required.tf", func() { // But with the wrong passphrase | |
| incorrectPassphrase := uuid.NewString() | |
| // Make sure changes to encryption keys notify the user correctly | |
| apply(withVarArg("passphrase", incorrectPassphrase)).Failure().StderrContains("decryption failed for state") | |
| requireEncryptedState() | |
| applyPlan(encryptedPlan, withVarArg("passphrase", incorrectPassphrase)).Failure().StderrContains("decryption failed: cipher: message authentication failed") | |
| requireEncryptedState() | |
| }) | |
| with("migratefrom.tf", func() { | |
| // Apply migration from encrypted state | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| // Make changes and confirm it's still encrypted (even with migration enabled) | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| // Apply unencrypted plan (with migration active) | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).StderrContains("Saved plan is stale") | |
| requireUnencryptedState() | |
| // Apply encrypted plan (with migration active) | |
| applyPlan(encryptedPlan, withVarArg("passphrase", correctPassphrase)).StderrContains("Saved plan is stale") | |
| requireUnencryptedState() | |
| }) | |
| { | |
| // Back to no encryption configuration with unencrypted state | |
| apply(withVarArg("passphrase", correctPassphrase)).Success() | |
| requireUnencryptedState() | |
| // Apply unencrypted plan | |
| applyPlan(unencryptedPlan, withVarArg("passphrase", correctPassphrase)).StderrContains("Saved plan is stale") | |
| requireUnencryptedState() | |
| // Can't apply encrypted plan | |
| applyPlan(encryptedPlan, withVarArg("passphrase", correctPassphrase)).Failure().StderrContains("the given plan file is encrypted and requires a valid encryption") | |
| requireUnencryptedState() | |
| } | |
| } | |
| func TestEncryptionEnvironmentVariable(t *testing.T) { | |
| // This test reaches out to registry.opentofu.org to download the | |
| // mock provider, so it can only run if network access is allowed | |
| skipIfCannotAccessNetwork(t) | |
| // There is a lot of setup / helpers defined. Actual test logic is below. | |
| fixturePath := filepath.Join("testdata", "encryption-flow") | |
| tf := e2e.NewBinary(t, tofuBin, fixturePath) | |
| // This test reproduces the situation reported in https://github.com/opentofu/opentofu/issues/4262 | |
| t.Run("with TF_ENCRYPTION containing only a simple space", func(t *testing.T) { | |
| // Setting this env var at the test level will force `tf.Run` to pick this up | |
| t.Setenv("TF_ENCRYPTION", " ") | |
| _, stderr, err := tf.Run("init") | |
| if err != nil { | |
| t.Errorf("unexpected error: %s", err) | |
| } | |
| if stderr != "" { | |
| t.Errorf("unexpected stderr output:\n%s", stderr) | |
| } | |
| }) | |
| } |