diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 78bf6e4..d718a1d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -43,7 +43,7 @@ jobs: - name: Test run: | echo "provider_installation { dev_overrides { \"registry.terraform.io/ably/ably\" = \"$PWD/bin\", } direct {} }" > ~/.terraformrc - TF_ACC=1 go test -v -parallel 4 ./... + TF_ACC=1 go test -timeout 20m -v -parallel 4 ./... env: ABLY_ACCOUNT_TOKEN: ${{ secrets.ABLY_ACCOUNT_TOKEN }} ABLY_URL: 'https://staging-control.ably-dev.net/v1' diff --git a/internal/provider/ingress_rules.go b/internal/provider/ingress_rules.go index 12e8170..75c3f1f 100644 --- a/internal/provider/ingress_rules.go +++ b/internal/provider/ingress_rules.go @@ -69,8 +69,13 @@ func GetPlanIngressRule(plan AblyIngressRule) (any, diag.Diagnostics) { // GetIngressRuleResponse maps an API rule response to the ingress rule terraform model. // Ingress rules use the same generic RuleResponse from the client, with target unmarshalled // according to the ruleType. -func GetIngressRuleResponse(ablyRule *control.RuleResponse, plan *AblyIngressRule) (AblyIngressRule, diag.Diagnostics) { +func GetIngressRuleResponse(ablyRule *control.RuleResponse, plan *AblyIngressRule, reading bool) (AblyIngressRule, diag.Diagnostics) { var diags diag.Diagnostics + rc := newReconciler(&diags) + if reading { + rc.forRead() + } + var respTarget any switch ablyRule.RuleType { @@ -80,14 +85,18 @@ func GetIngressRuleResponse(ablyRule *control.RuleResponse, plan *AblyIngressRul diags.AddError("Error unmarshalling ingress rule target", fmt.Sprintf("Could not unmarshal ingress/mongodb target: %s", err.Error())) return AblyIngressRule{}, diags } + var pt *AblyIngressRuleTargetMongo + if p, ok := plan.Target.(*AblyIngressRuleTargetMongo); ok { + pt = p + } respTarget = &AblyIngressRuleTargetMongo{ - Url: types.StringValue(target.URL), - Database: types.StringValue(target.Database), - Collection: types.StringValue(target.Collection), - Pipeline: types.StringValue(target.Pipeline), - FullDocument: types.StringValue(target.FullDocument), - FullDocumentBeforeChange: types.StringValue(target.FullDocumentBeforeChange), - PrimarySite: types.StringValue(target.PrimarySite), + Url: rc.str("target.url", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.Url }), types.StringValue(target.URL), false), + Database: rc.str("target.database", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.Database }), types.StringValue(target.Database), false), + Collection: rc.str("target.collection", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.Collection }), types.StringValue(target.Collection), false), + Pipeline: rc.str("target.pipeline", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.Pipeline }), types.StringValue(target.Pipeline), false), + FullDocument: rc.str("target.full_document", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.FullDocument }), types.StringValue(target.FullDocument), false), + FullDocumentBeforeChange: rc.str("target.full_document_before_change", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.FullDocumentBeforeChange }), types.StringValue(target.FullDocumentBeforeChange), false), + PrimarySite: rc.str("target.primary_site", planStr(pt, func(t *AblyIngressRuleTargetMongo) types.String { return t.PrimarySite }), types.StringValue(target.PrimarySite), false), } case "ingress-postgres-outbox": target, err := unmarshalTarget[control.IngressPostgresOutboxTarget](ablyRule.Target) @@ -95,15 +104,19 @@ func GetIngressRuleResponse(ablyRule *control.RuleResponse, plan *AblyIngressRul diags.AddError("Error unmarshalling ingress rule target", fmt.Sprintf("Could not unmarshal ingress-postgres-outbox target: %s", err.Error())) return AblyIngressRule{}, diags } + var pt *AblyIngressRuleTargetPostgresOutbox + if p, ok := plan.Target.(*AblyIngressRuleTargetPostgresOutbox); ok { + pt = p + } respTarget = &AblyIngressRuleTargetPostgresOutbox{ - Url: types.StringValue(target.URL), - OutboxTableSchema: types.StringValue(target.OutboxTableSchema), - OutboxTableName: types.StringValue(target.OutboxTableName), - NodesTableSchema: types.StringValue(target.NodesTableSchema), - NodesTableName: types.StringValue(target.NodesTableName), - SslMode: types.StringValue(target.SSLMode), - SslRootCert: optStringValue(target.SSLRootCert), - PrimarySite: types.StringValue(target.PrimarySite), + Url: rc.str("target.url", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.Url }), types.StringValue(target.URL), false), + OutboxTableSchema: rc.str("target.outbox_table_schema", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.OutboxTableSchema }), types.StringValue(target.OutboxTableSchema), false), + OutboxTableName: rc.str("target.outbox_table_name", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.OutboxTableName }), types.StringValue(target.OutboxTableName), false), + NodesTableSchema: rc.str("target.nodes_table_schema", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.NodesTableSchema }), types.StringValue(target.NodesTableSchema), false), + NodesTableName: rc.str("target.nodes_table_name", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.NodesTableName }), types.StringValue(target.NodesTableName), false), + SslMode: rc.str("target.ssl_mode", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.SslMode }), types.StringValue(target.SSLMode), false), + SslRootCert: rc.str("target.ssl_root_cert", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.SslRootCert }), optStringValue(target.SSLRootCert), false), + PrimarySite: rc.str("target.primary_site", planStr(pt, func(t *AblyIngressRuleTargetPostgresOutbox) types.String { return t.PrimarySite }), types.StringValue(target.PrimarySite), false), } default: diags.AddError( @@ -114,9 +127,9 @@ func GetIngressRuleResponse(ablyRule *control.RuleResponse, plan *AblyIngressRul } respRule := AblyIngressRule{ - ID: types.StringValue(ablyRule.ID), - AppID: types.StringValue(ablyRule.AppID), - Status: types.StringValue(ablyRule.Status), + ID: rc.str("id", plan.ID, types.StringValue(ablyRule.ID), true), + AppID: rc.str("app_id", plan.AppID, types.StringValue(ablyRule.AppID), false), + Status: rc.str("status", plan.Status, types.StringValue(ablyRule.Status), true), Target: respTarget, } @@ -188,7 +201,7 @@ func CreateIngressRule[T any](r Rule, ctx context.Context, req resource.CreateRe return } - responseValues, respDiags := GetIngressRuleResponse(&rule, &plan) + responseValues, respDiags := GetIngressRuleResponse(&rule, &plan, false) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return @@ -232,7 +245,7 @@ func ReadIngressRule[T any](r Rule, ctx context.Context, req resource.ReadReques return } - responseValues, respDiags := GetIngressRuleResponse(&rule, &state) + responseValues, respDiags := GetIngressRuleResponse(&rule, &state, true) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return @@ -278,7 +291,7 @@ func UpdateIngressRule[T any](r Rule, ctx context.Context, req resource.UpdateRe return } - responseValues, respDiags := GetIngressRuleResponse(&rule, &plan) + responseValues, respDiags := GetIngressRuleResponse(&rule, &plan, false) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return diff --git a/internal/provider/modifiers.go b/internal/provider/modifiers.go index d2d8921..05893d8 100644 --- a/internal/provider/modifiers.go +++ b/internal/provider/modifiers.go @@ -24,13 +24,12 @@ func (m defaultBoolModifier) MarkdownDescription(ctx context.Context) string { } func (m defaultBoolModifier) PlanModifyBool(_ context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { - if resp.PlanValue.IsUnknown() || req.ConfigValue.IsUnknown() { + if req.ConfigValue.IsUnknown() { return } - if !req.ConfigValue.IsNull() || !req.PlanValue.IsNull() { - return + if req.ConfigValue.IsNull() && (resp.PlanValue.IsNull() || resp.PlanValue.IsUnknown()) { + resp.PlanValue = m.value } - resp.PlanValue = m.value } // DefaultBoolAttribute returns a plan modifier that sets a default bool value. @@ -52,13 +51,12 @@ func (m defaultInt64Modifier) MarkdownDescription(ctx context.Context) string { } func (m defaultInt64Modifier) PlanModifyInt64(_ context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { - if resp.PlanValue.IsUnknown() || req.ConfigValue.IsUnknown() { + if req.ConfigValue.IsUnknown() { return } - if !req.ConfigValue.IsNull() || !req.PlanValue.IsNull() { - return + if req.ConfigValue.IsNull() && (resp.PlanValue.IsNull() || resp.PlanValue.IsUnknown()) { + resp.PlanValue = m.value } - resp.PlanValue = m.value } // DefaultInt64Attribute returns a plan modifier that sets a default int64 value. @@ -80,13 +78,15 @@ func (m defaultStringModifier) MarkdownDescription(ctx context.Context) string { } func (m defaultStringModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { - if resp.PlanValue.IsUnknown() || req.ConfigValue.IsUnknown() { + if req.ConfigValue.IsUnknown() { return } - if !req.ConfigValue.IsNull() || !req.PlanValue.IsNull() { - return + // Set default when config is null and the plan doesn't already carry + // a concrete value (e.g. from prior state). This covers both Create + // (PlanValue is Unknown for Computed fields) and Update with null state. + if req.ConfigValue.IsNull() && (resp.PlanValue.IsNull() || resp.PlanValue.IsUnknown()) { + resp.PlanValue = m.value } - resp.PlanValue = m.value } // DefaultStringAttribute returns a plan modifier that sets a default string value. diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index bc5f5b8..438a244 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -2,6 +2,7 @@ package provider import ( + "fmt" "os" "testing" @@ -22,3 +23,53 @@ func testAccPreCheck(t *testing.T) { t.Fatal("ABLY_ACCOUNT_TOKEN must be set for acceptance tests") } } + +// tfProvider is the shared Terraform provider configuration block used by +// minimal-config acceptance tests across resource test files. +const tfProvider = ` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} +` + +// minimalRuleConfig builds an HCL config with only required fields for a rule +// resource. The targetHCL argument is the target block content specific to +// each rule type. +func minimalRuleConfig(appName, resourceType, targetHCL string) string { + return fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource %q "rule0" { + app_id = ably_app.app0.id + + source = { + type = "channel.message" + } + + %s +} +`, tfProvider, appName, resourceType, targetHCL) +} + +// minimalIngressRuleConfig builds an HCL config with only required fields for +// an ingress rule resource. +func minimalIngressRuleConfig(appName, resourceType, targetHCL string) string { + return fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource %q "rule0" { + app_id = ably_app.app0.id + + %s +} +`, tfProvider, appName, resourceType, targetHCL) +} diff --git a/internal/provider/reconcile.go b/internal/provider/reconcile.go new file mode 100644 index 0000000..ed56994 --- /dev/null +++ b/internal/provider/reconcile.go @@ -0,0 +1,249 @@ +package provider + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// reconciler accumulates diagnostics so callers can reconcile many fields +// without checking errors after each one. +// +// When reading is true (set via forRead), every field is treated as computed +// so the API response is always accepted. This is necessary because during +// Read (including import), the prior state may be empty — there is no user +// plan to compare against. +type reconciler struct { + diags *diag.Diagnostics + reading bool +} + +func newReconciler(diags *diag.Diagnostics) *reconciler { + return &reconciler{diags: diags} +} + +func (r *reconciler) forRead() *reconciler { + r.reading = true + return r +} + +func (r *reconciler) str(field string, input, output types.String, computed bool) types.String { + v, err := reconcileString(field, input, output, computed || r.reading) + if err != nil { + r.diags.AddError("State reconciliation error", err.Error()) + } + return v +} + +func (r *reconciler) boolean(field string, input, output types.Bool, computed bool) types.Bool { + v, err := reconcileBool(field, input, output, computed || r.reading) + if err != nil { + r.diags.AddError("State reconciliation error", err.Error()) + } + return v +} + +func (r *reconciler) int64val(field string, input, output types.Int64, computed bool) types.Int64 { + v, err := reconcileInt64(field, input, output, computed || r.reading) + if err != nil { + r.diags.AddError("State reconciliation error", err.Error()) + } + return v +} + +// reconcileSlice reconciles a plan/state slice with an API response slice. +// Go does not allow generic methods on structs, so this is a standalone helper +// that takes the reconciler for diagnostics and reading mode. +func reconcileSlice[T any](field string, input, output []T, computed bool) ([]T, error) { + inputEmpty := len(input) == 0 + outputEmpty := len(output) == 0 + + switch { + case !inputEmpty && !outputEmpty: + return output, nil + case !inputEmpty && outputEmpty: + return input, nil + case inputEmpty && !outputEmpty: + if computed { + return output, nil + } + return nil, fmt.Errorf( + "reconcile %q: API returned %d elements but field was not set in config and is not computed", + field, len(output), + ) + default: + return nil, nil + } +} + +// rcSlice is the reconciler-aware wrapper for reconcileSlice. +func rcSlice[T any](rc *reconciler, field string, input, output []T, computed bool) []T { + v, err := reconcileSlice(field, input, output, computed || rc.reading) + if err != nil { + rc.diags.AddError("State reconciliation error", err.Error()) + } + return v +} + +// reconcileMapSet reconciles a plan/state map[string]types.Set with an API response map. +func reconcileMapSet(field string, input, output map[string]types.Set, computed bool) (map[string]types.Set, error) { + inputEmpty := len(input) == 0 + outputEmpty := len(output) == 0 + + switch { + case !inputEmpty && !outputEmpty: + return output, nil + case !inputEmpty && outputEmpty: + return input, nil + case inputEmpty && !outputEmpty: + if computed { + return output, nil + } + return nil, fmt.Errorf( + "reconcile %q: API returned %d entries but field was not set in config and is not computed", + field, len(output), + ) + default: + return nil, nil + } +} + +func (r *reconciler) mapSet(field string, input, output map[string]types.Set, computed bool) map[string]types.Set { + v, err := reconcileMapSet(field, input, output, computed || r.reading) + if err != nil { + r.diags.AddError("State reconciliation error", err.Error()) + } + return v +} + +// --- Plan field accessors --- +// These extract a typed field from a possibly-nil plan target pointer. +// When the plan target is nil (e.g. during import or type mismatch), +// they return the zero value which reconcile treats as empty/null. + +func planStr[T any](pt *T, fn func(*T) types.String) types.String { + if pt == nil { + return types.StringNull() + } + return fn(pt) +} + +func planBool[T any](pt *T, fn func(*T) types.Bool) types.Bool { + if pt == nil { + return types.BoolNull() + } + return fn(pt) +} + +func planInt64[T any](pt *T, fn func(*T) types.Int64) types.Int64 { + if pt == nil { + return types.Int64Null() + } + return fn(pt) +} + +func planSlice[T any, E any](pt *T, fn func(*T) []E) []E { + if pt == nil { + return nil + } + return fn(pt) +} + +// optIntFromIntPtr converts a *int to types.Int64, returning types.Int64Null() when nil. +func optIntFromIntPtr(i *int) types.Int64 { + if i == nil { + return types.Int64Null() + } + return types.Int64Value(int64(*i)) +} + +// optStringValue converts a non-pointer string to types.String, treating "" as null. +// For pointer strings, use the existing optStringValue in helpers.go. +func optStringFromString(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} + +// Reconcile functions handle the four-way matrix of input (plan/state) vs +// output (API response) emptiness: +// +// 1. Input non-empty, Output non-empty → return output (API is authoritative) +// 2. Input non-empty, Output empty → return input (write-only / sensitive field) +// 3. Input empty, Output non-empty: +// - computed=true → return output (server-owned or defaulted field) +// - computed=false → return error (unexpected value for optional-only field) +// 4. Input empty, Output empty → return null +// +// "Empty" means null, unknown, or (for strings) the zero-length string "". + +// reconcileString reconciles a plan/state string with an API response string. +func reconcileString(field string, input, output types.String, computed bool) (types.String, error) { + inputEmpty := input.IsNull() || input.IsUnknown() || input.ValueString() == "" + outputEmpty := output.IsNull() || output.IsUnknown() || output.ValueString() == "" + + switch { + case !inputEmpty && !outputEmpty: + return output, nil + case !inputEmpty && outputEmpty: + return input, nil + case inputEmpty && !outputEmpty: + if computed { + return output, nil + } + return types.StringNull(), fmt.Errorf( + "reconcile %q: API returned %q but field was not set in config and is not computed", + field, output.ValueString(), + ) + default: + return types.StringNull(), nil + } +} + +// reconcileBool reconciles a plan/state bool with an API response bool. +func reconcileBool(field string, input, output types.Bool, computed bool) (types.Bool, error) { + inputEmpty := input.IsNull() || input.IsUnknown() + outputEmpty := output.IsNull() || output.IsUnknown() + + switch { + case !inputEmpty && !outputEmpty: + return output, nil + case !inputEmpty && outputEmpty: + return input, nil + case inputEmpty && !outputEmpty: + if computed { + return output, nil + } + return types.BoolNull(), fmt.Errorf( + "reconcile %q: API returned %t but field was not set in config and is not computed", + field, output.ValueBool(), + ) + default: + return types.BoolNull(), nil + } +} + +// reconcileInt64 reconciles a plan/state int64 with an API response int64. +func reconcileInt64(field string, input, output types.Int64, computed bool) (types.Int64, error) { + inputEmpty := input.IsNull() || input.IsUnknown() + outputEmpty := output.IsNull() || output.IsUnknown() + + switch { + case !inputEmpty && !outputEmpty: + return output, nil + case !inputEmpty && outputEmpty: + return input, nil + case inputEmpty && !outputEmpty: + if computed { + return output, nil + } + return types.Int64Null(), fmt.Errorf( + "reconcile %q: API returned %d but field was not set in config and is not computed", + field, output.ValueInt64(), + ) + default: + return types.Int64Null(), nil + } +} diff --git a/internal/provider/reconcile_test.go b/internal/provider/reconcile_test.go new file mode 100644 index 0000000..3e998c6 --- /dev/null +++ b/internal/provider/reconcile_test.go @@ -0,0 +1,341 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// --- reconcileString --- + +func TestReconcileString_BothNonEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringValue("plan"), types.StringValue("api"), false) + if err != nil { + t.Fatal(err) + } + if got.ValueString() != "api" { + t.Fatalf("expected output value 'api', got %q", got.ValueString()) + } +} + +func TestReconcileString_InputNonEmpty_OutputNull(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringValue("secret"), types.StringNull(), false) + if err != nil { + t.Fatal(err) + } + if got.ValueString() != "secret" { + t.Fatalf("expected echoed input 'secret', got %q", got.ValueString()) + } +} + +func TestReconcileString_InputNonEmpty_OutputEmptyString(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringValue("secret"), types.StringValue(""), false) + if err != nil { + t.Fatal(err) + } + if got.ValueString() != "secret" { + t.Fatalf("expected echoed input 'secret', got %q", got.ValueString()) + } +} + +func TestReconcileString_InputNull_OutputNonEmpty_Computed(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringNull(), types.StringValue("server-id"), true) + if err != nil { + t.Fatal(err) + } + if got.ValueString() != "server-id" { + t.Fatalf("expected output value 'server-id', got %q", got.ValueString()) + } +} + +func TestReconcileString_InputNull_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + _, err := reconcileString("my_field", types.StringNull(), types.StringValue("surprise"), false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field") + } +} + +func TestReconcileString_InputEmpty_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + _, err := reconcileString("my_field", types.StringValue(""), types.StringValue("surprise"), false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field (empty string input)") + } +} + +func TestReconcileString_BothNull(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringNull(), types.StringNull(), false) + if err != nil { + t.Fatal(err) + } + if !got.IsNull() { + t.Fatalf("expected null, got %q", got.ValueString()) + } +} + +func TestReconcileString_BothEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringValue(""), types.StringValue(""), false) + if err != nil { + t.Fatal(err) + } + if !got.IsNull() { + t.Fatalf("expected null, got %q", got.ValueString()) + } +} + +func TestReconcileString_InputUnknown_OutputNonEmpty_Computed(t *testing.T) { + t.Parallel() + got, err := reconcileString("f", types.StringUnknown(), types.StringValue("resolved"), true) + if err != nil { + t.Fatal(err) + } + if got.ValueString() != "resolved" { + t.Fatalf("expected 'resolved', got %q", got.ValueString()) + } +} + +// --- reconcileBool --- + +func TestReconcileBool_BothNonEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileBool("f", types.BoolValue(true), types.BoolValue(false), false) + if err != nil { + t.Fatal(err) + } + if got.ValueBool() != false { + t.Fatal("expected output value false") + } +} + +func TestReconcileBool_InputNonEmpty_OutputNull(t *testing.T) { + t.Parallel() + got, err := reconcileBool("f", types.BoolValue(true), types.BoolNull(), false) + if err != nil { + t.Fatal(err) + } + if got.ValueBool() != true { + t.Fatal("expected echoed input true") + } +} + +func TestReconcileBool_InputNull_OutputNonEmpty_Computed(t *testing.T) { + t.Parallel() + got, err := reconcileBool("f", types.BoolNull(), types.BoolValue(true), true) + if err != nil { + t.Fatal(err) + } + if got.ValueBool() != true { + t.Fatal("expected output value true") + } +} + +func TestReconcileBool_InputNull_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + _, err := reconcileBool("my_field", types.BoolNull(), types.BoolValue(true), false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field") + } +} + +func TestReconcileBool_BothNull(t *testing.T) { + t.Parallel() + got, err := reconcileBool("f", types.BoolNull(), types.BoolNull(), false) + if err != nil { + t.Fatal(err) + } + if !got.IsNull() { + t.Fatal("expected null") + } +} + +func TestReconcileBool_FalseIsNotEmpty(t *testing.T) { + t.Parallel() + // false is a valid value, not "empty" + got, err := reconcileBool("f", types.BoolValue(false), types.BoolNull(), false) + if err != nil { + t.Fatal(err) + } + if got.ValueBool() != false { + t.Fatal("expected echoed input false") + } + if got.IsNull() { + t.Fatal("false should not be treated as empty") + } +} + +// --- reconcileInt64 --- + +func TestReconcileInt64_BothNonEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileInt64("f", types.Int64Value(10), types.Int64Value(20), false) + if err != nil { + t.Fatal(err) + } + if got.ValueInt64() != 20 { + t.Fatalf("expected output value 20, got %d", got.ValueInt64()) + } +} + +func TestReconcileInt64_InputNonEmpty_OutputNull(t *testing.T) { + t.Parallel() + got, err := reconcileInt64("f", types.Int64Value(42), types.Int64Null(), false) + if err != nil { + t.Fatal(err) + } + if got.ValueInt64() != 42 { + t.Fatalf("expected echoed input 42, got %d", got.ValueInt64()) + } +} + +func TestReconcileInt64_InputNull_OutputNonEmpty_Computed(t *testing.T) { + t.Parallel() + got, err := reconcileInt64("f", types.Int64Null(), types.Int64Value(99), true) + if err != nil { + t.Fatal(err) + } + if got.ValueInt64() != 99 { + t.Fatalf("expected output value 99, got %d", got.ValueInt64()) + } +} + +func TestReconcileInt64_InputNull_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + _, err := reconcileInt64("my_field", types.Int64Null(), types.Int64Value(99), false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field") + } +} + +func TestReconcileInt64_BothNull(t *testing.T) { + t.Parallel() + got, err := reconcileInt64("f", types.Int64Null(), types.Int64Null(), false) + if err != nil { + t.Fatal(err) + } + if !got.IsNull() { + t.Fatal("expected null") + } +} + +func TestReconcileInt64_ZeroIsNotEmpty(t *testing.T) { + t.Parallel() + // 0 is a valid value, not "empty" + got, err := reconcileInt64("f", types.Int64Value(0), types.Int64Null(), false) + if err != nil { + t.Fatal(err) + } + if got.ValueInt64() != 0 { + t.Fatalf("expected echoed input 0, got %d", got.ValueInt64()) + } + if got.IsNull() { + t.Fatal("0 should not be treated as empty") + } +} + +// --- reconcileSlice --- + +func TestReconcileSlice_BothNonEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileSlice("f", []string{"a"}, []string{"b"}, false) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "b" { + t.Fatalf("expected output [b], got %v", got) + } +} + +func TestReconcileSlice_InputNonEmpty_OutputEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileSlice("f", []string{"a"}, []string(nil), false) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "a" { + t.Fatalf("expected echoed input [a], got %v", got) + } +} + +func TestReconcileSlice_InputEmpty_OutputNonEmpty_Computed(t *testing.T) { + t.Parallel() + got, err := reconcileSlice("f", []string(nil), []string{"x"}, true) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "x" { + t.Fatalf("expected output [x], got %v", got) + } +} + +func TestReconcileSlice_InputEmpty_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + _, err := reconcileSlice("my_field", []string(nil), []string{"x"}, false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field") + } +} + +func TestReconcileSlice_BothEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileSlice("f", []string(nil), []string(nil), false) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +// --- reconcileMapSet --- + +func TestReconcileMapSet_BothNonEmpty(t *testing.T) { + t.Parallel() + input := map[string]types.Set{"a": types.SetNull(types.StringType)} + output := map[string]types.Set{"b": types.SetNull(types.StringType)} + got, err := reconcileMapSet("f", input, output, false) + if err != nil { + t.Fatal(err) + } + if _, ok := got["b"]; !ok { + t.Fatal("expected output map with key 'b'") + } +} + +func TestReconcileMapSet_InputNonEmpty_OutputEmpty(t *testing.T) { + t.Parallel() + input := map[string]types.Set{"a": types.SetNull(types.StringType)} + got, err := reconcileMapSet("f", input, nil, false) + if err != nil { + t.Fatal(err) + } + if _, ok := got["a"]; !ok { + t.Fatal("expected echoed input map with key 'a'") + } +} + +func TestReconcileMapSet_InputEmpty_OutputNonEmpty_NotComputed(t *testing.T) { + t.Parallel() + output := map[string]types.Set{"b": types.SetNull(types.StringType)} + _, err := reconcileMapSet("my_field", nil, output, false) + if err == nil { + t.Fatal("expected error for unexpected API value on non-computed field") + } +} + +func TestReconcileMapSet_BothEmpty(t *testing.T) { + t.Parallel() + got, err := reconcileMapSet("f", nil, nil, false) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatal("expected nil") + } +} diff --git a/internal/provider/resource_ably_app.go b/internal/provider/resource_ably_app.go index 2f5637e..df0df3c 100644 --- a/internal/provider/resource_ably_app.go +++ b/internal/provider/resource_ably_app.go @@ -93,7 +93,7 @@ func (r ResourceApp) Schema(ctx context.Context, req resource.SchemaRequest, res Description: "Enforce TLS for all connections. This setting overrides any channel setting.", Computed: true, PlanModifiers: []planmodifier.Bool{ - DefaultBoolAttribute(types.BoolValue(false)), + DefaultBoolAttribute(types.BoolValue(true)), }, }, "fcm_key": schema.StringAttribute{ @@ -189,6 +189,34 @@ func (r ResourceApp) Metadata(ctx context.Context, req resource.MetadataRequest, resp.TypeName = "ably_app" } +// buildAppState reconciles plan/state input with an API response into an AblyAppState. +// For Create/Update, pass the plan as input. For Read, pass the prior state. +func buildAppState(rc *reconciler, input AblyAppState, api control.AppResponse) AblyAppState { + return AblyAppState{ + AccountID: rc.str("account_id", input.AccountID, types.StringValue(api.AccountID), true), + ID: rc.str("id", input.ID, types.StringValue(api.ID), true), + Name: rc.str("name", input.Name, types.StringValue(api.Name), false), + Status: rc.str("status", input.Status, types.StringValue(api.Status), true), + TLSOnly: rc.boolean("tls_only", input.TLSOnly, optBoolValue(api.TLSOnly), true), + FcmKey: rc.str("fcm_key", input.FcmKey, types.StringNull(), false), + FcmServiceAccount: rc.str("fcm_service_account", input.FcmServiceAccount, types.StringNull(), false), + FcmProjectId: rc.str("fcm_project_id", input.FcmProjectId, optStringValue(api.FCMProjectID), false), + FcmServiceAccountConfigured: rc.boolean("fcm_service_account_configured", input.FcmServiceAccountConfigured, optBoolValue(api.FCMServiceAccountConfigured), true), + ApnsCertificate: rc.str("apns_certificate", input.ApnsCertificate, types.StringNull(), false), + ApnsPrivateKey: rc.str("apns_private_key", input.ApnsPrivateKey, types.StringNull(), false), + ApnsUseSandboxEndpoint: rc.boolean("apns_use_sandbox_endpoint", input.ApnsUseSandboxEndpoint, optBoolValue(api.APNSUseSandboxEndpoint), true), + ApnsAuthType: rc.str("apns_auth_type", input.ApnsAuthType, optStringValue(api.APNSAuthType), true), + ApnsSigningKey: rc.str("apns_signing_key", input.ApnsSigningKey, types.StringNull(), false), + ApnsSigningKeyId: rc.str("apns_signing_key_id", input.ApnsSigningKeyId, optStringValue(api.APNSSigningKeyID), true), + ApnsIssuerKey: rc.str("apns_issuer_key", input.ApnsIssuerKey, optStringValue(api.APNSIssuerKey), true), + ApnsTopicHeader: rc.str("apns_topic_header", input.ApnsTopicHeader, optStringValue(api.APNSTopicHeader), true), + ApnsCertificateConfigured: rc.boolean("apns_certificate_configured", input.ApnsCertificateConfigured, optBoolValue(api.APNSCertificateConfigured), true), + ApnsSigningKeyConfigured: rc.boolean("apns_signing_key_configured", input.ApnsSigningKeyConfigured, optBoolValue(api.APNSSigningKeyConfigured), true), + Created: rc.str("created", input.Created, types.StringValue(formatTimestamp(api.Created)), true), + Modified: rc.str("modified", input.Modified, types.StringValue(formatTimestamp(api.Modified)), true), + } +} + // Create creates a new resource. func (r ResourceApp) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if !r.p.ensureConfigured(&resp.Diagnostics) { @@ -259,33 +287,11 @@ func (r ResourceApp) Create(ctx context.Context, req resource.CreateRequest, res } // Maps response body to resource schema attributes. - respApps := AblyAppState{ - AccountID: types.StringValue(ablyApp.AccountID), - ID: types.StringValue(ablyApp.ID), - Name: types.StringValue(ablyApp.Name), - Status: types.StringValue(ablyApp.Status), - TLSOnly: types.BoolValue(deref(ablyApp.TLSOnly)), - FcmKey: plan.FcmKey, - FcmServiceAccount: plan.FcmServiceAccount, - FcmProjectId: optStringValue(ablyApp.FCMProjectID), - FcmServiceAccountConfigured: types.BoolValue(deref(ablyApp.FCMServiceAccountConfigured)), - ApnsCertificate: plan.ApnsCertificate, - ApnsPrivateKey: plan.ApnsPrivateKey, - ApnsUseSandboxEndpoint: types.BoolValue(deref(ablyApp.APNSUseSandboxEndpoint)), - ApnsAuthType: optStringValue(ablyApp.APNSAuthType), - ApnsSigningKey: plan.ApnsSigningKey, - ApnsSigningKeyId: optStringValue(ablyApp.APNSSigningKeyID), - ApnsIssuerKey: optStringValue(ablyApp.APNSIssuerKey), - ApnsTopicHeader: optStringValue(ablyApp.APNSTopicHeader), - ApnsCertificateConfigured: types.BoolValue(deref(ablyApp.APNSCertificateConfigured)), - ApnsSigningKeyConfigured: types.BoolValue(deref(ablyApp.APNSSigningKeyConfigured)), - Created: types.StringValue(formatTimestamp(ablyApp.Created)), - Modified: types.StringValue(formatTimestamp(ablyApp.Modified)), + rc := newReconciler(&resp.Diagnostics) + respApps := buildAppState(rc, plan, ablyApp) + if resp.Diagnostics.HasError() { + return } - emptyStringToNull(&respApps.FcmKey) - emptyStringToNull(&respApps.ApnsCertificate) - emptyStringToNull(&respApps.ApnsPrivateKey) - emptyStringToNull(&respApps.ApnsSigningKey) // Sets state for the new Ably App. diags = resp.State.Set(ctx, respApps) @@ -328,33 +334,11 @@ func (r ResourceApp) Read(ctx context.Context, req resource.ReadRequest, resp *r // Loops through apps and if account id matches, sets state. for _, v := range apps { if v.ID == appID { - respApps := AblyAppState{ - AccountID: types.StringValue(v.AccountID), - ID: types.StringValue(v.ID), - Name: types.StringValue(v.Name), - Status: types.StringValue(v.Status), - TLSOnly: types.BoolValue(deref(v.TLSOnly)), - FcmKey: state.FcmKey, - FcmServiceAccount: state.FcmServiceAccount, - FcmProjectId: optStringValue(v.FCMProjectID), - FcmServiceAccountConfigured: types.BoolValue(deref(v.FCMServiceAccountConfigured)), - ApnsCertificate: state.ApnsCertificate, - ApnsPrivateKey: state.ApnsPrivateKey, - ApnsUseSandboxEndpoint: types.BoolValue(deref(v.APNSUseSandboxEndpoint)), - ApnsAuthType: optStringValue(v.APNSAuthType), - ApnsSigningKey: state.ApnsSigningKey, - ApnsSigningKeyId: optStringValue(v.APNSSigningKeyID), - ApnsIssuerKey: optStringValue(v.APNSIssuerKey), - ApnsTopicHeader: optStringValue(v.APNSTopicHeader), - ApnsCertificateConfigured: types.BoolValue(deref(v.APNSCertificateConfigured)), - ApnsSigningKeyConfigured: types.BoolValue(deref(v.APNSSigningKeyConfigured)), - Created: types.StringValue(formatTimestamp(v.Created)), - Modified: types.StringValue(formatTimestamp(v.Modified)), + rc := newReconciler(&resp.Diagnostics).forRead() + respApps := buildAppState(rc, state, v) + if resp.Diagnostics.HasError() { + return } - emptyStringToNull(&respApps.FcmKey) - emptyStringToNull(&respApps.ApnsCertificate) - emptyStringToNull(&respApps.ApnsPrivateKey) - emptyStringToNull(&respApps.ApnsSigningKey) found = true // Sets state to app values. @@ -453,33 +437,11 @@ func (r ResourceApp) Update(ctx context.Context, req resource.UpdateRequest, res return } - respApps := AblyAppState{ - ID: types.StringValue(ablyApp.ID), - AccountID: types.StringValue(ablyApp.AccountID), - Name: types.StringValue(ablyApp.Name), - Status: types.StringValue(ablyApp.Status), - TLSOnly: types.BoolValue(deref(ablyApp.TLSOnly)), - FcmKey: plan.FcmKey, - FcmServiceAccount: plan.FcmServiceAccount, - FcmProjectId: optStringValue(ablyApp.FCMProjectID), - FcmServiceAccountConfigured: types.BoolValue(deref(ablyApp.FCMServiceAccountConfigured)), - ApnsCertificate: plan.ApnsCertificate, - ApnsPrivateKey: plan.ApnsPrivateKey, - ApnsUseSandboxEndpoint: types.BoolValue(deref(ablyApp.APNSUseSandboxEndpoint)), - ApnsAuthType: optStringValue(ablyApp.APNSAuthType), - ApnsSigningKey: plan.ApnsSigningKey, - ApnsSigningKeyId: optStringValue(ablyApp.APNSSigningKeyID), - ApnsIssuerKey: optStringValue(ablyApp.APNSIssuerKey), - ApnsTopicHeader: optStringValue(ablyApp.APNSTopicHeader), - ApnsCertificateConfigured: types.BoolValue(deref(ablyApp.APNSCertificateConfigured)), - ApnsSigningKeyConfigured: types.BoolValue(deref(ablyApp.APNSSigningKeyConfigured)), - Created: types.StringValue(formatTimestamp(ablyApp.Created)), - Modified: types.StringValue(formatTimestamp(ablyApp.Modified)), + rc := newReconciler(&resp.Diagnostics) + respApps := buildAppState(rc, plan, ablyApp) + if resp.Diagnostics.HasError() { + return } - emptyStringToNull(&respApps.FcmKey) - emptyStringToNull(&respApps.ApnsCertificate) - emptyStringToNull(&respApps.ApnsPrivateKey) - emptyStringToNull(&respApps.ApnsSigningKey) // Sets state to new app. diags = resp.State.Set(ctx, respApps) diff --git a/internal/provider/resource_ably_app_test.go b/internal/provider/resource_ably_app_test.go index 0a3fe1e..0d0317a 100644 --- a/internal/provider/resource_ably_app_test.go +++ b/internal/provider/resource_ably_app_test.go @@ -230,3 +230,45 @@ resource "ably_app" "app0" { }, }) } + +func TestAccAblyApp_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} +`, tfProvider, appName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + // Computed-only fields are populated + resource.TestCheckResourceAttrSet("ably_app.app0", "id"), + resource.TestCheckResourceAttrSet("ably_app.app0", "account_id"), + resource.TestCheckResourceAttrSet("ably_app.app0", "created"), + resource.TestCheckResourceAttrSet("ably_app.app0", "modified"), + // Optional+Computed defaults + resource.TestCheckResourceAttr("ably_app.app0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_app.app0", "tls_only", "true"), + resource.TestCheckResourceAttr("ably_app.app0", "apns_use_sandbox_endpoint", "false"), + // Optional-only fields should not be in state + resource.TestCheckNoResourceAttr("ably_app.app0", "fcm_key"), + resource.TestCheckNoResourceAttr("ably_app.app0", "fcm_service_account"), + resource.TestCheckNoResourceAttr("ably_app.app0", "fcm_project_id"), + resource.TestCheckNoResourceAttr("ably_app.app0", "apns_certificate"), + resource.TestCheckNoResourceAttr("ably_app.app0", "apns_private_key"), + resource.TestCheckNoResourceAttr("ably_app.app0", "apns_signing_key"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} diff --git a/internal/provider/resource_ably_ingress_rule_mongo_test.go b/internal/provider/resource_ably_ingress_rule_mongo_test.go index 8ec033a..409b7bb 100644 --- a/internal/provider/resource_ably_ingress_rule_mongo_test.go +++ b/internal/provider/resource_ably_ingress_rule_mongo_test.go @@ -86,6 +86,37 @@ func TestAccAblyIngressRuleMongo(t *testing.T) { }) } +func TestAccAblyIngressRuleMongo_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalIngressRuleConfig(appName, "ably_ingress_rule_mongodb", `target = { + url = "mongodb://user:pass@example.com:27017" + database = "testdb" + collection = "testcol" + pipeline = "[]" + full_document = "updateLookup" + full_document_before_change = "off" + primary_site = "us-east-1-A" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_ingress_rule_mongodb.rule0", "id"), + resource.TestCheckResourceAttr("ably_ingress_rule_mongodb.rule0", "status", "enabled"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyIngressRuleMongoConfig( appName string, diff --git a/internal/provider/resource_ably_ingress_rule_postgres_outbox_test.go b/internal/provider/resource_ably_ingress_rule_postgres_outbox_test.go index fbad78c..5b7d399 100644 --- a/internal/provider/resource_ably_ingress_rule_postgres_outbox_test.go +++ b/internal/provider/resource_ably_ingress_rule_postgres_outbox_test.go @@ -93,6 +93,37 @@ func TestAccAblyIngressRulePostgresOutbox(t *testing.T) { }) } +func TestAccAblyIngressRulePostgresOutbox_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalIngressRuleConfig(appName, "ably_ingress_rule_postgres_outbox", `target = { + url = "postgres://test:test@example.com:5432/testdb" + outbox_table_schema = "public" + outbox_table_name = "outbox" + nodes_table_schema = "public" + nodes_table_name = "nodes" + ssl_mode = "prefer" + primary_site = "us-east-1-A" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_ingress_rule_postgres_outbox.rule0", "id"), + resource.TestCheckResourceAttr("ably_ingress_rule_postgres_outbox.rule0", "status", "enabled"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyIngressRulePostgresOutboxConfig( appName string, diff --git a/internal/provider/resource_ably_key.go b/internal/provider/resource_ably_key.go index 6b8d7df..ba39131 100644 --- a/internal/provider/resource_ably_key.go +++ b/internal/provider/resource_ably_key.go @@ -94,6 +94,21 @@ func (r ResourceKey) Metadata(ctx context.Context, req resource.MetadataRequest, resp.TypeName = "ably_api_key" } +// buildKeyState reconciles plan/state input with an API response. +func buildKeyState(rc *reconciler, input AblyKey, api control.KeyResponse) AblyKey { + return AblyKey{ + ID: rc.str("id", input.ID, types.StringValue(api.ID), true), + AppID: rc.str("app_id", input.AppID, types.StringValue(api.AppID), false), + Name: rc.str("name", input.Name, types.StringValue(api.Name), false), + Key: rc.str("key", input.Key, types.StringValue(api.Key), true), + RevocableTokens: rc.boolean("revocable_tokens", input.RevocableTokens, optBoolValue(api.RevocableTokens), true), + Capability: rc.mapSet("capabilities", input.Capability, mapToTypedSet(api.Capability), false), + Status: rc.int64val("status", input.Status, types.Int64Value(int64(api.Status)), true), + Created: rc.int64val("created", input.Created, types.Int64Value(api.Created), true), + Modified: rc.int64val("modified", input.Modified, types.Int64Value(api.Modified), true), + } +} + // Create creates a new resource. func (r ResourceKey) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if !r.p.ensureConfigured(&resp.Diagnostics) { @@ -148,27 +163,13 @@ func (r ResourceKey) Create(ctx context.Context, req resource.CreateRequest, res } // Maps response body to resource schema attributes. - // Convert capability map from Go strings to Terraform types - tfCapability := mapToTypedSet(ablyKey.Capability) - - respRevocable := false - if ablyKey.RevocableTokens != nil { - respRevocable = *ablyKey.RevocableTokens - } - - respKey := AblyKey{ - ID: types.StringValue(ablyKey.ID), - AppID: types.StringValue(ablyKey.AppID), - Name: types.StringValue(ablyKey.Name), - Key: types.StringValue(ablyKey.Key), - RevocableTokens: types.BoolValue(respRevocable), - Capability: tfCapability, - Status: types.Int64Value(int64(ablyKey.Status)), - Created: types.Int64Value(int64(ablyKey.Created)), - Modified: types.Int64Value(int64(ablyKey.Modified)), + rc := newReconciler(&resp.Diagnostics) + respKey := buildKeyState(rc, plan, ablyKey) + if resp.Diagnostics.HasError() { + return } - // Sets state for the new Ably App. + // Sets state for the new Ably key. diags = resp.State.Set(ctx, respKey) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -214,26 +215,13 @@ func (r ResourceKey) Read(ctx context.Context, req resource.ReadRequest, resp *r // Loops through apps and if account id and key id match, sets state. for _, v := range keys { if v.AppID == appID && v.ID == keyID && v.Status == 0 { - // Convert capability map from Go strings to Terraform types - tfCapability := mapToTypedSet(v.Capability) - - vRevocable := false - if v.RevocableTokens != nil { - vRevocable = *v.RevocableTokens + rc := newReconciler(&resp.Diagnostics).forRead() + respKey := buildKeyState(rc, state, v) + if resp.Diagnostics.HasError() { + return } - respKey := AblyKey{ - ID: types.StringValue(v.ID), - AppID: types.StringValue(v.AppID), - Name: types.StringValue(v.Name), - RevocableTokens: types.BoolValue(vRevocable), - Capability: tfCapability, - Status: types.Int64Value(int64(v.Status)), - Key: types.StringValue(v.Key), - Created: types.Int64Value(int64(v.Created)), - Modified: types.Int64Value(int64(v.Modified)), - } - // Sets state to app values. + // Sets state to key values. diags = resp.State.Set(ctx, &respKey) found = true @@ -310,24 +298,10 @@ func (r ResourceKey) Update(ctx context.Context, req resource.UpdateRequest, res } } - // Convert capability map from Go strings to Terraform types - tfCapability := mapToTypedSet(ablyKey.Capability) - - updateRespRevocable := false - if ablyKey.RevocableTokens != nil { - updateRespRevocable = *ablyKey.RevocableTokens - } - - respKey := AblyKey{ - ID: types.StringValue(ablyKey.ID), - AppID: types.StringValue(ablyKey.AppID), - Name: types.StringValue(ablyKey.Name), - RevocableTokens: types.BoolValue(updateRespRevocable), - Capability: tfCapability, - Status: types.Int64Value(int64(ablyKey.Status)), - Key: types.StringValue(ablyKey.Key), - Created: types.Int64Value(int64(ablyKey.Created)), - Modified: types.Int64Value(int64(ablyKey.Modified)), + rc := newReconciler(&resp.Diagnostics) + respKey := buildKeyState(rc, plan, ablyKey) + if resp.Diagnostics.HasError() { + return } // Sets state. diff --git a/internal/provider/resource_ably_key_test.go b/internal/provider/resource_ably_key_test.go index 51ad850..df3d0db 100644 --- a/internal/provider/resource_ably_key_test.go +++ b/internal/provider/resource_ably_key_test.go @@ -108,3 +108,41 @@ resource "ably_api_key" "key0" { } `, appName, keyName, keyCapabilityName0, keyCapabilityCap0, revocableTokens) } + +func TestAccAblyKey_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource "ably_api_key" "key0" { + app_id = ably_app.app0.id + name = "minimal-key" + capabilities = { + "channel" = ["publish"] + } +} +`, tfProvider, appName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_api_key.key0", "id"), + resource.TestCheckResourceAttrSet("ably_api_key.key0", "key"), + resource.TestCheckResourceAttr("ably_api_key.key0", "name", "minimal-key"), + // Optional+Computed default + resource.TestCheckResourceAttr("ably_api_key.key0", "revocable_tokens", "false"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} diff --git a/internal/provider/resource_ably_namespace.go b/internal/provider/resource_ably_namespace.go index 8f9d29d..142c91b 100644 --- a/internal/provider/resource_ably_namespace.go +++ b/internal/provider/resource_ably_namespace.go @@ -154,6 +154,35 @@ func (r ResourceNamespace) Schema(_ context.Context, _ resource.SchemaRequest, r } } +// buildNamespaceState reconciles plan/state input with an API response. +func buildNamespaceState(rc *reconciler, input AblyNamespace, api control.NamespaceResponse) AblyNamespace { + ns := AblyNamespace{ + AppID: rc.str("app_id", input.AppID, types.StringValue(api.AppID), false), + ID: rc.str("id", input.ID, types.StringValue(api.ID), false), + Authenticated: rc.boolean("authenticated", input.Authenticated, types.BoolValue(api.Authenticated), true), + Persisted: rc.boolean("persisted", input.Persisted, types.BoolValue(api.Persisted), true), + PersistLast: rc.boolean("persist_last", input.PersistLast, types.BoolValue(api.PersistLast), true), + PushEnabled: rc.boolean("push_enabled", input.PushEnabled, types.BoolValue(api.PushEnabled), true), + TlsOnly: rc.boolean("tls_only", input.TlsOnly, types.BoolValue(api.TLSOnly), true), + ExposeTimeserial: rc.boolean("expose_timeserial", input.ExposeTimeserial, types.BoolValue(api.ExposeTimeserial), true), + MutableMessages: rc.boolean("mutable_messages", input.MutableMessages, types.BoolValue(api.MutableMessages), true), + PopulateChannelRegistry: rc.boolean("populate_channel_registry", input.PopulateChannelRegistry, types.BoolValue(api.PopulateChannelRegistry), true), + BatchingEnabled: rc.boolean("batching_enabled", input.BatchingEnabled, optBoolValue(api.BatchingEnabled), true), + ConflationEnabled: rc.boolean("conflation_enabled", input.ConflationEnabled, optBoolValue(api.ConflationEnabled), true), + } + + if api.BatchingEnabled != nil && *api.BatchingEnabled { + ns.BatchingInterval = rc.int64val("batching_interval", input.BatchingInterval, optIntValue(api.BatchingInterval), true) + } + + if api.ConflationEnabled != nil && *api.ConflationEnabled { + ns.ConflationInterval = rc.int64val("conflation_interval", input.ConflationInterval, optIntValue(api.ConflationInterval), true) + ns.ConflationKey = rc.str("conflation_key", input.ConflationKey, optStringValue(api.ConflationKey), true) + } + + return ns +} + // Create creates a new resource. func (r ResourceNamespace) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if !r.p.ensureConfigured(&resp.Diagnostics) { @@ -210,32 +239,14 @@ func (r ResourceNamespace) Create(ctx context.Context, req resource.CreateReques } // Maps response body to resource schema attributes. - respApps := AblyNamespace{ - AppID: types.StringValue(plan.AppID.ValueString()), - ID: types.StringValue(ablyNamespace.ID), - Authenticated: types.BoolValue(ablyNamespace.Authenticated), - Persisted: types.BoolValue(ablyNamespace.Persisted), - PersistLast: types.BoolValue(ablyNamespace.PersistLast), - PushEnabled: types.BoolValue(ablyNamespace.PushEnabled), - TlsOnly: types.BoolValue(ablyNamespace.TLSOnly), - ExposeTimeserial: types.BoolValue(ablyNamespace.ExposeTimeserial), - MutableMessages: types.BoolValue(ablyNamespace.MutableMessages), - PopulateChannelRegistry: types.BoolValue(ablyNamespace.PopulateChannelRegistry), - BatchingEnabled: optBoolValue(ablyNamespace.BatchingEnabled), - ConflationEnabled: optBoolValue(ablyNamespace.ConflationEnabled), - } - - if ablyNamespace.BatchingEnabled != nil && *ablyNamespace.BatchingEnabled { - respApps.BatchingInterval = optIntValue(ablyNamespace.BatchingInterval) - } - - if ablyNamespace.ConflationEnabled != nil && *ablyNamespace.ConflationEnabled { - respApps.ConflationInterval = optIntValue(ablyNamespace.ConflationInterval) - respApps.ConflationKey = optStringValue(ablyNamespace.ConflationKey) + rc := newReconciler(&resp.Diagnostics) + respNs := buildNamespaceState(rc, plan, ablyNamespace) + if resp.Diagnostics.HasError() { + return } - // Sets state for the new Ably App. - diags = resp.State.Set(ctx, respApps) + // Sets state for the new Ably namespace. + diags = resp.State.Set(ctx, respNs) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -284,32 +295,14 @@ func (r ResourceNamespace) Read(ctx context.Context, req resource.ReadRequest, r // Loops through namespaces and if id matches, sets state. for _, v := range namespaces { if v.ID == namespaceID { - respNamespaces := AblyNamespace{ - AppID: types.StringValue(appID), - ID: types.StringValue(namespaceID), - Authenticated: types.BoolValue(v.Authenticated), - Persisted: types.BoolValue(v.Persisted), - PersistLast: types.BoolValue(v.PersistLast), - PushEnabled: types.BoolValue(v.PushEnabled), - TlsOnly: types.BoolValue(v.TLSOnly), - ExposeTimeserial: types.BoolValue(v.ExposeTimeserial), - MutableMessages: types.BoolValue(v.MutableMessages), - PopulateChannelRegistry: types.BoolValue(v.PopulateChannelRegistry), - BatchingEnabled: optBoolValue(v.BatchingEnabled), - ConflationEnabled: optBoolValue(v.ConflationEnabled), - } - - if v.BatchingEnabled != nil && *v.BatchingEnabled { - respNamespaces.BatchingInterval = optIntValue(v.BatchingInterval) - } - - if v.ConflationEnabled != nil && *v.ConflationEnabled { - respNamespaces.ConflationInterval = optIntValue(v.ConflationInterval) - respNamespaces.ConflationKey = optStringValue(v.ConflationKey) + rc := newReconciler(&resp.Diagnostics).forRead() + respNs := buildNamespaceState(rc, state, v) + if resp.Diagnostics.HasError() { + return } // Sets state to namespace values. - diags = resp.State.Set(ctx, &respNamespaces) + diags = resp.State.Set(ctx, &respNs) found = true resp.Diagnostics.Append(diags...) @@ -385,32 +378,14 @@ func (r ResourceNamespace) Update(ctx context.Context, req resource.UpdateReques return } - respNamespaces := AblyNamespace{ - AppID: types.StringValue(appID), - ID: types.StringValue(ablyNamespace.ID), - Authenticated: types.BoolValue(ablyNamespace.Authenticated), - Persisted: types.BoolValue(ablyNamespace.Persisted), - PersistLast: types.BoolValue(ablyNamespace.PersistLast), - PushEnabled: types.BoolValue(ablyNamespace.PushEnabled), - TlsOnly: types.BoolValue(ablyNamespace.TLSOnly), - ExposeTimeserial: types.BoolValue(ablyNamespace.ExposeTimeserial), - MutableMessages: types.BoolValue(ablyNamespace.MutableMessages), - PopulateChannelRegistry: types.BoolValue(ablyNamespace.PopulateChannelRegistry), - BatchingEnabled: optBoolValue(ablyNamespace.BatchingEnabled), - ConflationEnabled: optBoolValue(ablyNamespace.ConflationEnabled), - } - - if ablyNamespace.BatchingEnabled != nil && *ablyNamespace.BatchingEnabled { - respNamespaces.BatchingInterval = optIntValue(ablyNamespace.BatchingInterval) - } - - if ablyNamespace.ConflationEnabled != nil && *ablyNamespace.ConflationEnabled { - respNamespaces.ConflationInterval = optIntValue(ablyNamespace.ConflationInterval) - respNamespaces.ConflationKey = optStringValue(ablyNamespace.ConflationKey) + rc := newReconciler(&resp.Diagnostics) + respNs := buildNamespaceState(rc, plan, ablyNamespace) + if resp.Diagnostics.HasError() { + return } // Sets state to new namespace. - diags = resp.State.Set(ctx, respNamespaces) + diags = resp.State.Set(ctx, respNs) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/provider/resource_ably_namespace_test.go b/internal/provider/resource_ably_namespace_test.go index 20924eb..ecc4650 100644 --- a/internal/provider/resource_ably_namespace_test.go +++ b/internal/provider/resource_ably_namespace_test.go @@ -302,6 +302,49 @@ resource "ably_namespace" "namespace0" { ) } +func TestAccAblyNamespace_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource "ably_namespace" "ns0" { + app_id = ably_app.app0.id + id = "minimal-ns" +} +`, tfProvider, appName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_namespace.ns0", "app_id"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "id", "minimal-ns"), + // Optional+Computed defaults (all false) + resource.TestCheckResourceAttr("ably_namespace.ns0", "authenticated", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "persisted", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "persist_last", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "push_enabled", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "tls_only", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "expose_timeserial", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "mutable_messages", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "populate_channel_registry", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "batching_enabled", "false"), + resource.TestCheckResourceAttr("ably_namespace.ns0", "conflation_enabled", "false"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + func TestAccAblyNamespace_InvalidBatchingInterval(t *testing.T) { appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) resource.Test(t, resource.TestCase{ diff --git a/internal/provider/resource_ably_queue.go b/internal/provider/resource_ably_queue.go index 05d804b..47dff8b 100644 --- a/internal/provider/resource_ably_queue.go +++ b/internal/provider/resource_ably_queue.go @@ -136,6 +136,26 @@ func (r ResourceQueue) Metadata(ctx context.Context, req resource.MetadataReques resp.TypeName = "ably_queue" } +// buildQueueState reconciles plan/state input with an API response. +func buildQueueState(rc *reconciler, input AblyQueue, api control.QueueResponse) AblyQueue { + return AblyQueue{ + AppID: rc.str("app_id", input.AppID, types.StringValue(api.AppID), false), + ID: rc.str("id", input.ID, types.StringValue(api.ID), true), + Name: rc.str("name", input.Name, types.StringValue(api.Name), false), + Ttl: rc.int64val("ttl", input.Ttl, types.Int64Value(int64(api.TTL)), false), + MaxLength: rc.int64val("max_length", input.MaxLength, types.Int64Value(int64(api.MaxLength)), false), + Region: rc.str("region", input.Region, types.StringValue(api.Region), false), + AmqpUri: rc.str("amqp_uri", input.AmqpUri, types.StringValue(api.AMQP.URI), true), + AmqpQueueName: rc.str("amqp_queue_name", input.AmqpQueueName, types.StringValue(api.AMQP.QueueName), true), + StompURI: rc.str("stomp_uri", input.StompURI, types.StringValue(api.Stomp.URI), true), + StompHost: rc.str("stomp_host", input.StompHost, types.StringValue(api.Stomp.Host), true), + StompDestination: rc.str("stomp_destination", input.StompDestination, types.StringValue(api.Stomp.Destination), true), + State: rc.str("state", input.State, types.StringValue(api.State), true), + Deadletter: rc.boolean("deadletter", input.Deadletter, types.BoolValue(api.Deadletter), true), + DeadletterID: rc.str("deadletter_id", input.DeadletterID, optStringValue(api.DeadletterID), true), + } +} + // Create creates a new resource. func (r ResourceQueue) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if !r.p.ensureConfigured(&resp.Diagnostics) { @@ -169,26 +189,14 @@ func (r ResourceQueue) Create(ctx context.Context, req resource.CreateRequest, r } // Maps response body to resource schema attributes. - respApps := AblyQueue{ - AppID: types.StringValue(plan.AppID.ValueString()), - ID: types.StringValue(ablyQueue.ID), - Name: types.StringValue(ablyQueue.Name), - Ttl: types.Int64Value(int64(ablyQueue.TTL)), - MaxLength: types.Int64Value(int64(ablyQueue.MaxLength)), - Region: types.StringValue(ablyQueue.Region), - - AmqpUri: types.StringValue(ablyQueue.AMQP.URI), - AmqpQueueName: types.StringValue(ablyQueue.AMQP.QueueName), - StompURI: types.StringValue(ablyQueue.Stomp.URI), - StompHost: types.StringValue(ablyQueue.Stomp.Host), - StompDestination: types.StringValue(ablyQueue.Stomp.Destination), - State: types.StringValue(ablyQueue.State), - Deadletter: types.BoolValue(ablyQueue.Deadletter), - DeadletterID: optStringValue(ablyQueue.DeadletterID), + rc := newReconciler(&resp.Diagnostics) + respQueue := buildQueueState(rc, plan, ablyQueue) + if resp.Diagnostics.HasError() { + return } - // Sets state for the new Ably App. - diags = resp.State.Set(ctx, respApps) + // Sets state for the new Ably queue. + diags = resp.State.Set(ctx, respQueue) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -233,25 +241,14 @@ func (r ResourceQueue) Read(ctx context.Context, req resource.ReadRequest, resp // Loops through queues and if id matches, sets state. for _, v := range queues { if v.ID == queueID { - respQueues := AblyQueue{ - AppID: types.StringValue(v.AppID), - ID: types.StringValue(v.ID), - Name: types.StringValue(v.Name), - Ttl: types.Int64Value(int64(v.TTL)), - MaxLength: types.Int64Value(int64(v.MaxLength)), - Region: types.StringValue(v.Region), - - AmqpUri: types.StringValue(v.AMQP.URI), - AmqpQueueName: types.StringValue(v.AMQP.QueueName), - StompURI: types.StringValue(v.Stomp.URI), - StompHost: types.StringValue(v.Stomp.Host), - StompDestination: types.StringValue(v.Stomp.Destination), - State: types.StringValue(v.State), - Deadletter: types.BoolValue(v.Deadletter), - DeadletterID: optStringValue(v.DeadletterID), + rc := newReconciler(&resp.Diagnostics).forRead() + respQueue := buildQueueState(rc, state, v) + if resp.Diagnostics.HasError() { + return } + // Sets state to queue values. - diags = resp.State.Set(ctx, &respQueues) + diags = resp.State.Set(ctx, &respQueue) found = true resp.Diagnostics.Append(diags...) diff --git a/internal/provider/resource_ably_queue_test.go b/internal/provider/resource_ably_queue_test.go index e2fe091..e8fa631 100644 --- a/internal/provider/resource_ably_queue_test.go +++ b/internal/provider/resource_ably_queue_test.go @@ -106,6 +106,44 @@ resource "ably_queue" "queue0" { `, appName, queue.Name, queue.TTL, queue.MaxLength, queue.Region) } +func TestAccAblyQueue_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource "ably_queue" "q0" { + app_id = ably_app.app0.id + name = "minimal-queue" + ttl = 60 + max_length = 10000 + region = "us-east-1-a" +} +`, tfProvider, appName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_queue.q0", "id"), + resource.TestCheckResourceAttr("ably_queue.q0", "name", "minimal-queue"), + resource.TestCheckResourceAttr("ably_queue.q0", "ttl", "60"), + resource.TestCheckResourceAttr("ably_queue.q0", "max_length", "10000"), + resource.TestCheckResourceAttr("ably_queue.q0", "region", "us-east-1-a"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + func TestAccAblyQueue_InvalidTTL(t *testing.T) { appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) resource.Test(t, resource.TestCase{ diff --git a/internal/provider/resource_ably_rule_amqp_external_test.go b/internal/provider/resource_ably_rule_amqp_external_test.go index d28ee54..646c71f 100644 --- a/internal/provider/resource_ably_rule_amqp_external_test.go +++ b/internal/provider/resource_ably_rule_amqp_external_test.go @@ -133,6 +133,35 @@ func TestAccAblyRuleAMQPExternal(t *testing.T) { }) } +func TestAccAblyRuleAMQPExternal_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_amqp_external", `target = { + url = "amqps://user:pass@example.com/vhost" + routing_key = "test-key" + mandatory_route = false + persistent_messages = false + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_amqp_external.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_amqp_external.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_amqp_external.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleAMQPExternalConfig( appName string, diff --git a/internal/provider/resource_ably_rule_amqp_test.go b/internal/provider/resource_ably_rule_amqp_test.go index 39f2758..af4df34 100644 --- a/internal/provider/resource_ably_rule_amqp_test.go +++ b/internal/provider/resource_ably_rule_amqp_test.go @@ -109,6 +109,54 @@ func TestAccAblyRuleAMQP(t *testing.T) { }) } +func TestAccAblyRuleAMQP_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := fmt.Sprintf(`%s +resource "ably_app" "app0" { + name = %q +} + +resource "ably_queue" "q0" { + app_id = ably_app.app0.id + name = "minimal-amqp-queue" + ttl = 60 + max_length = 10000 + region = "us-east-1-a" +} + +resource "ably_rule_amqp" "rule0" { + app_id = ably_app.app0.id + + source = { + type = "channel.message" + } + + target = { + queue_id = ably_queue.q0.id + } +} +`, tfProvider, appName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_amqp.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_amqp.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_amqp.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleAMQPConfig( appName string, diff --git a/internal/provider/resource_ably_rule_azure_function_test.go b/internal/provider/resource_ably_rule_azure_function_test.go index 4e106e2..7c4b0d2 100644 --- a/internal/provider/resource_ably_rule_azure_function_test.go +++ b/internal/provider/resource_ably_rule_azure_function_test.go @@ -112,6 +112,33 @@ func TestAccAblyRuleAzureFunction(t *testing.T) { }) } +func TestAccAblyRuleAzureFunction_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_azure_function", `target = { + azure_app_id = "demo" + function_name = "function0" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_azure_function.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_azure_function.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_azure_function.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleAzureFunctionConfig( appName string, diff --git a/internal/provider/resource_ably_rule_http_cloudflare_worker_test.go b/internal/provider/resource_ably_rule_http_cloudflare_worker_test.go index 5c559a0..73cb49d 100644 --- a/internal/provider/resource_ably_rule_http_cloudflare_worker_test.go +++ b/internal/provider/resource_ably_rule_http_cloudflare_worker_test.go @@ -103,6 +103,32 @@ func TestAccAblyRuleCloudflareWorker(t *testing.T) { }) } +func TestAccAblyRuleCloudflareWorker_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_cloudflare_worker", `target = { + url = "https://example.com/webhooks" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_cloudflare_worker.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_cloudflare_worker.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_cloudflare_worker.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleCloudflareWorkerConfig( appName string, diff --git a/internal/provider/resource_ably_rule_http_google_cloud_function_test.go b/internal/provider/resource_ably_rule_http_google_cloud_function_test.go index 5a19970..54d7687 100644 --- a/internal/provider/resource_ably_rule_http_google_cloud_function_test.go +++ b/internal/provider/resource_ably_rule_http_google_cloud_function_test.go @@ -115,6 +115,34 @@ func TestAccAblyRuleGoogleFunction(t *testing.T) { }) } +func TestAccAblyRuleGoogleFunction_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_google_function", `target = { + region = "us-central1" + project_id = "test-project" + function_name = "test-function" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_google_function.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_google_function.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_google_function.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleGoogleFunctionConfig( appName string, diff --git a/internal/provider/resource_ably_rule_http_test.go b/internal/provider/resource_ably_rule_http_test.go index ca0b2a6..d75e3d1 100644 --- a/internal/provider/resource_ably_rule_http_test.go +++ b/internal/provider/resource_ably_rule_http_test.go @@ -181,6 +181,33 @@ resource "ably_rule_http" "rule0" { `, appName, ruleStatus, channelFilter, sourceType, requestMode, targetHeaders, targetSigningKeyID, targetURL, targetFormat, enveloped) } +func TestAccAblyRuleHTTP_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_http", `target = { + url = "https://example.com/webhook" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_http.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_http.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_http.rule0", "request_mode", "single"), + resource.TestCheckNoResourceAttr("ably_rule_http.rule0", "source.channel_filter"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + func TestAccAblyRule_InvalidStatus(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/internal/provider/resource_ably_rule_ifttt_test.go b/internal/provider/resource_ably_rule_ifttt_test.go index 056517e..740d72d 100644 --- a/internal/provider/resource_ably_rule_ifttt_test.go +++ b/internal/provider/resource_ably_rule_ifttt_test.go @@ -80,6 +80,33 @@ func TestAccAblyRuleIFTTT(t *testing.T) { }) } +func TestAccAblyRuleIFTTT_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_ifttt", `target = { + webhook_key = "test-key" + event_name = "test-event" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_ifttt.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_ifttt.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_ifttt.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleIFTTTConfig( appName string, diff --git a/internal/provider/resource_ably_rule_kafka_test.go b/internal/provider/resource_ably_rule_kafka_test.go index 20def70..2e91924 100644 --- a/internal/provider/resource_ably_rule_kafka_test.go +++ b/internal/provider/resource_ably_rule_kafka_test.go @@ -107,6 +107,40 @@ func TestAccAblyRuleKafka(t *testing.T) { }) } +func TestAccAblyRuleKafka_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_kafka", `target = { + routing_key = "test-key" + brokers = ["broker1.example.com:9092"] + auth = { + sasl = { + mechanism = "plain" + username = "user" + password = "pass" + } + } + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_kafka.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_kafka.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_kafka.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleKafkaConfig( appName string, diff --git a/internal/provider/resource_ably_rule_kinesis_test.go b/internal/provider/resource_ably_rule_kinesis_test.go index 4450fa9..7e4b469 100644 --- a/internal/provider/resource_ably_rule_kinesis_test.go +++ b/internal/provider/resource_ably_rule_kinesis_test.go @@ -113,6 +113,39 @@ func TestAccAblyRuleKinesis(t *testing.T) { }) } +func TestAccAblyRuleKinesis_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_kinesis", `target = { + region = "us-west-1" + stream_name = "test-stream" + partition_key = "/data" + authentication = { + mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_kinesis.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_kinesis.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_kinesis.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleKinesisConfig( appName string, diff --git a/internal/provider/resource_ably_rule_lambda_test.go b/internal/provider/resource_ably_rule_lambda_test.go index 01acbc3..f3f15f9 100644 --- a/internal/provider/resource_ably_rule_lambda_test.go +++ b/internal/provider/resource_ably_rule_lambda_test.go @@ -101,6 +101,38 @@ func TestAccAblyRuleLambda(t *testing.T) { }) } +func TestAccAblyRuleLambda_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_lambda", `target = { + region = "us-west-1" + function_name = "test-function" + authentication = { + mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_lambda.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_lambda.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_lambda.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleLambdaConfig( appName string, diff --git a/internal/provider/resource_ably_rule_pulsar_test.go b/internal/provider/resource_ably_rule_pulsar_test.go index e84433b..4cb15d3 100644 --- a/internal/provider/resource_ably_rule_pulsar_test.go +++ b/internal/provider/resource_ably_rule_pulsar_test.go @@ -105,6 +105,38 @@ func TestAccAblyRulePulsar(t *testing.T) { }) } +func TestAccAblyRulePulsar_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_pulsar", `target = { + routing_key = "test-key" + topic = "my-tenant/my-namespace/my-topic" + service_url = "pulsar://pulsar.us-west.example.com:6650/" + authentication = { + mode = "token" + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + } + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_pulsar.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_pulsar.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_pulsar.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRulePulsarConfig( appName string, diff --git a/internal/provider/resource_ably_rule_sqs_test.go b/internal/provider/resource_ably_rule_sqs_test.go index 72b18f6..8ecd606 100644 --- a/internal/provider/resource_ably_rule_sqs_test.go +++ b/internal/provider/resource_ably_rule_sqs_test.go @@ -106,6 +106,40 @@ func TestAccAblyRuleSqs(t *testing.T) { }) } +func TestAccAblyRuleSqs_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_sqs", `target = { + region = "us-west-1" + aws_account_id = "123456789012" + queue_name = "test-queue" + format = "json" + authentication = { + mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_sqs.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_sqs.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_sqs.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleSqsConfig( appName string, diff --git a/internal/provider/resource_ably_rule_zapier_test.go b/internal/provider/resource_ably_rule_zapier_test.go index a5075ab..ee84c27 100644 --- a/internal/provider/resource_ably_rule_zapier_test.go +++ b/internal/provider/resource_ably_rule_zapier_test.go @@ -103,6 +103,32 @@ func TestAccAblyRuleZapier(t *testing.T) { }) } +func TestAccAblyRuleZapier_Minimal(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + config := minimalRuleConfig(appName, "ably_rule_zapier", `target = { + url = "https://hooks.zapier.com/hooks/catch/000000/aaaaa" + }`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("ably_rule_zapier.rule0", "id"), + resource.TestCheckResourceAttr("ably_rule_zapier.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_zapier.rule0", "request_mode", "single"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + // Function with inline HCL to provision an ably_app resource func testAccAblyRuleZapierConfig( appName string, diff --git a/internal/provider/rules.go b/internal/provider/rules.go index ac1c767..c37a69f 100644 --- a/internal/provider/rules.go +++ b/internal/provider/rules.go @@ -321,7 +321,7 @@ func GetRequestMode(plan AblyRule) string { // GetAwsAuth converts AWS authentication from control SDK format to terraform format. // Using plan to fill in values that the api does not return. -func GetAwsAuth(auth control.AWSAuthentication, plan *AblyRule) AwsAuth { +func GetAwsAuth(rc *reconciler, auth control.AWSAuthentication, plan *AblyRule) AwsAuth { var planAuth AwsAuth switch p := plan.Target.(type) { @@ -339,25 +339,29 @@ func GetAwsAuth(auth control.AWSAuthentication, plan *AblyRule) AwsAuth { } } - var respAwsAuth AwsAuth + // The API returns different fields depending on auth mode. + // Fields not relevant to the current mode are null in the response, + // so we pass types.StringNull() as the output for those — reconcile + // case 4 (both empty → null) or case 2 (echo plan) handles them. + var modeOutput, keyOutput, secretOutput, arnOutput types.String + modeOutput = types.StringValue(auth.AuthenticationMode) switch control.AWSAuthMode(auth.AuthenticationMode) { case control.AWSAuthModeCredentials: - respAwsAuth = AwsAuth{ - AuthenticationMode: types.StringValue(auth.AuthenticationMode), - AccessKeyId: types.StringValue(auth.AccessKeyID), - SecretAccessKey: planAuth.SecretAccessKey, - RoleArn: types.StringNull(), - } + keyOutput = types.StringValue(auth.AccessKeyID) + secretOutput = types.StringNull() // write-only, never returned + arnOutput = types.StringNull() case control.AWSAuthModeAssumeRole: - respAwsAuth = AwsAuth{ - AuthenticationMode: types.StringValue(auth.AuthenticationMode), - RoleArn: types.StringValue(auth.AssumeRoleArn), - AccessKeyId: types.StringNull(), - SecretAccessKey: types.StringNull(), - } + keyOutput = types.StringNull() + secretOutput = types.StringNull() + arnOutput = types.StringValue(auth.AssumeRoleArn) } - return respAwsAuth + return AwsAuth{ + AuthenticationMode: rc.str("target.authentication.mode", planAuth.AuthenticationMode, modeOutput, false), + AccessKeyId: rc.str("target.authentication.access_key_id", planAuth.AccessKeyId, keyOutput, false), + SecretAccessKey: rc.str("target.authentication.secret_access_key", planAuth.SecretAccessKey, secretOutput, false), + RoleArn: rc.str("target.authentication.role_arn", planAuth.RoleArn, arnOutput, false), + } } // unmarshalTarget JSON-marshals the generic target from RuleResponse and unmarshals into a typed struct. @@ -387,8 +391,13 @@ func ToHeaders(headers []control.RuleHeader) []AblyRuleHeaders { // GetRuleResponse maps response body to resource schema attributes. // Using plan to fill in values that the api does not return. // Returns (AblyRule, diag.Diagnostics) so callers can check for unmarshal errors. -func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diag.Diagnostics) { +func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule, reading bool) (AblyRule, diag.Diagnostics) { var diags diag.Diagnostics + rc := newReconciler(&diags) + if reading { + rc.forRead() + } + var respTarget any switch ablyRule.RuleType { @@ -398,13 +407,17 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal aws/kinesis target: %s", err.Error())) return AblyRule{}, diags } + var pt *AblyRuleTargetKinesis + if p, ok := plan.Target.(*AblyRuleTargetKinesis); ok { + pt = p + } respTarget = &AblyRuleTargetKinesis{ - Region: types.StringValue(target.Region), - StreamName: types.StringValue(target.StreamName), - PartitionKey: types.StringValue(target.PartitionKey), - AwsAuth: GetAwsAuth(target.Authentication, plan), - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Region: rc.str("target.region", planStr(pt, func(t *AblyRuleTargetKinesis) types.String { return t.Region }), types.StringValue(target.Region), false), + StreamName: rc.str("target.stream_name", planStr(pt, func(t *AblyRuleTargetKinesis) types.String { return t.StreamName }), types.StringValue(target.StreamName), false), + PartitionKey: rc.str("target.partition_key", planStr(pt, func(t *AblyRuleTargetKinesis) types.String { return t.PartitionKey }), types.StringValue(target.PartitionKey), false), + AwsAuth: GetAwsAuth(rc, target.Authentication, plan), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetKinesis) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetKinesis) types.String { return t.Format }), types.StringValue(target.Format), true), } case "aws/sqs": target, err := unmarshalTarget[control.AWSSQSTarget](ablyRule.Target) @@ -412,13 +425,17 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal aws/sqs target: %s", err.Error())) return AblyRule{}, diags } + var pt *AblyRuleTargetSqs + if p, ok := plan.Target.(*AblyRuleTargetSqs); ok { + pt = p + } respTarget = &AblyRuleTargetSqs{ - Region: types.StringValue(target.Region), - AwsAccountID: types.StringValue(target.AWSAccountID), - QueueName: types.StringValue(target.QueueName), - AwsAuth: GetAwsAuth(target.Authentication, plan), - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Region: rc.str("target.region", planStr(pt, func(t *AblyRuleTargetSqs) types.String { return t.Region }), types.StringValue(target.Region), false), + AwsAccountID: rc.str("target.aws_account_id", planStr(pt, func(t *AblyRuleTargetSqs) types.String { return t.AwsAccountID }), types.StringValue(target.AWSAccountID), false), + QueueName: rc.str("target.queue_name", planStr(pt, func(t *AblyRuleTargetSqs) types.String { return t.QueueName }), types.StringValue(target.QueueName), false), + AwsAuth: GetAwsAuth(rc, target.Authentication, plan), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetSqs) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetSqs) types.String { return t.Format }), types.StringValue(target.Format), true), } case "aws/lambda": target, err := unmarshalTarget[control.AWSLambdaTarget](ablyRule.Target) @@ -426,11 +443,15 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal aws/lambda target: %s", err.Error())) return AblyRule{}, diags } + var pt *AblyRuleTargetLambda + if p, ok := plan.Target.(*AblyRuleTargetLambda); ok { + pt = p + } respTarget = &AblyRuleTargetLambda{ - Region: types.StringValue(target.Region), - FunctionName: types.StringValue(target.FunctionName), - AwsAuth: GetAwsAuth(target.Authentication, plan), - Enveloped: types.BoolValue(deref(target.Enveloped)), + Region: rc.str("target.region", planStr(pt, func(t *AblyRuleTargetLambda) types.String { return t.Region }), types.StringValue(target.Region), false), + FunctionName: rc.str("target.function_name", planStr(pt, func(t *AblyRuleTargetLambda) types.String { return t.FunctionName }), types.StringValue(target.FunctionName), false), + AwsAuth: GetAwsAuth(rc, target.Authentication, plan), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetLambda) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), } case "http/zapier": target, err := unmarshalTarget[control.ZapierRuleTarget](ablyRule.Target) @@ -438,11 +459,14 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http/zapier target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetZapier + if p, ok := plan.Target.(*AblyRuleTargetZapier); ok { + pt = p + } respTarget = &AblyRuleTargetZapier{ - Url: types.StringValue(target.URL), - SigningKeyId: optStringValue(target.SigningKeyID), - Headers: headers, + Url: rc.str("target.url", planStr(pt, func(t *AblyRuleTargetZapier) types.String { return t.Url }), types.StringValue(target.URL), false), + SigningKeyId: rc.str("target.signing_key_id", planStr(pt, func(t *AblyRuleTargetZapier) types.String { return t.SigningKeyId }), optStringValue(target.SigningKeyID), true), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetZapier) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), } case "http/cloudflare-worker": target, err := unmarshalTarget[control.CloudflareWorkerRuleTarget](ablyRule.Target) @@ -450,11 +474,14 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http/cloudflare-worker target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetCloudflareWorker + if p, ok := plan.Target.(*AblyRuleTargetCloudflareWorker); ok { + pt = p + } respTarget = &AblyRuleTargetCloudflareWorker{ - Url: types.StringValue(target.URL), - SigningKeyId: optStringValue(target.SigningKeyID), - Headers: headers, + Url: rc.str("target.url", planStr(pt, func(t *AblyRuleTargetCloudflareWorker) types.String { return t.Url }), types.StringValue(target.URL), false), + SigningKeyId: rc.str("target.signing_key_id", planStr(pt, func(t *AblyRuleTargetCloudflareWorker) types.String { return t.SigningKeyId }), optStringValue(target.SigningKeyID), true), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetCloudflareWorker) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), } case "pulsar": target, err := unmarshalTarget[control.PulsarRuleTarget](ablyRule.Target) @@ -462,12 +489,9 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal pulsar target: %s", err.Error())) return AblyRule{}, diags } - // TlsTrustCerts is write-only in the API (accepted on create/update but - // never returned on read), so preserve whatever the user configured in - // state rather than overwriting it with nil from the API response. - var tlsTrustCerts []types.String - if p, ok := plan.Target.(*AblyRuleTargetPulsar); ok && p != nil { - tlsTrustCerts = p.TlsTrustCerts + var pt *AblyRuleTargetPulsar + if p, ok := plan.Target.(*AblyRuleTargetPulsar); ok { + pt = p } authMode := "" authToken := "" @@ -476,16 +500,16 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, authToken = target.Authentication.Token } respTarget = &AblyRuleTargetPulsar{ - RoutingKey: types.StringValue(target.RoutingKey), - Topic: types.StringValue(target.Topic), - ServiceURL: types.StringValue(target.ServiceURL), - TlsTrustCerts: tlsTrustCerts, + RoutingKey: rc.str("target.routing_key", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.RoutingKey }), types.StringValue(target.RoutingKey), false), + Topic: rc.str("target.topic", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.Topic }), types.StringValue(target.Topic), false), + ServiceURL: rc.str("target.service_url", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.ServiceURL }), types.StringValue(target.ServiceURL), false), + TlsTrustCerts: rcSlice(rc, "target.tls_trust_certs", planSlice(pt, func(t *AblyRuleTargetPulsar) []types.String { return t.TlsTrustCerts }), toTypedStringSlice(target.TLSTrustCerts), false), Authentication: PulsarAuthentication{ - Mode: types.StringValue(authMode), - Token: types.StringValue(authToken), + Mode: rc.str("target.authentication.mode", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.Authentication.Mode }), types.StringValue(authMode), false), + Token: rc.str("target.authentication.token", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.Authentication.Token }), types.StringValue(authToken), false), }, - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetPulsar) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetPulsar) types.String { return t.Format }), types.StringValue(target.Format), true), } case "http/ifttt": target, err := unmarshalTarget[control.IFTTTRuleTarget](ablyRule.Target) @@ -493,9 +517,13 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http/ifttt target: %s", err.Error())) return AblyRule{}, diags } + var pt *AblyRuleTargetIFTTT + if p, ok := plan.Target.(*AblyRuleTargetIFTTT); ok { + pt = p + } respTarget = &AblyRuleTargetIFTTT{ - EventName: types.StringValue(target.EventName), - WebhookKey: types.StringValue(target.WebhookKey), + EventName: rc.str("target.event_name", planStr(pt, func(t *AblyRuleTargetIFTTT) types.String { return t.EventName }), types.StringValue(target.EventName), false), + WebhookKey: rc.str("target.webhook_key", planStr(pt, func(t *AblyRuleTargetIFTTT) types.String { return t.WebhookKey }), types.StringValue(target.WebhookKey), false), } case "http/google-cloud-function": target, err := unmarshalTarget[control.GoogleCloudFunctionRuleTarget](ablyRule.Target) @@ -503,15 +531,18 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http/google-cloud-function target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetGoogleFunction + if p, ok := plan.Target.(*AblyRuleTargetGoogleFunction); ok { + pt = p + } respTarget = &AblyRuleTargetGoogleFunction{ - Region: types.StringValue(target.Region), - ProjectID: types.StringValue(target.ProjectID), - FunctionName: types.StringValue(target.FunctionName), - Headers: headers, - SigningKeyId: optStringValue(target.SigningKeyID), - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Region: rc.str("target.region", planStr(pt, func(t *AblyRuleTargetGoogleFunction) types.String { return t.Region }), types.StringValue(target.Region), false), + ProjectID: rc.str("target.project_id", planStr(pt, func(t *AblyRuleTargetGoogleFunction) types.String { return t.ProjectID }), types.StringValue(target.ProjectID), false), + FunctionName: rc.str("target.function_name", planStr(pt, func(t *AblyRuleTargetGoogleFunction) types.String { return t.FunctionName }), types.StringValue(target.FunctionName), false), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetGoogleFunction) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), + SigningKeyId: rc.str("target.signing_key_id", planStr(pt, func(t *AblyRuleTargetGoogleFunction) types.String { return t.SigningKeyId }), optStringValue(target.SigningKeyID), true), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetGoogleFunction) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetGoogleFunction) types.String { return t.Format }), types.StringValue(target.Format), true), } case "http/azure-function": target, err := unmarshalTarget[control.AzureFunctionRuleTarget](ablyRule.Target) @@ -519,14 +550,17 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http/azure-function target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetAzureFunction + if p, ok := plan.Target.(*AblyRuleTargetAzureFunction); ok { + pt = p + } respTarget = &AblyRuleTargetAzureFunction{ - AzureAppID: types.StringValue(target.AzureAppID), - AzureFunctionName: types.StringValue(target.AzureFunctionName), - Headers: headers, - SigningKeyID: optStringValue(target.SigningKeyID), - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + AzureAppID: rc.str("target.azure_app_id", planStr(pt, func(t *AblyRuleTargetAzureFunction) types.String { return t.AzureAppID }), types.StringValue(target.AzureAppID), false), + AzureFunctionName: rc.str("target.function_name", planStr(pt, func(t *AblyRuleTargetAzureFunction) types.String { return t.AzureFunctionName }), types.StringValue(target.AzureFunctionName), false), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetAzureFunction) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), + SigningKeyID: rc.str("target.signing_key_id", planStr(pt, func(t *AblyRuleTargetAzureFunction) types.String { return t.SigningKeyID }), optStringValue(target.SigningKeyID), true), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetAzureFunction) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetAzureFunction) types.String { return t.Format }), types.StringValue(target.Format), true), } case "http": target, err := unmarshalTarget[control.HTTPRuleTarget](ablyRule.Target) @@ -534,13 +568,16 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal http target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetHTTP + if p, ok := plan.Target.(*AblyRuleTargetHTTP); ok { + pt = p + } respTarget = &AblyRuleTargetHTTP{ - Url: types.StringValue(target.URL), - Headers: headers, - SigningKeyId: optStringValue(target.SigningKeyID), - Format: types.StringValue(target.Format), - Enveloped: types.BoolValue(deref(target.Enveloped)), + Url: rc.str("target.url", planStr(pt, func(t *AblyRuleTargetHTTP) types.String { return t.Url }), types.StringValue(target.URL), false), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetHTTP) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), + SigningKeyId: rc.str("target.signing_key_id", planStr(pt, func(t *AblyRuleTargetHTTP) types.String { return t.SigningKeyId }), optStringValue(target.SigningKeyID), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetHTTP) types.String { return t.Format }), types.StringValue(target.Format), true), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetHTTP) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), } case "kafka": target, err := unmarshalTarget[control.KafkaRuleTarget](ablyRule.Target) @@ -548,6 +585,10 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal kafka target: %s", err.Error())) return AblyRule{}, diags } + var pt *AblyRuleTargetKafka + if p, ok := plan.Target.(*AblyRuleTargetKafka); ok { + pt = p + } saslMechanism := "" saslUsername := "" saslPassword := "" @@ -557,17 +598,17 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, saslPassword = target.Auth.SASL.Password } respTarget = &AblyRuleTargetKafka{ - RoutingKey: types.StringValue(target.RoutingKey), - Brokers: toTypedStringSlice(target.Brokers), + RoutingKey: rc.str("target.routing_key", planStr(pt, func(t *AblyRuleTargetKafka) types.String { return t.RoutingKey }), types.StringValue(target.RoutingKey), false), + Brokers: rcSlice(rc, "target.brokers", planSlice(pt, func(t *AblyRuleTargetKafka) []types.String { return t.Brokers }), toTypedStringSlice(target.Brokers), false), KafkaAuthentication: KafkaAuthentication{ Sasl{ - Mechanism: types.StringValue(saslMechanism), - Username: types.StringValue(saslUsername), - Password: types.StringValue(saslPassword), + Mechanism: rc.str("target.auth.sasl.mechanism", planStr(pt, func(t *AblyRuleTargetKafka) types.String { return t.KafkaAuthentication.Sasl.Mechanism }), types.StringValue(saslMechanism), false), + Username: rc.str("target.auth.sasl.username", planStr(pt, func(t *AblyRuleTargetKafka) types.String { return t.KafkaAuthentication.Sasl.Username }), types.StringValue(saslUsername), false), + Password: rc.str("target.auth.sasl.password", planStr(pt, func(t *AblyRuleTargetKafka) types.String { return t.KafkaAuthentication.Sasl.Password }), types.StringValue(saslPassword), false), }, }, - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetKafka) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetKafka) types.String { return t.Format }), types.StringValue(target.Format), true), } case "amqp": target, err := unmarshalTarget[control.AMQPRuleTarget](ablyRule.Target) @@ -575,12 +616,15 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal amqp target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) + var pt *AblyRuleTargetAMQP + if p, ok := plan.Target.(*AblyRuleTargetAMQP); ok { + pt = p + } respTarget = &AblyRuleTargetAMQP{ - QueueID: types.StringValue(target.QueueID), - Headers: headers, - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + QueueID: rc.str("target.queue_id", planStr(pt, func(t *AblyRuleTargetAMQP) types.String { return t.QueueID }), types.StringValue(target.QueueID), false), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetAMQP) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetAMQP) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetAMQP) types.String { return t.Format }), types.StringValue(target.Format), true), } case "amqp/external": target, err := unmarshalTarget[control.AMQPExternalRuleTarget](ablyRule.Target) @@ -588,43 +632,20 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal amqp/external target: %s", err.Error())) return AblyRule{}, diags } - headers := ToHeaders(target.Headers) - - // Several target fields are not required in the API response and may - // be omitted. When the plan provided values for these fields, preserve - // them so Terraform doesn't see a diff (the target block contains the - // sensitive "url" field, so ANY field mismatch triggers the opaque - // "inconsistent values for sensitive attribute" error). - url := types.StringValue(target.URL) - exchange := types.StringNull() - if target.Exchange != "" { - exchange = types.StringValue(target.Exchange) - } - ttl := types.Int64Null() - if target.MessageTTL != nil && *target.MessageTTL != 0 { - ttl = types.Int64Value(int64(*target.MessageTTL)) - } - if p, ok := plan.Target.(*AblyRuleTargetAMQPExternal); ok && p != nil { - if !p.Url.IsNull() { - url = p.Url - } - if target.Exchange == "" { - exchange = p.Exchange - } - if ttl.IsNull() && !p.MessageTtl.IsNull() { - ttl = p.MessageTtl - } + var pt *AblyRuleTargetAMQPExternal + if p, ok := plan.Target.(*AblyRuleTargetAMQPExternal); ok { + pt = p } respTarget = &AblyRuleTargetAMQPExternal{ - Url: url, - RoutingKey: types.StringValue(target.RoutingKey), - Exchange: exchange, - MandatoryRoute: types.BoolValue(deref(target.MandatoryRoute)), - PersistentMessages: types.BoolValue(deref(target.PersistentMessages)), - MessageTtl: ttl, - Headers: headers, - Enveloped: types.BoolValue(deref(target.Enveloped)), - Format: types.StringValue(target.Format), + Url: rc.str("target.url", planStr(pt, func(t *AblyRuleTargetAMQPExternal) types.String { return t.Url }), types.StringValue(target.URL), false), + RoutingKey: rc.str("target.routing_key", planStr(pt, func(t *AblyRuleTargetAMQPExternal) types.String { return t.RoutingKey }), types.StringValue(target.RoutingKey), false), + Exchange: rc.str("target.exchange", planStr(pt, func(t *AblyRuleTargetAMQPExternal) types.String { return t.Exchange }), optStringValue(&target.Exchange), false), + MandatoryRoute: rc.boolean("target.mandatory_route", planBool(pt, func(t *AblyRuleTargetAMQPExternal) types.Bool { return t.MandatoryRoute }), optBoolValue(target.MandatoryRoute), false), + PersistentMessages: rc.boolean("target.persistent_messages", planBool(pt, func(t *AblyRuleTargetAMQPExternal) types.Bool { return t.PersistentMessages }), optBoolValue(target.PersistentMessages), false), + MessageTtl: rc.int64val("target.message_ttl", planInt64(pt, func(t *AblyRuleTargetAMQPExternal) types.Int64 { return t.MessageTtl }), optIntFromIntPtr(target.MessageTTL), false), + Headers: rcSlice(rc, "target.headers", planSlice(pt, func(t *AblyRuleTargetAMQPExternal) []AblyRuleHeaders { return t.Headers }), ToHeaders(target.Headers), false), + Enveloped: rc.boolean("target.enveloped", planBool(pt, func(t *AblyRuleTargetAMQPExternal) types.Bool { return t.Enveloped }), optBoolValue(target.Enveloped), true), + Format: rc.str("target.format", planStr(pt, func(t *AblyRuleTargetAMQPExternal) types.String { return t.Format }), types.StringValue(target.Format), true), } default: diags.AddError( @@ -634,6 +655,11 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, return AblyRule{}, diags } + var planSource *AblyRuleSource + if plan.Source != nil { + planSource = plan.Source + } + channelFilter := types.StringNull() if ablyRule.Source != nil && ablyRule.Source.ChannelFilter != "" { channelFilter = types.StringValue(ablyRule.Source.ChannelFilter) @@ -645,17 +671,16 @@ func GetRuleResponse(ablyRule *control.RuleResponse, plan *AblyRule) (AblyRule, } respSource := AblyRuleSource{ - ChannelFilter: channelFilter, - Type: types.StringValue(sourceType), + ChannelFilter: rc.str("source.channel_filter", planStr(planSource, func(s *AblyRuleSource) types.String { return s.ChannelFilter }), channelFilter, false), + Type: rc.str("source.type", planStr(planSource, func(s *AblyRuleSource) types.String { return s.Type }), types.StringValue(sourceType), false), } - respRule := AblyRule{ - ID: types.StringValue(ablyRule.ID), - AppID: types.StringValue(ablyRule.AppID), - Status: types.StringValue(ablyRule.Status), + ID: rc.str("id", plan.ID, types.StringValue(ablyRule.ID), true), + AppID: rc.str("app_id", plan.AppID, types.StringValue(ablyRule.AppID), false), + Status: rc.str("status", plan.Status, types.StringValue(ablyRule.Status), true), Source: &respSource, Target: respTarget, - RequestMode: types.StringValue(ablyRule.RequestMode), + RequestMode: rc.str("request_mode", plan.RequestMode, types.StringValue(ablyRule.RequestMode), true), } return respRule, diags @@ -835,7 +860,7 @@ func CreateRule[T any](r Rule, ctx context.Context, req resource.CreateRequest, return } - responseValues, respDiags := GetRuleResponse(&rule, &plan) + responseValues, respDiags := GetRuleResponse(&rule, &plan, false) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return @@ -881,7 +906,7 @@ func ReadRule[T any](r Rule, ctx context.Context, req resource.ReadRequest, resp return } - responseValues, respDiags := GetRuleResponse(&rule, &state) + responseValues, respDiags := GetRuleResponse(&rule, &state, true) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return @@ -930,7 +955,7 @@ func UpdateRule[T any](r Rule, ctx context.Context, req resource.UpdateRequest, return } - responseValues, respDiags := GetRuleResponse(&rule, &plan) + responseValues, respDiags := GetRuleResponse(&rule, &plan, false) resp.Diagnostics.Append(respDiags...) if resp.Diagnostics.HasError() { return diff --git a/internal/provider/rules_unit_test.go b/internal/provider/rules_unit_test.go index 0186658..81e9522 100644 --- a/internal/provider/rules_unit_test.go +++ b/internal/provider/rules_unit_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ably/terraform-provider-ably/control" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -196,13 +197,18 @@ func TestGetAwsAuth_ImportWithNilPlan(t *testing.T) { AccessKeyID: "AKID", } - // Simulate import: plan target is a typed nil. + // Simulate import: plan target is a typed nil, reconciler in read mode. + var diags diag.Diagnostics + rc := newReconciler(&diags).forRead() plan := &AblyRule{ Target: (*AblyRuleTargetLambda)(nil), } - result := GetAwsAuth(auth, plan) + result := GetAwsAuth(rc, auth, plan) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } if result.AuthenticationMode.ValueString() != "credentials" { t.Fatalf("expected mode=credentials, got %q", result.AuthenticationMode.ValueString()) } @@ -219,12 +225,17 @@ func TestGetAwsAuth_AssumeRoleImport(t *testing.T) { AssumeRoleArn: "arn:aws:iam::123:role/test", } + var diags diag.Diagnostics + rc := newReconciler(&diags).forRead() plan := &AblyRule{ Target: (*AblyRuleTargetLambda)(nil), } - result := GetAwsAuth(auth, plan) + result := GetAwsAuth(rc, auth, plan) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } if result.AuthenticationMode.ValueString() != "assumeRole" { t.Fatalf("expected mode=assumeRole, got %q", result.AuthenticationMode.ValueString()) }