Repository: honeycombio/terraform-provider-honeycombio
Pull request #568 (2024-11-14 22:28:15)

chore: migrate r/webhook_recipient to Framework
This migrates `r/webhook_recipient` from the Plugin SDKv2 to the Plugin Framework.

In the process, also updates `secret` to be optional to match the upstream change.

---
- To see the specific tasks where the Asana app for GitHub is being used, see below:
  - https://app.asana.com/0/0/1208745422473717

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) Report
Attention: Patch coverage is `60.63348%` with `87 lines` in your changes missing coverage. Please review.
> Project coverage is 75.56%. Comparing base [(`bec5669`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/bec5669a0a18d19c4e8b27abc2b23c692edf4980?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) to head [(`69f437b`)](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/69f437b3f527848621624f1da9383608b6a1b702?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).

| [Files with missing lines](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) | Patch % | Lines |
|---|---|---|
| [internal/provider/webhook\_recipient\_resource.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?src=pr&el=tree&filepath=internal%2Fprovider%2Fwebhook_recipient_resource.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvcHJvdmlkZXIvd2ViaG9va19yZWNpcGllbnRfcmVzb3VyY2UuZ28=) | 58.82% | [60 Missing and 17 partials :warning: ](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) |
| [internal/helper/validation/valid\_url.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?src=pr&el=tree&filepath=internal%2Fhelper%2Fvalidation%2Fvalid_url.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvaGVscGVyL3ZhbGlkYXRpb24vdmFsaWRfdXJsLmdv) | 69.69% | [9 Missing and 1 partial :warning: ](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #568      +/-   ##
==========================================
- Coverage   76.05%   75.56%   -0.49%     
==========================================
  Files          86       87       +1     
  Lines        7107     7288     +181     
==========================================
+ Hits         5405     5507     +102     
- Misses       1368     1429      +61     
- Partials      334      352      +18     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/568?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).


Patch:
diff --git a/docs/resources/webhook_recipient.md b/docs/resources/webhook_recipient.md
index 43ca3d8..260c183 100644
--- a/docs/resources/webhook_recipient.md
+++ b/docs/resources/webhook_recipient.md
@@ -17,7 +17,7 @@ resource "honeycombio_webhook_recipient" "prod" {
 The following arguments are supported:
 
 * `name` - (Required) The name of the Webhook Integration to create.
-* `secret` - (Required) The secret to include when sending the notification to the webhook.
+* `secret` - (Optional) The secret to include when sending the notification to the webhook.
 * `url` - (Required) The URL of the endpoint to send the notification to.
 
 ## Attribute Reference
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index c5bc0be..653379c 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -71,7 +71,6 @@ func Provider(version string) *schema.Provider {
 			"honeycombio_msteams_recipient":          newMSTeamsRecipient(), // deprecated
 			"honeycombio_msteams_workflow_recipient": newMSTeamsWorkflowRecipient(),
 			"honeycombio_slack_recipient":            newSlackRecipient(),
-			"honeycombio_webhook_recipient":          newWebhookRecipient(),
 			"honeycombio_slo":                        newSLO(),
 		},
 	}
diff --git a/honeycombio/resource_webhook_recipient.go b/honeycombio/resource_webhook_recipient.go
deleted file mode 100644
index 9433310..0000000
--- a/honeycombio/resource_webhook_recipient.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package honeycombio
-
-import (
-	"context"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
-
-	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
-)
-
-func newWebhookRecipient() *schema.Resource {
-	return &schema.Resource{
-		CreateContext: resourceWebhookRecipientCreate,
-		ReadContext:   resourceWebhookRecipientRead,
-		UpdateContext: resourceWebhookRecipientUpdate,
-		DeleteContext: resourceWebhookRecipientDelete,
-		Importer: &schema.ResourceImporter{
-			StateContext: schema.ImportStatePassthroughContext,
-		},
-		Description: "Honeycomb Webhook Recipient allows you to define and manage a Webhook recipient that can be used by Triggers or BurnAlerts notifications.",
-
-		Schema: map[string]*schema.Schema{
-			"name": {
-				Type:        schema.TypeString,
-				Required:    true,
-				Description: "The name of the Webhook Integration to create",
-			},
-			"secret": {
-				Type:        schema.TypeString,
-				Required:    true,
-				Sensitive:   true,
-				Description: "The secret to include when sending the notification to the webhook",
-			},
-			"url": {
-				Type:         schema.TypeString,
-				Required:     true,
-				Description:  "The URL of the endpoint to send the integration to",
-				ValidateFunc: validation.IsURLWithHTTPorHTTPS,
-			},
-		},
-	}
-}
-
-func resourceWebhookRecipientCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	return createRecipient(ctx, d, meta, honeycombio.RecipientTypeWebhook)
-}
-
-func resourceWebhookRecipientRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	return readRecipient(ctx, d, meta, honeycombio.RecipientTypeWebhook)
-}
-
-func resourceWebhookRecipientUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	return updateRecipient(ctx, d, meta, honeycombio.RecipientTypeWebhook)
-}
-
-func resourceWebhookRecipientDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	return deleteRecipient(ctx, d, meta)
-}
diff --git a/internal/helper/validation/valid_url.go b/internal/helper/validation/valid_url.go
new file mode 100644
index 0000000..f4e1744
--- /dev/null
+++ b/internal/helper/validation/valid_url.go
@@ -0,0 +1,66 @@
+package validation
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"slices"
+	"strings"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+)
+
+var _ validator.String = isValidURLValidator{}
+
+type isValidURLValidator struct {
+	schemes []string
+}
+
+func (v isValidURLValidator) Description(_ context.Context) string {
+	return "value must be a valid URL"
+}
+
+func (v isValidURLValidator) MarkdownDescription(ctx context.Context) string {
+	return v.Description(ctx)
+}
+
+func (v isValidURLValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) {
+	if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
+		return
+	}
+
+	u, err := url.Parse(request.ConfigValue.ValueString())
+	if err != nil {
+		response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+			request.Path,
+			v.Description(ctx),
+			fmt.Sprintf("%q: %s", request.ConfigValue.ValueString(), err.Error()),
+		))
+		return
+	}
+
+	if u.Host == "" {
+		response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+			request.Path,
+			v.Description(ctx)+" but is missing a host",
+			request.ConfigValue.ValueString(),
+		))
+	}
+
+	if !slices.Contains(v.schemes, u.Scheme) {
+		response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+			request.Path,
+			fmt.Sprintf("%s with a schema of %q", v.Description(ctx), strings.Join(v.schemes, ",")),
+			request.ConfigValue.ValueString(),
+		))
+	}
+}
+
+// IsURLWithHTTPorHTTPS returns an AttributeValidator which ensures that any configured
+// attribute value is a valid HTTP or HTTPS URL.
+//
+// Null (unconfigured) and unknown (known after apply) values are skipped.
+func IsURLWithHTTPorHTTPS() validator.String {
+	return isValidURLValidator{[]string{"http", "https"}}
+}
diff --git a/internal/models/recipients.go b/internal/models/recipients.go
new file mode 100644
index 0000000..c98586e
--- /dev/null
+++ b/internal/models/recipients.go
@@ -0,0 +1,12 @@
+package models
+
+import (
+	"github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+type WebhookRecipientModel struct {
+	ID     types.String `tfsdk:"id"`
+	Name   types.String `tfsdk:"name"`
+	Secret types.String `tfsdk:"secret"`
+	URL    types.String `tfsdk:"url"`
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index c24e330..7bdb87b 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -80,6 +80,7 @@ func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.R
 		NewDatasetResource,
 		NewTriggerResource,
 		NewQueryResource,
+		NewWebhookRecipientResource,
 		NewAPIKeyResource,
 		NewEnvironmentResource,
 	}
diff --git a/internal/provider/webhook_recipient_resource.go b/internal/provider/webhook_recipient_resource.go
new file mode 100644
index 0000000..1066f7c
--- /dev/null
+++ b/internal/provider/webhook_recipient_resource.go
@@ -0,0 +1,249 @@
+package provider
+
+import (
+	"context"
+	"errors"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+	_ resource.Resource                = &webhookRecipientResource{}
+	_ resource.ResourceWithConfigure   = &webhookRecipientResource{}
+	_ resource.ResourceWithImportState = &webhookRecipientResource{}
+)
+
+type webhookRecipientResource struct {
+	client *client.Client
+}
+
+func NewWebhookRecipientResource() resource.Resource {
+	return &webhookRecipientResource{}
+}
+
+func (*webhookRecipientResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_webhook_recipient"
+}
+
+func (r *webhookRecipientResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	w := getClientFromResourceRequest(&req)
+	if w == nil {
+		return
+	}
+
+	c, err := w.V1Client()
+	if err != nil || c == nil {
+		resp.Diagnostics.AddError("Failed to configure client", err.Error())
+		return
+	}
+	r.client = c
+}
+
+func (*webhookRecipientResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Description: "A Webhook recipient can be used by Triggers or BurnAlerts notifications to send an event to an HTTP endpoint.",
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Description: "The unique identifier for this Recipient.",
+				Computed:    true,
+				Required:    false,
+				Optional:    false,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"name": schema.StringAttribute{
+				Description: "The name of this Webhook recipient.",
+				Required:    true,
+				Validators: []validator.String{
+					stringvalidator.LengthBetween(1, 255),
+				},
+			},
+			"secret": schema.StringAttribute{
+				Description: "The secret to include when sending the notification to the webhook.",
+				Optional:    true,
+				Sensitive:   true,
+				Validators: []validator.String{
+					stringvalidator.LengthAtMost(255),
+				},
+			},
+			"url": schema.StringAttribute{
+				Description: "The URL of the endpoint the notification will be sent to.",
+				Required:    true,
+				Validators: []validator.String{
+					stringvalidator.LengthBetween(1, 2048),
+					validation.IsURLWithHTTPorHTTPS(),
+				},
+			},
+		},
+	}
+}
+
+func (r *webhookRecipientResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	if req.ID == "" {
+		resp.Diagnostics.AddError("Invalid Import ID", "The Recipient ID must be provided")
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &models.WebhookRecipientModel{
+		ID: types.StringValue(req.ID),
+	})...)
+}
+
+func (r *webhookRecipientResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan models.WebhookRecipientModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	rcpt, err := r.client.Recipients.Create(ctx, &client.Recipient{
+		Type: client.RecipientTypeWebhook,
+		Details: client.RecipientDetails{
+			WebhookName:   plan.Name.ValueString(),
+			WebhookURL:    plan.URL.ValueString(),
+			WebhookSecret: plan.Secret.ValueString(),
+		},
+	})
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb Webhook Recipient", err) {
+		return
+	}
+
+	var state models.WebhookRecipientModel
+	state.ID = types.StringValue(rcpt.ID)
+	state.Name = types.StringValue(rcpt.Details.WebhookName)
+	state.URL = types.StringValue(rcpt.Details.WebhookURL)
+	if rcpt.Details.WebhookSecret != "" {
+		state.Secret = types.StringValue(rcpt.Details.WebhookSecret)
+	} else {
+		state.Secret = types.StringNull()
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *webhookRecipientResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var state models.WebhookRecipientModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	var detailedErr client.DetailedError
+	rcpt, err := r.client.Recipients.Get(ctx, state.ID.ValueString())
+	if errors.As(err, &detailedErr) {
+		if detailedErr.IsNotFound() {
+			// if not found consider it deleted -- so just remove it from state
+			resp.State.RemoveResource(ctx)
+			return
+		} else {
+			resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
+				"Error Reading Honeycomb Webhook Recipient",
+				&detailedErr,
+			))
+		}
+	} else if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Webhook Recipient",
+			"Unexpected error reading Webhook Recipient "+state.ID.ValueString()+": "+err.Error(),
+		)
+	}
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	if rcpt.Type != client.RecipientTypeWebhook {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Webhook Recipient",
+			"Unexpected recipient type "+rcpt.Type.String(),
+		)
+		return
+	}
+
+	state.ID = types.StringValue(rcpt.ID)
+	state.Name = types.StringValue(rcpt.Details.WebhookName)
+	state.URL = types.StringValue(rcpt.Details.WebhookURL)
+	if rcpt.Details.WebhookSecret != "" {
+		state.Secret = types.StringValue(rcpt.Details.WebhookSecret)
+	} else {
+		state.Secret = types.StringNull()
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *webhookRecipientResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var plan models.WebhookRecipientModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	_, err := r.client.Recipients.Update(ctx, &client.Recipient{
+		ID:   plan.ID.ValueString(),
+		Type: client.RecipientTypeWebhook,
+		Details: client.RecipientDetails{
+			WebhookName:   plan.Name.ValueString(),
+			WebhookURL:    plan.URL.ValueString(),
+			WebhookSecret: plan.Secret.ValueString(),
+		},
+	})
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Webhook Recipient", err) {
+		return
+	}
+
+	rcpt, err := r.client.Recipients.Get(ctx, plan.ID.ValueString())
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb Burn Alert", err) {
+		return
+	}
+
+	var state models.WebhookRecipientModel
+	state.ID = types.StringValue(rcpt.ID)
+	state.Name = types.StringValue(rcpt.Details.WebhookName)
+	state.URL = types.StringValue(rcpt.Details.WebhookURL)
+	if rcpt.Details.WebhookSecret != "" {
+		state.Secret = types.StringValue(rcpt.Details.WebhookSecret)
+	} else {
+		state.Secret = types.StringNull()
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *webhookRecipientResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var state models.WebhookRecipientModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	var detailedErr client.DetailedError
+	err := r.client.Recipients.Delete(ctx, state.ID.ValueString())
+	if err != nil {
+		if errors.As(err, &detailedErr) {
+			// if not found consider it deleted -- so don't error
+			if !detailedErr.IsNotFound() {
+				resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
+					"Error Deleting Honeycomb Webhook Recipient",
+					&detailedErr,
+				))
+			}
+		} else {
+			resp.Diagnostics.AddError(
+				"Error Deleting Honeycomb Webhook Recipient",
+				"Could not delete Webhook Recipient ID "+state.ID.ValueString()+": "+err.Error(),
+			)
+		}
+	}
+}


Test patch:
diff --git a/internal/helper/validation/divisible_by_test.go b/internal/helper/validation/divisible_by_test.go
index 27752c4..4925388 100644
--- a/internal/helper/validation/divisible_by_test.go
+++ b/internal/helper/validation/divisible_by_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/path"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 )
@@ -54,13 +55,11 @@ func Test_BetweenValidator(t *testing.T) {
 			response := validator.Int64Response{}
 			validation.Int64DivisibleBy(test.divisor).ValidateInt64(context.Background(), request, &response)
 
-			if !response.Diagnostics.HasError() && test.expectError {
-				t.Fatal("expected error, got no error")
-			}
-
-			if response.Diagnostics.HasError() && !test.expectError {
-				t.Fatalf("got unexpected error: %s", response.Diagnostics)
-			}
+			assert.Equal(t,
+				test.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
 		})
 	}
 }
diff --git a/internal/helper/validation/precision_at_most_test.go b/internal/helper/validation/precision_at_most_test.go
index bb0d969..f652287 100644
--- a/internal/helper/validation/precision_at_most_test.go
+++ b/internal/helper/validation/precision_at_most_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/path"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 )
@@ -64,13 +65,11 @@ func TestValidation_PrecisionAtMost(t *testing.T) {
 			response := validator.Float64Response{}
 			validation.Float64PrecisionAtMost(test.maxPrecision).ValidateFloat64(context.Background(), request, &response)
 
-			if !response.Diagnostics.HasError() && test.expectError {
-				t.Fatal("expected error, got no error")
-			}
-
-			if response.Diagnostics.HasError() && !test.expectError {
-				t.Fatalf("got unexpected error: %s", response.Diagnostics)
-			}
+			assert.Equal(t,
+				test.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
 		})
 	}
 }
diff --git a/internal/helper/validation/query_spec_test.go b/internal/helper/validation/query_spec_test.go
index e3f665b..9b23fa3 100644
--- a/internal/helper/validation/query_spec_test.go
+++ b/internal/helper/validation/query_spec_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/path"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 )
@@ -48,13 +49,11 @@ func Test_QuerySpecValidator(t *testing.T) {
 			response := validator.StringResponse{}
 			validation.ValidQuerySpec().ValidateString(context.Background(), request, &response)
 
-			if !response.Diagnostics.HasError() && test.expectError {
-				t.Fatal("expected error, got no error")
-			}
-
-			if response.Diagnostics.HasError() && !test.expectError {
-				t.Fatalf("got unexpected error: %s", response.Diagnostics)
-			}
+			assert.Equal(t,
+				test.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
 		})
 	}
 }
diff --git a/internal/helper/validation/valid_regex_test.go b/internal/helper/validation/valid_regex_test.go
index b71279d..0bddec9 100644
--- a/internal/helper/validation/valid_regex_test.go
+++ b/internal/helper/validation/valid_regex_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/path"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 )
@@ -45,13 +46,11 @@ func Test_IsValidRegexValidator(t *testing.T) {
 			response := validator.StringResponse{}
 			validation.IsValidRegExp().ValidateString(context.Background(), request, &response)
 
-			if !response.Diagnostics.HasError() && test.expectError {
-				t.Fatal("expected error, got no error")
-			}
-
-			if response.Diagnostics.HasError() && !test.expectError {
-				t.Fatalf("got unexpected error: %s", response.Diagnostics)
-			}
+			assert.Equal(t,
+				test.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
 		})
 	}
 }
diff --git a/internal/helper/validation/valid_url_test.go b/internal/helper/validation/valid_url_test.go
new file mode 100644
index 0000000..d2eb698
--- /dev/null
+++ b/internal/helper/validation/valid_url_test.go
@@ -0,0 +1,73 @@
+package validation_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+)
+
+func Test_IsURLWithHTTPorHTTPS(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	type testCase struct {
+		val         types.String
+		expectError bool
+	}
+	tests := map[string]testCase{
+		"unknown": {
+			val: types.StringUnknown(),
+		},
+		"null": {
+			val: types.StringNull(),
+		},
+		"valid http": {
+			val: types.StringValue("http://sub.example.com/a/b/c/d?e=f#g"),
+		},
+		"valid https": {
+			val: types.StringValue("https://sub.example.com/a/b/c/d?e=f#g"),
+		},
+		"empty": {
+			val:         types.StringValue(""),
+			expectError: true,
+		},
+		"garbage": {
+			val:         types.StringValue("not-a-url"),
+			expectError: true,
+		},
+		"missing host": {
+			val:         types.StringValue("http:///a/b/c/d?e=f#g"),
+			expectError: true,
+		},
+		"invalid scheme": {
+			val:         types.StringValue("ftp://sub.example.com/"),
+			expectError: true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			request := validator.StringRequest{
+				Path:           path.Root("test"),
+				PathExpression: path.MatchRoot("test"),
+				ConfigValue:    test.val,
+			}
+			response := validator.StringResponse{}
+			validation.IsURLWithHTTPorHTTPS().ValidateString(ctx, request, &response)
+
+			assert.Equal(t,
+				test.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
+		})
+	}
+}
diff --git a/internal/provider/dataset_resource_test.go b/internal/provider/dataset_resource_test.go
index e368fed..43cd6ba 100644
--- a/internal/provider/dataset_resource_test.go
+++ b/internal/provider/dataset_resource_test.go
@@ -195,8 +195,7 @@ resource "honeycombio_dataset" "test" {
 }`, name),
 			},
 		},
-	},
-	)
+	})
 }
 
 func testAccConfigBasicDatasetTest(name, description string, jsonDepth int, protected bool) string {
diff --git a/internal/provider/query_resource_test.go b/internal/provider/query_resource_test.go
index f92e457..ca5d4b6 100644
--- a/internal/provider/query_resource_test.go
+++ b/internal/provider/query_resource_test.go
@@ -139,7 +139,7 @@ func TestAcc_QueryResourceUpgradeFromVersion022(t *testing.T) {
 	})
 }
 
-// TestAcc_QueryResourceEquivalentQuerySpecSupressed tests the  behavior of the
+// TestAcc_QueryResourceEquivalentQuerySpecSupressed tests the behavior of the
 // resource when an equivalent query is suppressed by the plan modifier.
 func TestAcc_QueryResourceEquivalentQuerySpecSupressed(t *testing.T) {
 	dataset := testAccDataset()
diff --git a/internal/provider/webhook_recipient_resource_test.go b/internal/provider/webhook_recipient_resource_test.go
new file mode 100644
index 0000000..fab2723
--- /dev/null
+++ b/internal/provider/webhook_recipient_resource_test.go
@@ -0,0 +1,120 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test"
+)
+
+func TestAcc_WebhookRecipientResource(t *testing.T) {
+	t.Run("happy path", func(t *testing.T) {
+		name := test.RandomStringWithPrefix("test.", 20)
+		url := test.RandomURL()
+
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+}`, name, url),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckNoResourceAttr("honeycombio_webhook_recipient.test", "secret"),
+					),
+				},
+				{
+					Config: fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name = "%s"
+	url  = "%s"
+
+	secret = "so-secret"
+}`, name, url),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+						resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+						resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "secret", "so-secret"),
+					),
+				},
+				{
+					ResourceName: "honeycombio_webhook_recipient.test",
+					ImportState:  true,
+				},
+			},
+		})
+	})
+}
+
+// TestAcc_WebhookRecipientResource_UpgradeFromVersion027 tests the migration case from the
+// last SDK-based version of the Webhook Recipient resource to the current Framework-based version.
+//
+// See: https://developer.hashicorp.com/terraform/plugin/framework/migrating/testing#testing-migration
+func TestAcc_WebhookRecipientResource_UpgradeFromVersion027(t *testing.T) {
+	name := test.RandomStringWithPrefix("test.", 20)
+	url := test.RandomURL()
+	config := fmt.Sprintf(`
+resource "honeycombio_webhook_recipient" "test" {
+  name   = "%s"
+	url    = "%s"
+	secret = "so-secret"
+}`, name, url)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck: testAccPreCheck(t),
+		Steps: []resource.TestStep{
+			{
+				ExternalProviders: map[string]resource.ExternalProvider{
+					"honeycombio": {
+						VersionConstraint: "0.27",
+						Source:            "honeycombio/honeycombio",
+					},
+				},
+				Config: config,
+				Check: resource.ComposeAggregateTestCheckFunc(
+					testAccEnsureRecipientExists(t, "honeycombio_webhook_recipient.test"),
+				),
+			},
+			{
+				ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+				Config:                   config,
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestCheckResourceAttrSet("honeycombio_webhook_recipient.test", "id"),
+					resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "name", name),
+					resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "url", url),
+					resource.TestCheckResourceAttr("honeycombio_webhook_recipient.test", "secret", "so-secret"),
+				),
+			},
+		},
+	})
+}
+
+func testAccEnsureRecipientExists(t *testing.T, name string) resource.TestCheckFunc { //nolint:unparam
+	return func(s *terraform.State) error {
+		rs, ok := s.RootModule().Resources[name]
+		if !ok {
+			return fmt.Errorf("resource not found: %s", name)
+		}
+
+		client := testAccClient(t)
+		_, err := client.Recipients.Get(context.Background(), rs.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch created recipient: %s", err)
+		}
+
+		return nil
+	}
+}