|
1 | | -package volume |
| 1 | +package volume_test |
2 | 2 |
|
3 | 3 | import ( |
4 | | - "context" |
5 | 4 | "encoding/json" |
6 | 5 | "fmt" |
7 | 6 | "io" |
8 | 7 | "net/http" |
9 | | - "net/http/httptest" |
10 | 8 | "testing" |
11 | 9 |
|
12 | | - "github.com/hashicorp/terraform-plugin-framework/resource" |
13 | | - "github.com/hashicorp/terraform-plugin-framework/tfsdk" |
14 | | - "github.com/hashicorp/terraform-plugin-framework/types" |
15 | | - "github.com/hashicorp/terraform-plugin-go/tftypes" |
16 | | - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" |
17 | | - iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" |
| 10 | + "github.com/google/uuid" |
| 11 | + "github.com/hashicorp/terraform-plugin-testing/helper/resource" |
18 | 12 |
|
19 | | - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" |
20 | | -) |
| 13 | + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" |
21 | 14 |
|
22 | | -const ( |
23 | | - testProjectId = "4e684f79-a12c-449d-aa89-bcd9d8aafaf2" |
24 | | - testRegion = "eu01" |
25 | | - testVolumeId = "3dee3fb9-59f0-4f97-8eeb-a4da37d05a00" |
26 | | - testKeyPayloadBase64 = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4=" |
| 15 | + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" |
27 | 16 | ) |
28 | 17 |
|
29 | | -// buildCreateRequest builds a resource.CreateRequest from a plan and a config model. |
30 | | -// Terraform populates write-only attribute values only in the config model - never in the plan or state model. |
31 | | -// That's why we need both, the plan model AND config model to build the request. |
32 | | -func buildCreateRequest(ctx context.Context, t *testing.T, schemaResp *resource.SchemaResponse, planModel, configModel *Model) resource.CreateRequest { |
33 | | - t.Helper() |
34 | | - |
35 | | - req := resource.CreateRequest{} |
36 | | - req.Plan = tfsdk.Plan{ |
37 | | - Schema: schemaResp.Schema, |
38 | | - Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), |
39 | | - } |
40 | | - if diags := req.Plan.Set(ctx, planModel); diags.HasError() { |
41 | | - t.Fatalf("Failed to set plan: %v", diags.Errors()) |
42 | | - } |
43 | | - |
44 | | - configScratch := tfsdk.Plan{ |
45 | | - Schema: schemaResp.Schema, |
46 | | - Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), |
47 | | - } |
48 | | - if diags := configScratch.Set(ctx, configModel); diags.HasError() { |
49 | | - t.Fatalf("Failed to set config: %v", diags.Errors()) |
50 | | - } |
51 | | - req.Config = tfsdk.Config{ |
52 | | - Schema: schemaResp.Schema, |
53 | | - Raw: configScratch.Raw, |
54 | | - } |
55 | | - |
56 | | - return req |
57 | | -} |
58 | | - |
59 | | -type volumeFixture struct { |
60 | | - server *httptest.Server |
61 | | - capturedKeyPayload *string |
62 | | - createCalled bool |
63 | | -} |
64 | | - |
65 | | -// newVolumeFixture spins up a mock IaaS API server handling volume creation and the subsequent |
66 | | -// polling of the wait handler. The create handler decodes the request body and records the |
67 | | -// encryption key payload that the provider sent to the API. |
68 | | -func newVolumeFixture(t *testing.T) *volumeFixture { |
69 | | - t.Helper() |
70 | | - fixture := &volumeFixture{} |
71 | | - |
72 | | - mux := http.NewServeMux() |
73 | | - // Create volume |
74 | | - mux.HandleFunc(fmt.Sprintf("POST /v2/projects/%s/regions/%s/volumes", testProjectId, testRegion), func(w http.ResponseWriter, r *http.Request) { |
75 | | - fixture.createCalled = true |
76 | | - body, err := io.ReadAll(r.Body) |
77 | | - if err != nil { |
78 | | - t.Errorf("Failed to read create request body: %v", err) |
79 | | - w.WriteHeader(http.StatusBadRequest) |
80 | | - return |
81 | | - } |
82 | | - var payload iaas.CreateVolumePayload |
83 | | - if err := json.Unmarshal(body, &payload); err != nil { |
84 | | - t.Errorf("Failed to unmarshal create request body: %v", err) |
85 | | - w.WriteHeader(http.StatusBadRequest) |
86 | | - return |
87 | | - } |
88 | | - if payload.EncryptionParameters != nil { |
89 | | - fixture.capturedKeyPayload = payload.EncryptionParameters.KeyPayload |
90 | | - } |
91 | | - |
92 | | - w.Header().Set("content-type", "application/json") |
93 | | - volumeId := testVolumeId |
94 | | - _ = json.NewEncoder(w).Encode(iaas.Volume{Id: &volumeId}) |
95 | | - }) |
96 | | - // Get volume (used by the create wait handler and by mapFields via the response of the wait handler) |
97 | | - mux.HandleFunc(fmt.Sprintf("GET /v2/projects/%s/regions/%s/volumes/%s", testProjectId, testRegion, testVolumeId), func(w http.ResponseWriter, _ *http.Request) { |
98 | | - w.Header().Set("content-type", "application/json") |
99 | | - volumeId := testVolumeId |
100 | | - status := "AVAILABLE" |
101 | | - _ = json.NewEncoder(w).Encode(iaas.Volume{ |
102 | | - Id: &volumeId, |
103 | | - Status: &status, |
104 | | - AvailabilityZone: "eu01-1", |
105 | | - }) |
106 | | - }) |
107 | | - |
108 | | - fixture.server = httptest.NewServer(mux) |
109 | | - t.Cleanup(fixture.server.Close) |
110 | | - return fixture |
111 | | -} |
112 | | - |
113 | | -// newTestVolumeResource builds a volumeResource with the client's URL being set to the mock URL |
114 | | -func newTestVolumeResource(t *testing.T, server *httptest.Server) *volumeResource { |
115 | | - t.Helper() |
116 | | - client, err := iaas.NewAPIClient( |
117 | | - sdkConfig.WithEndpoint(server.URL), |
118 | | - sdkConfig.WithoutAuthentication(), |
| 18 | +// TestCreateWriteOnlyKeyPayload is a regression test for the bug where the write-only key payload |
| 19 | +// was read from the plan model instead of the config model. |
| 20 | +// The test asserts that the value configured via key_payload_base64_wo is actually |
| 21 | +// sent to the API in the create request. |
| 22 | +func TestCreateWriteOnlyKeyPayload(t *testing.T) { |
| 23 | + projectId := uuid.NewString() |
| 24 | + volumeId := uuid.NewString() |
| 25 | + kekKeyId := uuid.NewString() |
| 26 | + kekKeyringId := uuid.NewString() |
| 27 | + const ( |
| 28 | + region = "eu01" |
| 29 | + availabilityZone = "eu01-1" |
| 30 | + name = "test-volume" |
| 31 | + size = 16 |
| 32 | + serviceAccount = "test-sa@sa.stackit.cloud" |
| 33 | + testKeyPayload = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4=" |
| 34 | + volumeStatusCreated = "AVAILABLE" |
119 | 35 | ) |
120 | | - if err != nil { |
121 | | - t.Fatalf("Failed to initialize client: %v", err) |
122 | | - } |
123 | | - return &volumeResource{ |
124 | | - client: client, |
125 | | - providerData: core.ProviderData{ |
126 | | - DefaultRegion: testRegion, |
127 | | - }, |
128 | | - } |
| 36 | + s := testutil.NewMockServer(t) |
| 37 | + t.Cleanup(s.Server.Close) |
| 38 | + tfConfig := fmt.Sprintf(` |
| 39 | +provider "stackit" { |
| 40 | + default_region = "%s" |
| 41 | + iaas_custom_endpoint = "%s" |
| 42 | + service_account_token = "mock-server-needs-no-auth" |
129 | 43 | } |
130 | 44 |
|
131 | | -func encryptionParametersTestModel() *encryptionParametersModel { |
132 | | - return &encryptionParametersModel{ |
133 | | - KekKeyId: types.StringValue("11111111-1111-1111-1111-111111111111"), |
134 | | - KekKeyVersion: types.Int64Value(1), |
135 | | - KekKeyringId: types.StringValue("22222222-2222-2222-2222-222222222222"), |
136 | | - KeyPayloadBase64: types.StringNull(), |
137 | | - KeyPayloadBase64WriteOnly: types.StringNull(), // will be set manually for the config model |
138 | | - KeyPayloadBase64WriteOnlyVersion: types.Int64Value(1), |
139 | | - ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), |
| 45 | +resource "stackit_volume" "volume" { |
| 46 | + project_id = "%s" |
| 47 | + availability_zone = "%s" |
| 48 | + name = "%s" |
| 49 | + size = %d |
| 50 | + encryption_parameters = { |
| 51 | + kek_key_id = "%s" |
| 52 | + kek_key_version = 1 |
| 53 | + kek_keyring_id = "%s" |
| 54 | + key_payload_base64_wo = "%s" |
| 55 | + key_payload_base64_wo_version = 1 |
| 56 | + service_account = "%s" |
140 | 57 | } |
141 | 58 | } |
142 | | - |
143 | | -func baseTestModel() Model { |
144 | | - return Model{ |
145 | | - ProjectId: types.StringValue(testProjectId), |
146 | | - Region: types.StringValue(testRegion), |
147 | | - AvailabilityZone: types.StringValue("eu01-1"), |
148 | | - Name: types.StringValue("test-volume"), |
149 | | - Size: types.Int64Value(16), |
150 | | - Labels: types.MapNull(types.StringType), |
151 | | - Source: types.ObjectNull(sourceTypes), |
| 59 | +`, region, s.Server.URL, projectId, availabilityZone, name, size, kekKeyId, kekKeyringId, testKeyPayload, serviceAccount) |
| 60 | + |
| 61 | + volumeName := name |
| 62 | + volumeSize := int64(size) |
| 63 | + volume := iaas.Volume{ |
| 64 | + Id: &volumeId, |
| 65 | + Status: new(volumeStatusCreated), |
| 66 | + AvailabilityZone: availabilityZone, |
| 67 | + Name: &volumeName, |
| 68 | + Size: &volumeSize, |
152 | 69 | } |
153 | | -} |
154 | 70 |
|
155 | | -// TestCreate_WriteOnlyKeyPayload is a regression test for the bug where the write-only key payload |
156 | | -// was read from the plan model instead of the config model. |
157 | | -// The test asserts that the value configured via key_payload_base64_wo is actually |
158 | | -// sent to the API in the create request. |
159 | | -func TestCreate_WriteOnlyKeyPayload(t *testing.T) { |
160 | | - ctx := context.Background() |
161 | | - |
162 | | - // Usually terraform will only ever write write-only fields in the config model, not the plan. |
163 | | - // Since we're setting the models manually here, we have to ensure this is done correctly. |
164 | | - // Ensuring that the write-only fields never go into the state/plan model is not part of this test's scope here |
165 | | - planModel := baseTestModel() |
166 | | - planModel.EncryptionParameters = encryptionParametersTestModel() |
167 | | - |
168 | | - configModel := baseTestModel() |
169 | | - configEncryptionParams := encryptionParametersTestModel() |
170 | | - configEncryptionParams.KeyPayloadBase64WriteOnly = types.StringValue(testKeyPayloadBase64) |
171 | | - configModel.EncryptionParameters = configEncryptionParams |
172 | | - |
173 | | - fixture := newVolumeFixture(t) |
174 | | - iaasRessource := newTestVolumeResource(t, fixture.server) |
175 | | - |
176 | | - schemaResp := &resource.SchemaResponse{} |
177 | | - iaasRessource.Schema(ctx, resource.SchemaRequest{}, schemaResp) |
178 | | - |
179 | | - req := buildCreateRequest(ctx, t, schemaResp, &planModel, &configModel) |
180 | | - // we have to set an initial empty state so it is != nil |
181 | | - resp := &resource.CreateResponse{} |
182 | | - resp.State = tfsdk.State{ |
183 | | - Schema: schemaResp.Schema, |
184 | | - Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), |
| 71 | + var capturedKeyPayload *string |
| 72 | + createCalled := false |
| 73 | + createVolume := testutil.MockResponse{ |
| 74 | + Description: "create", |
| 75 | + Handler: func(w http.ResponseWriter, req *http.Request) { |
| 76 | + expected := fmt.Sprintf("/v2/projects/%s/regions/%s/volumes", projectId, region) |
| 77 | + if req.URL.Path != expected { |
| 78 | + t.Errorf("expected request to %s, got %s", expected, req.URL.Path) |
| 79 | + } |
| 80 | + createCalled = true |
| 81 | + body, err := io.ReadAll(req.Body) |
| 82 | + if err != nil { |
| 83 | + t.Errorf("failed to read create request body: %v", err) |
| 84 | + w.WriteHeader(http.StatusBadRequest) |
| 85 | + return |
| 86 | + } |
| 87 | + var payload iaas.CreateVolumePayload |
| 88 | + if err := json.Unmarshal(body, &payload); err != nil { |
| 89 | + t.Errorf("failed to unmarshal create request body: %v", err) |
| 90 | + w.WriteHeader(http.StatusBadRequest) |
| 91 | + return |
| 92 | + } |
| 93 | + if payload.EncryptionParameters != nil { |
| 94 | + capturedKeyPayload = payload.EncryptionParameters.KeyPayload |
| 95 | + } |
| 96 | + |
| 97 | + w.Header().Set("content-type", "application/json") |
| 98 | + _ = json.NewEncoder(w).Encode(iaas.Volume{Id: &volumeId}) |
| 99 | + }, |
185 | 100 | } |
186 | 101 |
|
187 | | - iaasRessource.Create(ctx, req, resp) |
| 102 | + resource.UnitTest(t, resource.TestCase{ |
| 103 | + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, |
| 104 | + Steps: []resource.TestStep{ |
| 105 | + { |
| 106 | + PreConfig: func() { |
| 107 | + s.Reset( |
| 108 | + createVolume, |
| 109 | + testutil.MockResponse{Description: "create waiter", ToJsonBody: volume}, |
| 110 | + testutil.MockResponse{Description: "get", ToJsonBody: volume}, |
| 111 | + testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, |
| 112 | + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusNotFound}, |
| 113 | + ) |
| 114 | + }, |
| 115 | + Config: tfConfig, |
| 116 | + Check: resource.ComposeTestCheckFunc( |
| 117 | + resource.TestCheckResourceAttr("stackit_volume.volume", "volume_id", volumeId), |
| 118 | + resource.TestCheckResourceAttr("stackit_volume.volume", "region", region), |
| 119 | + resource.TestCheckNoResourceAttr("stackit_volume.volume", "encryption_parameters.key_payload_base64_wo"), |
| 120 | + resource.TestCheckResourceAttr("stackit_volume.volume", "encryption_parameters.key_payload_base64_wo_version", "1"), |
| 121 | + ), |
| 122 | + }, |
| 123 | + }, |
| 124 | + }) |
188 | 125 |
|
189 | | - if resp.Diagnostics.HasError() { |
190 | | - t.Fatalf("Create should succeed, but got errors: %v", resp.Diagnostics.Errors()) |
191 | | - } |
192 | | - if !fixture.createCalled { |
| 126 | + if !createCalled { |
193 | 127 | t.Fatalf("Expected the create endpoint to be called") |
194 | 128 | } |
195 | | - |
196 | | - if fixture.capturedKeyPayload == nil { |
197 | | - t.Fatalf("Expected key payload %q to be sent to the API, but none was sent", testKeyPayloadBase64) |
| 129 | + if capturedKeyPayload == nil { |
| 130 | + t.Fatalf("Expected key payload %q to be sent to the API, but none was sent", testKeyPayload) |
198 | 131 | } |
199 | | - if *fixture.capturedKeyPayload != testKeyPayloadBase64 { |
200 | | - t.Fatalf("Wrong key payload sent to the API: expected %q, got %q", testKeyPayloadBase64, *fixture.capturedKeyPayload) |
| 132 | + if *capturedKeyPayload != testKeyPayload { |
| 133 | + t.Fatalf("Wrong key payload sent to the API: expected %q, got %q", testKeyPayload, *capturedKeyPayload) |
201 | 134 | } |
202 | 135 | } |
0 commit comments