From c340cf729d328ec44c067974accc36b17fa9a37d Mon Sep 17 00:00:00 2001 From: Jonas Schlecht Date: Fri, 14 Aug 2026 13:08:50 +0200 Subject: [PATCH 1/2] fix(iaas): read write only fields from config model Relates to STACKITTPR-782 --- CONTRIBUTING.md | 9 + .../internal/services/iaas/volume/resource.go | 32 ++- .../services/iaas/volume/resource_test.go | 252 +++++++++++++++--- 3 files changed, 243 insertions(+), 50 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc36124d1..fe9c02eae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Your contribution is welcome! Thank you for your interest in contributing to the - [Repository structure](#repository-structure) - [Implementing a new resource](#implementing-a-new-resource) - [Resource file structure](#resource-file-structure) + - [Implementing write-only attributes](#implementing-write-only-attributes) - [Implementing a new datasource](#implementing-a-new-datasource) - [Onboarding a new STACKIT service](#onboarding-a-new-stackit-service) - [Implementing IAM Role Bindings](#implementing-iam-role-bindings) @@ -66,6 +67,14 @@ https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/doc If the new resource `bar` is the first resource in the TFP using a STACKIT service `foo`, please refer to [Onboarding a new STACKIT service](./CONTRIBUTING.md/#onboarding-a-new-stackit-service). +#### Implementing write-only attributes + +When implementing [write-only attributes](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) (supported in Terraform 1.11.0 and later), keep in mind that Terraform never populates write-only values in the plan or state models. They are only available in the config model, i.e. they must be read via `req.Config.Get(...)` in the `Create`/`Update` handlers, while all other values are read from the plan model as usual. + +You can find a reference implementation of write-only attributes (including the accompanying `_wo_version` rotation counter pattern) in the VPN connection resource: + +https://github.com/stackitcloud/terraform-provider-stackit/blob/main/stackit/internal/services/vpn/connection/resource.go + ### Implementing a new datasource The process to implement a new datasource is similar to [implementing a new resource](#implementing-a-new-resource). Some differences worth noting are: diff --git a/stackit/internal/services/iaas/volume/resource.go b/stackit/internal/services/iaas/volume/resource.go index 4fe39e8b1..109cf36d0 100644 --- a/stackit/internal/services/iaas/volume/resource.go +++ b/stackit/internal/services/iaas/volume/resource.go @@ -433,6 +433,16 @@ func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest, return } + // The config model - this has to be used because Terraform doesn't include write-only field values in the + // plan and state models - for security measures. Write-only values should be only kept in the config model + // so that they never end up in the state (or plan). + var configModel Model + diags = req.Config.Get(ctx, &configModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx = core.InitProviderContext(ctx) projectId := model.ProjectId.ValueString() @@ -450,7 +460,7 @@ func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest, } // Generate API request body from model - payload, err := toCreatePayload(ctx, &model, source) + payload, err := toCreatePayload(ctx, &model, &configModel, source) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Creating API payload: %v", err)) return @@ -753,10 +763,13 @@ func mapFields(ctx context.Context, volumeResp *iaas.Volume, model *Model, regio return nil } -func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) { +func toCreatePayload(ctx context.Context, model, configModel *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } + if configModel == nil { + return nil, fmt.Errorf("nil config model") + } labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) if err != nil { @@ -782,12 +795,19 @@ func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*i Source: sourcePayload, } - if model.EncryptionParameters != nil { + if model.EncryptionParameters != nil && configModel.EncryptionParameters != nil { + // Terraform keeps the write-only field values in the config model - and they shouldn't leave this config model + // to make sure they don't end up being stored in the state. In the plan model the write-only field values are just + // empty. That's why write-only field values must be read from the config model. Everything else comes from the + // plan model. var keyPayload *string - if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64WriteOnly) { - keyPayload = conversion.StringValueToPointer(model.EncryptionParameters.KeyPayloadBase64WriteOnly) - } else if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64) { + if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64) { + // handle the legacy fallback logic keyPayload = conversion.StringValueToPointer(model.EncryptionParameters.KeyPayloadBase64) + } else if !utils.IsUndefined(configModel.EncryptionParameters.KeyPayloadBase64WriteOnly) && + !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64WriteOnlyVersion) { + // the user is using the write-only field + keyPayload = conversion.StringValueToPointer(configModel.EncryptionParameters.KeyPayloadBase64WriteOnly) } payload.EncryptionParameters = &iaas.VolumeEncryptionParameter{ diff --git a/stackit/internal/services/iaas/volume/resource_test.go b/stackit/internal/services/iaas/volume/resource_test.go index 511a2a2fd..74d715fcf 100644 --- a/stackit/internal/services/iaas/volume/resource_test.go +++ b/stackit/internal/services/iaas/volume/resource_test.go @@ -186,32 +186,39 @@ func TestMapFields(t *testing.T) { } func TestToCreatePayload(t *testing.T) { + type args struct { + planModel *Model + configModel *Model + source *sourceModel + } tests := []struct { description string - input *Model - source *sourceModel + args args expected *iaas.CreateVolumePayload isValid bool }{ { description: "no volume encryption", - input: &Model{ - Name: types.StringValue("name"), - AvailabilityZone: types.StringValue("zone"), - Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ - "key": types.StringValue("value"), - }), - Description: types.StringValue("desc"), - PerformanceClass: types.StringValue("class"), - Size: types.Int64Value(1), - Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{ - "type": types.StringNull(), - "id": types.StringNull(), - }), - }, - source: &sourceModel{ - Type: types.StringValue("volume"), - Id: types.StringValue("id"), + args: args{ + planModel: &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + PerformanceClass: types.StringValue("class"), + Size: types.Int64Value(1), + Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{ + "type": types.StringNull(), + "id": types.StringNull(), + }), + }, + configModel: &Model{}, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, }, expected: &iaas.CreateVolumePayload{ Name: new("name"), @@ -231,19 +238,24 @@ func TestToCreatePayload(t *testing.T) { }, { description: "with volume encryption without key payload", - input: &Model{ - Labels: types.MapNull(types.StringType), - EncryptionParameters: &encryptionParametersModel{ - KekKeyId: types.StringValue("kek-key-id"), - KekKeyVersion: types.Int64Value(int64(1)), - KekKeyringId: types.StringValue("kek-keyring-id"), - KeyPayloadBase64: types.StringNull(), - ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + args: args{ + planModel: &Model{ + Labels: types.MapNull(types.StringType), + EncryptionParameters: &encryptionParametersModel{ + KekKeyId: types.StringValue("kek-key-id"), + KekKeyVersion: types.Int64Value(int64(1)), + KekKeyringId: types.StringValue("kek-keyring-id"), + KeyPayloadBase64: types.StringNull(), + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + }, + }, + configModel: &Model{ + EncryptionParameters: &encryptionParametersModel{}, + }, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), }, - }, - source: &sourceModel{ - Type: types.StringValue("volume"), - Id: types.StringValue("id"), }, expected: &iaas.CreateVolumePayload{ Source: &iaas.VolumeSource{ @@ -263,20 +275,109 @@ func TestToCreatePayload(t *testing.T) { isValid: true, }, { - description: "with volume encryption including key payload", - input: &Model{ - Labels: types.MapNull(types.StringType), - EncryptionParameters: &encryptionParametersModel{ - KekKeyId: types.StringValue("kek-key-id"), - KekKeyVersion: types.Int64Value(int64(1)), - KekKeyringId: types.StringValue("kek-keyring-id"), - KeyPayloadBase64: types.StringValue("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="), // The quick brown fox jumps over 13 lazy dogs. - ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + description: "with volume encryption including key payload via legacy field", + args: args{ + planModel: &Model{ + Labels: types.MapNull(types.StringType), + EncryptionParameters: &encryptionParametersModel{ + KekKeyId: types.StringValue("kek-key-id"), + KekKeyVersion: types.Int64Value(int64(1)), + KekKeyringId: types.StringValue("kek-keyring-id"), + KeyPayloadBase64: types.StringValue("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="), // The quick brown fox jumps over 13 lazy dogs. + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + }, + }, + configModel: &Model{ + EncryptionParameters: &encryptionParametersModel{}, + }, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, + }, + expected: &iaas.CreateVolumePayload{ + Source: &iaas.VolumeSource{ + Type: "volume", + Id: "id", + }, + Labels: map[string]any{}, + EncryptionParameters: &iaas.VolumeEncryptionParameter{ + KekKeyId: "kek-key-id", + KekKeyVersion: int64(1), + KekKeyringId: "kek-keyring-id", + KekProjectId: nil, + KeyPayload: new("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="), + ServiceAccount: "test-sa@sa.stackit.cloud", + }, + }, + isValid: true, + }, + { + description: "with volume encryption including key payload via legacy field which takes precedence over write-only field", + args: args{ + planModel: &Model{ + Labels: types.MapNull(types.StringType), + EncryptionParameters: &encryptionParametersModel{ + KekKeyId: types.StringValue("kek-key-id"), + KekKeyVersion: types.Int64Value(int64(1)), + KekKeyringId: types.StringValue("kek-keyring-id"), + KeyPayloadBase64: types.StringValue("bGVnYWN5LWtleS1wYXlsb2Fk"), + KeyPayloadBase64WriteOnly: types.StringNull(), + KeyPayloadBase64WriteOnlyVersion: types.Int64Value(1), + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + }, + }, + configModel: &Model{ + EncryptionParameters: &encryptionParametersModel{ + KeyPayloadBase64WriteOnly: types.StringValue("d3JpdGUtb25seS1rZXktcGF5bG9hZA=="), + }, + }, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, + }, + expected: &iaas.CreateVolumePayload{ + Source: &iaas.VolumeSource{ + Type: "volume", + Id: "id", + }, + Labels: map[string]any{}, + EncryptionParameters: &iaas.VolumeEncryptionParameter{ + KekKeyId: "kek-key-id", + KekKeyVersion: int64(1), + KekKeyringId: "kek-keyring-id", + KekProjectId: nil, + KeyPayload: new("bGVnYWN5LWtleS1wYXlsb2Fk"), + ServiceAccount: "test-sa@sa.stackit.cloud", }, }, - source: &sourceModel{ - Type: types.StringValue("volume"), - Id: types.StringValue("id"), + isValid: true, + }, + { + description: "with volume encryption including key payload via write-only field together with write-only version", + args: args{ + planModel: &Model{ + Labels: types.MapNull(types.StringType), + EncryptionParameters: &encryptionParametersModel{ + KekKeyId: types.StringValue("kek-key-id"), + KekKeyVersion: types.Int64Value(int64(1)), + KekKeyringId: types.StringValue("kek-keyring-id"), + KeyPayloadBase64: types.StringNull(), + KeyPayloadBase64WriteOnly: types.StringNull(), + KeyPayloadBase64WriteOnlyVersion: types.Int64Value(1), + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + }, + }, + configModel: &Model{ + EncryptionParameters: &encryptionParametersModel{ + KeyPayloadBase64WriteOnly: types.StringValue("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="), // The quick brown fox jumps over 13 lazy dogs. + }, + }, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, }, expected: &iaas.CreateVolumePayload{ Source: &iaas.VolumeSource{ @@ -295,10 +396,73 @@ func TestToCreatePayload(t *testing.T) { }, isValid: true, }, + { + description: "with volume encryption including key payload via write-only field but write-only version not set", + args: args{ + planModel: &Model{ + Labels: types.MapNull(types.StringType), + EncryptionParameters: &encryptionParametersModel{ + KekKeyId: types.StringValue("kek-key-id"), + KekKeyVersion: types.Int64Value(int64(1)), + KekKeyringId: types.StringValue("kek-keyring-id"), + KeyPayloadBase64: types.StringNull(), + KeyPayloadBase64WriteOnly: types.StringNull(), + KeyPayloadBase64WriteOnlyVersion: types.Int64Null(), + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + }, + }, + configModel: &Model{ + EncryptionParameters: &encryptionParametersModel{ + KeyPayloadBase64WriteOnly: types.StringValue("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="), // The quick brown fox jumps over 13 lazy dogs. + }, + }, + source: &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, + }, + expected: &iaas.CreateVolumePayload{ + Source: &iaas.VolumeSource{ + Type: "volume", + Id: "id", + }, + Labels: map[string]any{}, + EncryptionParameters: &iaas.VolumeEncryptionParameter{ + KekKeyId: "kek-key-id", + KekKeyVersion: int64(1), + KekKeyringId: "kek-keyring-id", + KekProjectId: nil, + // must be nil because the write-only version is not set + KeyPayload: nil, + ServiceAccount: "test-sa@sa.stackit.cloud", + }, + }, + isValid: true, + }, + { + description: "plan model is nil", + args: args{ + planModel: nil, + configModel: &Model{}, + source: &sourceModel{}, + }, + expected: nil, + isValid: false, + }, + { + description: "config model is nil", + args: args{ + planModel: &Model{}, + configModel: nil, + source: &sourceModel{}, + }, + expected: nil, + isValid: false, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(context.Background(), tt.input, tt.source) + output, err := toCreatePayload(context.Background(), tt.args.planModel, tt.args.configModel, tt.args.source) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } From 2c2559d35e388b6cb336fe883f69d37ce8bc4aea Mon Sep 17 00:00:00 2001 From: Jonas Schlecht Date: Mon, 17 Aug 2026 09:41:08 +0200 Subject: [PATCH 2/2] test(iaas): add mock-based test to verify write only fields functionality Relates to STACKITTPR-782 --- .../iaas/volume/resource_create_test.go | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 stackit/internal/services/iaas/volume/resource_create_test.go diff --git a/stackit/internal/services/iaas/volume/resource_create_test.go b/stackit/internal/services/iaas/volume/resource_create_test.go new file mode 100644 index 000000000..c2c569101 --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource_create_test.go @@ -0,0 +1,202 @@ +package volume + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" +) + +const ( + testProjectId = "4e684f79-a12c-449d-aa89-bcd9d8aafaf2" + testRegion = "eu01" + testVolumeId = "3dee3fb9-59f0-4f97-8eeb-a4da37d05a00" + testKeyPayloadBase64 = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4=" +) + +// buildCreateRequest builds a resource.CreateRequest from a plan and a config model. +// Terraform populates write-only attribute values only in the config model - never in the plan or state model. +// That's why we need both, the plan model AND config model to build the request. +func buildCreateRequest(ctx context.Context, t *testing.T, schemaResp *resource.SchemaResponse, planModel, configModel *Model) resource.CreateRequest { + t.Helper() + + req := resource.CreateRequest{} + req.Plan = tfsdk.Plan{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + if diags := req.Plan.Set(ctx, planModel); diags.HasError() { + t.Fatalf("Failed to set plan: %v", diags.Errors()) + } + + configScratch := tfsdk.Plan{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + if diags := configScratch.Set(ctx, configModel); diags.HasError() { + t.Fatalf("Failed to set config: %v", diags.Errors()) + } + req.Config = tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: configScratch.Raw, + } + + return req +} + +type volumeFixture struct { + server *httptest.Server + capturedKeyPayload *string + createCalled bool +} + +// newVolumeFixture spins up a mock IaaS API server handling volume creation and the subsequent +// polling of the wait handler. The create handler decodes the request body and records the +// encryption key payload that the provider sent to the API. +func newVolumeFixture(t *testing.T) *volumeFixture { + t.Helper() + fixture := &volumeFixture{} + + mux := http.NewServeMux() + // Create volume + mux.HandleFunc(fmt.Sprintf("POST /v2/projects/%s/regions/%s/volumes", testProjectId, testRegion), func(w http.ResponseWriter, r *http.Request) { + fixture.createCalled = true + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("Failed to read create request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + var payload iaas.CreateVolumePayload + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("Failed to unmarshal create request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if payload.EncryptionParameters != nil { + fixture.capturedKeyPayload = payload.EncryptionParameters.KeyPayload + } + + w.Header().Set("content-type", "application/json") + volumeId := testVolumeId + _ = json.NewEncoder(w).Encode(iaas.Volume{Id: &volumeId}) + }) + // Get volume (used by the create wait handler and by mapFields via the response of the wait handler) + mux.HandleFunc(fmt.Sprintf("GET /v2/projects/%s/regions/%s/volumes/%s", testProjectId, testRegion, testVolumeId), func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + volumeId := testVolumeId + status := "AVAILABLE" + _ = json.NewEncoder(w).Encode(iaas.Volume{ + Id: &volumeId, + Status: &status, + AvailabilityZone: "eu01-1", + }) + }) + + fixture.server = httptest.NewServer(mux) + t.Cleanup(fixture.server.Close) + return fixture +} + +// newTestVolumeResource builds a volumeResource with the client's URL being set to the mock URL +func newTestVolumeResource(t *testing.T, server *httptest.Server) *volumeResource { + t.Helper() + client, err := iaas.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + return &volumeResource{ + client: client, + providerData: core.ProviderData{ + DefaultRegion: testRegion, + }, + } +} + +func encryptionParametersTestModel() *encryptionParametersModel { + return &encryptionParametersModel{ + KekKeyId: types.StringValue("11111111-1111-1111-1111-111111111111"), + KekKeyVersion: types.Int64Value(1), + KekKeyringId: types.StringValue("22222222-2222-2222-2222-222222222222"), + KeyPayloadBase64: types.StringNull(), + KeyPayloadBase64WriteOnly: types.StringNull(), // will be set manually for the config model + KeyPayloadBase64WriteOnlyVersion: types.Int64Value(1), + ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"), + } +} + +func baseTestModel() Model { + return Model{ + ProjectId: types.StringValue(testProjectId), + Region: types.StringValue(testRegion), + AvailabilityZone: types.StringValue("eu01-1"), + Name: types.StringValue("test-volume"), + Size: types.Int64Value(16), + Labels: types.MapNull(types.StringType), + Source: types.ObjectNull(sourceTypes), + } +} + +// TestCreate_WriteOnlyKeyPayload is a regression test for the bug where the write-only key payload +// was read from the plan model instead of the config model. +// The test asserts that the value configured via key_payload_base64_wo is actually +// sent to the API in the create request. +func TestCreate_WriteOnlyKeyPayload(t *testing.T) { + ctx := context.Background() + + // Usually terraform will only ever write write-only fields in the config model, not the plan. + // Since we're setting the models manually here, we have to ensure this is done correctly. + // Ensuring that the write-only fields never go into the state/plan model is not part of this test's scope here + planModel := baseTestModel() + planModel.EncryptionParameters = encryptionParametersTestModel() + + configModel := baseTestModel() + configEncryptionParams := encryptionParametersTestModel() + configEncryptionParams.KeyPayloadBase64WriteOnly = types.StringValue(testKeyPayloadBase64) + configModel.EncryptionParameters = configEncryptionParams + + fixture := newVolumeFixture(t) + iaasRessource := newTestVolumeResource(t, fixture.server) + + schemaResp := &resource.SchemaResponse{} + iaasRessource.Schema(ctx, resource.SchemaRequest{}, schemaResp) + + req := buildCreateRequest(ctx, t, schemaResp, &planModel, &configModel) + // we have to set an initial empty state so it is != nil + resp := &resource.CreateResponse{} + resp.State = tfsdk.State{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + + iaasRessource.Create(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("Create should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + if !fixture.createCalled { + t.Fatalf("Expected the create endpoint to be called") + } + + if fixture.capturedKeyPayload == nil { + t.Fatalf("Expected key payload %q to be sent to the API, but none was sent", testKeyPayloadBase64) + } + if *fixture.capturedKeyPayload != testKeyPayloadBase64 { + t.Fatalf("Wrong key payload sent to the API: expected %q, got %q", testKeyPayloadBase64, *fixture.capturedKeyPayload) + } +}