opentofu/internal/cloud/testing.go
Christian Mesh 2f5dcd5c0a
Integrate Encryption into State Backends (#1288)
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
2024-03-04 09:25:14 -05:00

633 lines
18 KiB
Go

// 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"strconv"
"testing"
"time"
tfe "github.com/hashicorp/go-tfe"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/backend"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/states/statefile"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/internal/tofu"
"github.com/opentofu/opentofu/version"
backendLocal "github.com/opentofu/opentofu/internal/backend/local"
)
const (
testCred = "test-auth-token"
)
var (
tfeHost = "app.terraform.io"
credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
svchost.Hostname(tfeHost): {"token": testCred},
})
testBackendSingleWorkspaceName = "app-prod"
defaultTFCPing = map[string]func(http.ResponseWriter, *http.Request){
"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("TFP-API-Version", "2.5")
w.Header().Set("TFP-AppName", "Terraform Cloud")
},
}
)
func skipIfTFENotEnabled(t *testing.T) {
if os.Getenv("TF_TFC_TEST") == "" {
t.Skip("this test accesses " + tfeHost + "; set TF_TFC_TEST=1 to run it")
}
}
// mockInput is a mock implementation of tofu.UIInput.
type mockInput struct {
answers map[string]string
}
func (m *mockInput) Input(ctx context.Context, opts *tofu.InputOpts) (string, error) {
v, ok := m.answers[opts.Id]
if !ok {
return "", fmt.Errorf("unexpected input request in test: %s", opts.Id)
}
if v == "wait-for-external-update" {
select {
case <-ctx.Done():
case <-time.After(time.Minute):
}
}
delete(m.answers, opts.Id)
return v, nil
}
func testInput(t *testing.T, answers map[string]string) *mockInput {
skipIfTFENotEnabled(t)
return &mockInput{answers: answers}
}
func testBackendWithName(t *testing.T) (*Cloud, func()) {
b, _, c := testBackendAndMocksWithName(t)
return b, c
}
func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.StringVal(tfeHost),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
return testBackend(t, obj, defaultTFCPing)
}
func testBackendWithTags(t *testing.T) (*Cloud, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.StringVal(tfeHost),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"tags": cty.SetVal(
[]cty.Value{
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.StringVal(tfeHost),
"organization": cty.StringVal("no-operations"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.StringVal(tfeHost),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, handlers)
return b, c
}
func testCloudState(t *testing.T) *State {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
raw, err := b.StateMgr(testBackendSingleWorkspaceName)
if err != nil {
t.Fatalf("error: %v", err)
}
return raw.(*State)
}
func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
b, cleanup := testBackendWithName(t)
// Get a new mock client to use for adding outputs
mc := NewMockClient()
mc.StateVersionOutputs.create("svo-abcd", &tfe.StateVersionOutput{
ID: "svo-abcd",
Value: "foobar",
Sensitive: true,
Type: "string",
Name: "sensitive_output",
DetailedType: "string",
})
mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{
ID: "svo-zyxw",
Value: "bazqux",
Type: "string",
Name: "nonsensitive_output",
DetailedType: "string",
})
var dt interface{}
var val interface{}
err := json.Unmarshal([]byte(`["object", {"foo":"string"}]`), &dt)
if err != nil {
t.Fatalf("could not unmarshal detailed type: %s", err)
}
err = json.Unmarshal([]byte(`{"foo":"bar"}`), &val)
if err != nil {
t.Fatalf("could not unmarshal value: %s", err)
}
mc.StateVersionOutputs.create("svo-efgh", &tfe.StateVersionOutput{
ID: "svo-efgh",
Value: val,
Type: "object",
Name: "object_output",
DetailedType: dt,
})
err = json.Unmarshal([]byte(`["list", "bool"]`), &dt)
if err != nil {
t.Fatalf("could not unmarshal detailed type: %s", err)
}
err = json.Unmarshal([]byte(`[true, false, true, true]`), &val)
if err != nil {
t.Fatalf("could not unmarshal value: %s", err)
}
mc.StateVersionOutputs.create("svo-ijkl", &tfe.StateVersionOutput{
ID: "svo-ijkl",
Value: val,
Type: "array",
Name: "list_output",
DetailedType: dt,
})
b.client.StateVersionOutputs = mc.StateVersionOutputs
return b, cleanup
}
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) {
skipIfTFENotEnabled(t)
var s *httptest.Server
if handlers != nil {
s = testServerWithHandlers(handlers)
} else {
s = testServer(t)
}
b := New(testDisco(s), encryption.StateEncryptionDisabled())
// Configure the backend so the client is created.
newObj, valDiags := b.PrepareConfig(obj)
if len(valDiags) != 0 {
t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings())
}
obj = newObj
confDiags := b.Configure(obj)
if len(confDiags) != 0 {
t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings())
}
// Get a new mock client.
mc := NewMockClient()
// Replace the services we use with our mock services.
b.CLI = cli.NewMockUi()
b.client.Applies = mc.Applies
b.client.ConfigurationVersions = mc.ConfigurationVersions
b.client.CostEstimates = mc.CostEstimates
b.client.Organizations = mc.Organizations
b.client.Plans = mc.Plans
b.client.TaskStages = mc.TaskStages
b.client.PolicySetOutcomes = mc.PolicySetOutcomes
b.client.PolicyChecks = mc.PolicyChecks
b.client.Runs = mc.Runs
b.client.RunEvents = mc.RunEvents
b.client.StateVersions = mc.StateVersions
b.client.StateVersionOutputs = mc.StateVersionOutputs
b.client.Variables = mc.Variables
b.client.Workspaces = mc.Workspaces
// Set local to a local test backend.
b.local = testLocalBackend(t, b)
b.input = true
baseURL, err := url.Parse("https://" + tfeHost)
if err != nil {
t.Fatalf("testBackend: failed to parse base URL for client")
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}
ctx := context.Background()
// Create the organization.
_, err = b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
Name: tfe.String(b.organization),
})
if err != nil {
t.Fatalf("error: %v", err)
}
// Create the default workspace if required.
if b.WorkspaceMapping.Name != "" {
_, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{
Name: tfe.String(b.WorkspaceMapping.Name),
})
if err != nil {
t.Fatalf("error: %v", err)
}
}
return b, mc, s.Close
}
// testUnconfiguredBackend is used for testing the configuration of the backend
// with the mock client
func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
skipIfTFENotEnabled(t)
s := testServer(t)
b := New(testDisco(s), encryption.StateEncryptionDisabled())
// Normally, the client is created during configuration, but the configuration uses the
// client to read entitlements.
var err error
b.client, err = tfe.NewClient(&tfe.Config{
Token: "fake-token",
})
if err != nil {
t.Fatal(err)
}
// Get a new mock client.
mc := NewMockClient()
// Replace the services we use with our mock services.
b.CLI = cli.NewMockUi()
b.client.Applies = mc.Applies
b.client.ConfigurationVersions = mc.ConfigurationVersions
b.client.CostEstimates = mc.CostEstimates
b.client.Organizations = mc.Organizations
b.client.Plans = mc.Plans
b.client.PolicySetOutcomes = mc.PolicySetOutcomes
b.client.PolicyChecks = mc.PolicyChecks
b.client.Runs = mc.Runs
b.client.RunEvents = mc.RunEvents
b.client.StateVersions = mc.StateVersions
b.client.StateVersionOutputs = mc.StateVersionOutputs
b.client.Variables = mc.Variables
b.client.Workspaces = mc.Workspaces
baseURL, err := url.Parse("https://" + tfeHost)
if err != nil {
t.Fatalf("testBackend: failed to parse base URL for client")
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}
// Set local to a local test backend.
b.local = testLocalBackend(t, b)
return b, s.Close
}
func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced {
skipIfTFENotEnabled(t)
b := backendLocal.NewWithBackend(cloud, nil)
// Add a test provider to the local backend.
p := backendLocal.TestLocalProvider(t, b, "null", providers.ProviderSchema{
ResourceTypes: map[string]providers.Schema{
"null_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
},
},
},
},
})
p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
})}
return b
}
// testServer returns a started *httptest.Server used for local testing with the default set of
// request handlers.
func testServer(t *testing.T) *httptest.Server {
skipIfTFENotEnabled(t)
return testServerWithHandlers(testDefaultRequestHandlers)
}
// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers
// overriding any default request handlers (testDefaultRequestHandlers).
func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
mux := http.NewServeMux()
for route, handler := range handlers {
mux.HandleFunc(route, handler)
}
for route, handler := range testDefaultRequestHandlers {
if handlers[route] == nil {
mux.HandleFunc(route, handler)
}
}
return httptest.NewServer(mux)
}
func testServerWithSnapshotsEnabled(t *testing.T, enabled bool) *httptest.Server {
skipIfTFENotEnabled(t)
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Log(r.Method, r.URL.String())
if r.URL.Path == "/state-json" {
t.Log("pretending to be Archivist")
fakeState := states.NewState()
fakeStateFile := statefile.New(fakeState, "boop", 1)
var buf bytes.Buffer
statefile.Write(fakeStateFile, &buf, encryption.StateEncryptionDisabled())
respBody := buf.Bytes()
w.Header().Set("content-type", "application/json")
w.Header().Set("content-length", strconv.FormatInt(int64(len(respBody)), 10))
w.WriteHeader(http.StatusOK)
w.Write(respBody)
return
}
if r.URL.Path == "/api/ping" {
t.Log("pretending to be Ping")
w.WriteHeader(http.StatusNoContent)
return
}
fakeBody := map[string]any{
"data": map[string]any{
"type": "state-versions",
"id": GenerateID("sv-"),
"attributes": map[string]any{
"hosted-state-download-url": serverURL + "/state-json",
"hosted-state-upload-url": serverURL + "/state-json",
},
},
}
fakeBodyRaw, err := json.Marshal(fakeBody)
if err != nil {
t.Fatal(err)
}
w.Header().Set("content-type", tfe.ContentTypeJSONAPI)
w.Header().Set("content-length", strconv.FormatInt(int64(len(fakeBodyRaw)), 10))
switch r.Method {
case "POST":
t.Log("pretending to be Create a State Version")
if enabled {
w.Header().Set("x-terraform-snapshot-interval", "300")
}
w.WriteHeader(http.StatusAccepted)
case "GET":
t.Log("pretending to be Fetch the Current State Version for a Workspace")
if enabled {
w.Header().Set("x-terraform-snapshot-interval", "300")
}
w.WriteHeader(http.StatusOK)
case "PUT":
t.Log("pretending to be Archivist")
default:
t.Fatal("don't know what API operation this was supposed to be")
}
w.WriteHeader(http.StatusOK)
w.Write(fakeBodyRaw)
}))
serverURL = server.URL
return server
}
// testDefaultRequestHandlers is a map of request handlers intended to be used in a request
// multiplexer for a test server. A caller may use testServerWithHandlers to start a server with
// this base set of routes, and override a particular route for whatever edge case is being tested.
var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){
// Respond to service discovery calls.
"/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{
"tfe.v2": "/api/v2/",
}`)
},
// Respond to service version constraints calls.
"/v1/versions/": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, fmt.Sprintf(`{
"service": "%s",
"product": "terraform",
"minimum": "0.1.0",
"maximum": "10.0.0"
}`, path.Base(r.URL.Path)))
},
// Respond to pings to get the API version header.
"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("TFP-API-Version", "2.5")
},
// Respond to the initial query to read the hashicorp org entitlements.
"/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
"id": "org-GExadygjSbKP8hsY",
"type": "entitlement-sets",
"attributes": {
"operations": true,
"private-module-registry": true,
"sentinel": true,
"state-storage": true,
"teams": true,
"vcs-integrations": true
}
}
}`)
},
// Respond to the initial query to read the no-operations org entitlements.
"/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
"id": "org-ufxa3y8jSbKP8hsT",
"type": "entitlement-sets",
"attributes": {
"operations": false,
"private-module-registry": true,
"sentinel": true,
"state-storage": true,
"teams": true,
"vcs-integrations": true
}
}
}`)
},
// All tests that are assumed to pass will use the hashicorp organization,
// so for all other organization requests we will return a 404.
"/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
io.WriteString(w, `{
"errors": [
{
"status": "404",
"title": "not found"
}
]
}`)
},
}
func mockColorize() *colorstring.Colorize {
colors := make(map[string]string)
for k, v := range colorstring.DefaultColors {
colors[k] = v
}
colors["purple"] = "38;5;57"
return &colorstring.Colorize{
Colors: colors,
Disable: false,
Reset: true,
}
}
func mockSROWorkspace(t *testing.T, b *Cloud, workspaceName string) {
_, err := b.client.Workspaces.Update(context.Background(), "hashicorp", workspaceName, tfe.WorkspaceUpdateOptions{
StructuredRunOutputEnabled: tfe.Bool(true),
TerraformVersion: tfe.String("1.4.0"),
})
if err != nil {
t.Fatalf("Error enabling SRO on workspace %s: %v", workspaceName, err)
}
}
// testDisco returns a *disco.Disco mapping app.terraform.io and
// localhost to a local test server.
func testDisco(s *httptest.Server) *disco.Disco {
services := map[string]interface{}{
"tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL),
}
d := disco.NewWithCredentialsSource(credsSrc)
d.SetUserAgent(httpclient.OpenTofuUserAgent(version.String()))
d.ForceHostServices(svchost.Hostname(tfeHost), services)
d.ForceHostServices(svchost.Hostname("localhost"), services)
d.ForceHostServices(svchost.Hostname("nontfe.local"), nil)
return d
}
type unparsedVariableValue struct {
value string
source tofu.ValueSourceType
}
func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*tofu.InputValue, tfdiags.Diagnostics) {
return &tofu.InputValue{
Value: cty.StringVal(v.value),
SourceType: v.source,
}, tfdiags.Diagnostics{}
}
// testVariable returns a backend.UnparsedVariableValue used for testing.
func testVariables(s tofu.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue {
vars := make(map[string]backend.UnparsedVariableValue, len(vs))
for _, v := range vs {
vars[v] = &unparsedVariableValue{
value: v,
source: s,
}
}
return vars
}