Files
cairnobs/terraform/internal/provider/client_test.go
T
jcoffey-dev 49dd050689 Start the Terraform provider: sentry_dashboard, the first resource
CLAUDE.md names the Terraform provider a first-class deliverable
alongside sentryctl ("CLI and Terraform provider are first-class, not
afterthoughts"), but no phase before this one had actually built any of
it -- no terraform/ directory existed. This is a first slice, not a
finished provider: one resource, scoped and confirmed with the project
owner before starting (a new pinned external dependency and an
architectural decision not covered in /docs/architecture.md are both
things CLAUDE.md's own "When in doubt" section says to ask about).

New Go module (terraform/, github.com/sentry/sentry/terraform) built on
HashiCorp's terraform-plugin-framework -- the actively-developed
library, not the legacy SDKv2, since there's no existing provider code
to migrate and no reason to start new on the framework HashiCorp itself
steers people away from.

internal/provider/client.go talks the exact same JSON contract
sentryctl's "dashboards apply" and web's Export JSON button already use
against api/dashboards.Handler (POST/GET/PUT/DELETE /dashboards[/{id}]) --
cli/README.md already named this "the seed of a future Terraform
provider: one JSON contract, multiple callers," this is that third
caller, not a new contract invented for Terraform's sake.

sentry_dashboard's schema deliberately leaves default_earliest/
default_latest Optional+Computed with no Terraform-side static default,
even though the API defaults them to "-1h"/"now" when empty -- letting
the API stay the one source of truth for what "unset" means rather than
duplicating that default in two places that could drift. tenant_id is
Computed-only, matching api/dashboards.Handler's own tenantID() doc
comment that a client-supplied value is always overridden server-side.

Panels are not modeled by this resource -- a genuinely separate resource
shape (own lifecycle, own endpoints, own validation needs), scoped out
deliberately, not an oversight. Alert rules, notification targets, and
tenant/RBAC resources are the same: real, disclosed future work, not
attempted in this pass. See terraform/README.md for the full accounting.

Verified: client_test.go runs real HTTP round trips against httptest.
Server (request construction, response parsing, the 404-vs-other-error
distinction Read/Delete need for Terraform's out-of-band-deletion
convention) -- same pattern cli/cmd/sentryctl's own tests already use
against the same api/dashboards endpoints. provider_test.go validates
both schemas are internally well-formed without needing a Terraform
binary. dashboard_resource_test.go's TestAccDashboardResource_basic is a
real acceptance test (terraform-plugin-testing), skip-gated by TF_ACC=1
per that framework's own convention -- even with TF_ACC set it would
still need a live api service (Postgres+ClickHouse) to apply against,
which this environment has no Docker access to bring up, so it has not
actually run here, same disclosed gap as every other live-infra test in
this repo.
2026-08-15 00:06:26 -07:00

153 lines
5.0 KiB
Go

package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestCreateDashboardSendsExpectedRequest is the same "real
// httptest.Server, real HTTP round trip" pattern
// cli/cmd/sentryctl's own tests use against the same api/dashboards
// endpoints -- this client has no fake/mock mode, so its tests exercise
// real request construction and real response parsing throughout.
func TestCreateDashboardSendsExpectedRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/dashboards" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want Bearer test-token", got)
}
var body dashboard
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding request body: %v", err)
}
if body.Name != "My Dashboard" {
t.Errorf("request body Name = %q, want My Dashboard", body.Name)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(dashboard{
ID: "dash-1", TenantID: "acme", Name: body.Name,
DefaultEarliest: "-1h", DefaultLatest: "now",
})
}))
defer srv.Close()
c := newClient(srv.URL, "test-token")
out, err := c.createDashboard(context.Background(), &dashboard{Name: "My Dashboard"})
if err != nil {
t.Fatalf("createDashboard: %v", err)
}
if out.ID != "dash-1" || out.TenantID != "acme" || out.DefaultEarliest != "-1h" {
t.Fatalf("unexpected response: %+v", out)
}
}
func TestGetDashboardNotFoundIsRecognizable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "dashboard not found"})
}))
defer srv.Close()
c := newClient(srv.URL, "")
_, err := c.getDashboard(context.Background(), "does-not-exist")
if err == nil {
t.Fatal("expected an error for a 404 response")
}
if !isNotFound(err) {
t.Fatalf("isNotFound(%v) = false, want true", err)
}
}
func TestGetDashboardServerErrorIsNotNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := newClient(srv.URL, "")
_, err := c.getDashboard(context.Background(), "dash-1")
if err == nil {
t.Fatal("expected an error for a 500 response")
}
if isNotFound(err) {
t.Fatal("isNotFound must be false for a 500 -- only a real 404 means \"this resource is gone\"")
}
}
func TestUpdateDashboardSendsToCorrectPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/dashboards/dash-1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dashboard{ID: "dash-1", Name: "Renamed"})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.updateDashboard(context.Background(), "dash-1", &dashboard{Name: "Renamed"})
if err != nil {
t.Fatalf("updateDashboard: %v", err)
}
if out.Name != "Renamed" {
t.Fatalf("Name = %q, want Renamed", out.Name)
}
}
func TestDeleteDashboardSendsToCorrectPath(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodDelete || r.URL.Path != "/dashboards/dash-1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := newClient(srv.URL, "")
if err := c.deleteDashboard(context.Background(), "dash-1"); err != nil {
t.Fatalf("deleteDashboard: %v", err)
}
if !called {
t.Fatal("expected the server to receive a DELETE request")
}
}
func TestDoOmitsAuthorizationHeaderWhenNoTokenConfigured(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Errorf("expected no Authorization header, got %q", r.Header.Get("Authorization"))
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := newClient(srv.URL, "")
if err := c.do(context.Background(), http.MethodGet, "/dashboards", nil, nil); err != nil {
t.Fatalf("do: %v", err)
}
}
func TestApiErrorSurfacesPlainTextBodyWhenNotJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("forbidden"))
}))
defer srv.Close()
c := newClient(srv.URL, "")
_, err := c.getDashboard(context.Background(), "dash-1")
if err == nil || !strings.Contains(err.Error(), "forbidden") {
t.Fatalf("err = %v, want it to surface the plain-text body", err)
}
}