The open design question named in the last three commits' README --
"a sentry_dashboard_panel resource (or a panels list block on this one)"
-- is resolved: separate resource, matching api/dashboards.Handler's own
shape (a panel is created/updated/deleted independently of its parent
dashboard via its own endpoints, never by rewriting the dashboard's
whole panel list). A nested list block would have forced every panel to
be rewritten on any single panel's change, hiding fine-grained diffs a
separate resource shows naturally -- the more idiomatic Terraform
pattern for independently-lifecycled child resources, and the one that
matches what the API actually does.
Unlike sentry_alert_rule/sentry_notification_target, this resource
supports a real in-place Update -- api/dashboards.Handler actually has a
PUT /dashboards/{id}/panels/{panelId}. Only dashboard_id forces
RequiresReplace: UpdatePanel's SQL matches WHERE id = $panelID AND
dashboard_id = $dashboardID, so changing dashboard_id through the
existing panel's URL wouldn't move it, it would just fail to match --
there's no API operation for "move a panel to a different dashboard."
Panels have no standalone GET endpoint -- only GET /dashboards/{id},
which includes the full panels array. client.go's new getPanel fetches
the parent dashboard and finds the panel by ID within it, returning the
same *apiError{StatusCode: 404} shape a direct GET would whether the
dashboard itself or just the panel within it is gone, so isNotFound
works identically either way. This also means a bare panel ID isn't
enough to import from -- ImportState takes "dashboard_id/panel_id" and
splits on the last "/", the one resource here with a composite import
identifier.
query_language never accepts "sql" for panels specifically -- confirmed
in api/dashboards's own validatePanel ("dashboards only support
pipe-syntax queries, since the dashboard time-range picker is injected
as leading query terms"), a real constraint from the API this client
doesn't re-validate client-side (same "let the API be the one source of
truth for validation" posture the other resources already take), but
documented in the schema so it's not a surprise 400 from Create.
sentry_dashboard_panel gets a matching data source too
(dashboard_id + id both Required, unlike the other three data sources'
single Required id, since getPanel itself needs both).
Verified: client tests are real httptest.Server round trips, including
getPanel finding the right panel within a real dashboard response and
returning a recognizable not-found both when the panel is missing and
when the parent dashboard itself is gone. Schema validation needs no
Terraform binary. TestAccDashboardPanelResource_basic and
TestAccDashboardPanelDataSource_basic are real acceptance tests,
skip-gated by TF_ACC same as the other six -- the resource test proves a
genuine in-place update (a title change, no plancheck needed since
in-place update is the default expectation here, unlike the
create/destroy-only resources). Not run against a live stack in this
environment, same disclosed gap as everything else Docker-gated in this
repo.
269 lines
8.7 KiB
Go
269 lines
8.7 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/terraform-plugin-framework/datasource"
|
|
"github.com/hashicorp/terraform-plugin-framework/provider"
|
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
|
)
|
|
|
|
// TestProviderSchemaValid and TestDashboardResourceSchemaValid don't
|
|
// need a Terraform binary or a live api service -- ValidateImplementation
|
|
// runs the same internal consistency checks
|
|
// terraform-plugin-framework's own protocol layer would (attribute
|
|
// names are valid identifiers, no Optional+Required conflicts, etc.),
|
|
// catching a broken schema before it ever reaches an acceptance test.
|
|
func TestProviderSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := provider.SchemaRequest{}
|
|
resp := &provider.SchemaResponse{}
|
|
|
|
New("test")().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("provider schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
for _, attr := range []string{"endpoint", "alerting_endpoint", "token"} {
|
|
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
|
t.Errorf("provider schema missing expected attribute %q", attr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDashboardResourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := resource.SchemaRequest{}
|
|
resp := &resource.SchemaResponse{}
|
|
|
|
newDashboardResource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_dashboard schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
for _, attr := range []string{
|
|
"id", "tenant_id", "name", "description",
|
|
"default_earliest", "default_latest", "created_by", "created_at", "updated_at",
|
|
} {
|
|
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
|
t.Errorf("sentry_dashboard schema missing expected attribute %q", attr)
|
|
}
|
|
}
|
|
if !resp.Schema.Attributes["name"].IsRequired() {
|
|
t.Error(`"name" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsComputed() {
|
|
t.Error(`"id" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestDashboardResourceMetadataSetsTypeName(t *testing.T) {
|
|
resp := &resource.MetadataResponse{}
|
|
newDashboardResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
|
if resp.TypeName != "sentry_dashboard" {
|
|
t.Fatalf("TypeName = %q, want sentry_dashboard", resp.TypeName)
|
|
}
|
|
}
|
|
|
|
func TestDashboardPanelResourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := resource.SchemaRequest{}
|
|
resp := &resource.SchemaResponse{}
|
|
|
|
newDashboardPanelResource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_dashboard_panel schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
for _, attr := range []string{
|
|
"id", "dashboard_id", "title", "query", "query_language", "viz_type", "viz_config",
|
|
"position_x", "position_y", "width", "height",
|
|
"earliest_override", "latest_override", "sort_order", "created_at", "updated_at",
|
|
} {
|
|
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
|
t.Errorf("sentry_dashboard_panel schema missing expected attribute %q", attr)
|
|
}
|
|
}
|
|
if !resp.Schema.Attributes["query"].IsRequired() {
|
|
t.Error(`"query" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["dashboard_id"].IsRequired() {
|
|
t.Error(`"dashboard_id" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsComputed() {
|
|
t.Error(`"id" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestDashboardPanelResourceMetadataSetsTypeName(t *testing.T) {
|
|
resp := &resource.MetadataResponse{}
|
|
newDashboardPanelResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
|
if resp.TypeName != "sentry_dashboard_panel" {
|
|
t.Fatalf("TypeName = %q, want sentry_dashboard_panel", resp.TypeName)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleResourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := resource.SchemaRequest{}
|
|
resp := &resource.SchemaResponse{}
|
|
|
|
newAlertRuleResource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_alert_rule schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
for _, attr := range []string{
|
|
"id", "tenant_id", "name", "description", "query", "query_language",
|
|
"condition_type", "comparator", "threshold_value", "eval_interval_seconds",
|
|
"for_minutes", "renotify_interval_minutes", "notification_target_id", "enabled", "created_by",
|
|
} {
|
|
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
|
t.Errorf("sentry_alert_rule schema missing expected attribute %q", attr)
|
|
}
|
|
}
|
|
if !resp.Schema.Attributes["name"].IsRequired() {
|
|
t.Error(`"name" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsComputed() {
|
|
t.Error(`"id" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleResourceMetadataSetsTypeName(t *testing.T) {
|
|
resp := &resource.MetadataResponse{}
|
|
newAlertRuleResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
|
if resp.TypeName != "sentry_alert_rule" {
|
|
t.Fatalf("TypeName = %q, want sentry_alert_rule", resp.TypeName)
|
|
}
|
|
}
|
|
|
|
func TestNotificationTargetResourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := resource.SchemaRequest{}
|
|
resp := &resource.SchemaResponse{}
|
|
|
|
newNotificationTargetResource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_notification_target schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
for _, attr := range []string{
|
|
"id", "tenant_id", "name", "kind", "webhook_url",
|
|
"payload_template", "headers", "secret", "created_by",
|
|
} {
|
|
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
|
t.Errorf("sentry_notification_target schema missing expected attribute %q", attr)
|
|
}
|
|
}
|
|
if !resp.Schema.Attributes["name"].IsRequired() {
|
|
t.Error(`"name" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["secret"].IsSensitive() {
|
|
t.Error(`"secret" must be Sensitive`)
|
|
}
|
|
}
|
|
|
|
func TestNotificationTargetResourceMetadataSetsTypeName(t *testing.T) {
|
|
resp := &resource.MetadataResponse{}
|
|
newNotificationTargetResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
|
if resp.TypeName != "sentry_notification_target" {
|
|
t.Fatalf("TypeName = %q, want sentry_notification_target", resp.TypeName)
|
|
}
|
|
}
|
|
|
|
func TestDashboardDataSourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := datasource.SchemaRequest{}
|
|
resp := &datasource.SchemaResponse{}
|
|
|
|
newDashboardDataSource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_dashboard data source schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsRequired() {
|
|
t.Error(`"id" must be Required -- a data source needs it to know what to look up`)
|
|
}
|
|
if !resp.Schema.Attributes["name"].IsComputed() {
|
|
t.Error(`"name" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestDashboardPanelDataSourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := datasource.SchemaRequest{}
|
|
resp := &datasource.SchemaResponse{}
|
|
|
|
newDashboardPanelDataSource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_dashboard_panel data source schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
if !resp.Schema.Attributes["dashboard_id"].IsRequired() {
|
|
t.Error(`"dashboard_id" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsRequired() {
|
|
t.Error(`"id" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["query"].IsComputed() {
|
|
t.Error(`"query" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestAlertRuleDataSourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := datasource.SchemaRequest{}
|
|
resp := &datasource.SchemaResponse{}
|
|
|
|
newAlertRuleDataSource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_alert_rule data source schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsRequired() {
|
|
t.Error(`"id" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["query"].IsComputed() {
|
|
t.Error(`"query" must be Computed`)
|
|
}
|
|
}
|
|
|
|
func TestNotificationTargetDataSourceSchemaValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := datasource.SchemaRequest{}
|
|
resp := &datasource.SchemaResponse{}
|
|
|
|
newNotificationTargetDataSource().Schema(ctx, req, resp)
|
|
|
|
if resp.Diagnostics.HasError() {
|
|
t.Fatalf("sentry_notification_target data source schema has errors: %v", resp.Diagnostics)
|
|
}
|
|
if !resp.Schema.Attributes["id"].IsRequired() {
|
|
t.Error(`"id" must be Required`)
|
|
}
|
|
if !resp.Schema.Attributes["secret"].IsSensitive() {
|
|
t.Error(`"secret" must be Sensitive`)
|
|
}
|
|
}
|
|
|
|
func TestDataSourcesMetadataSetTypeNames(t *testing.T) {
|
|
cases := []struct {
|
|
newDS func() datasource.DataSource
|
|
wantType string
|
|
}{
|
|
{newDashboardDataSource, "sentry_dashboard"},
|
|
{newDashboardPanelDataSource, "sentry_dashboard_panel"},
|
|
{newAlertRuleDataSource, "sentry_alert_rule"},
|
|
{newNotificationTargetDataSource, "sentry_notification_target"},
|
|
}
|
|
for _, c := range cases {
|
|
resp := &datasource.MetadataResponse{}
|
|
c.newDS().Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
|
if resp.TypeName != c.wantType {
|
|
t.Errorf("TypeName = %q, want %q", resp.TypeName, c.wantType)
|
|
}
|
|
}
|
|
}
|