Files
cairnobs/terraform/internal/provider/client_test.go
T
jcoffey-dev 30ae84cd04 Add sentry_notification_target, closing the alert-rule-as-code loop
sentry_alert_rule.notification_target_id could previously only point at
a target created outside Terraform (sentryctl/curl/the web UI) --
without this resource, "manage alert rules as code" was only half true.
Same create/destroy-only shape as sentry_alert_rule and for the same
reason: alerting has no PUT /targets/{id} either, confirmed down to
notifystore.Store (Create/List/Get/Delete, no Update).

client.go's notificationTarget type mirrors notifystore.Target's JSON
shape. headers stays raw JSON bytes end to end -- the client has no
opinion about its shape (neither does alerting's own Target type,
json.RawMessage), and the resource layer round-trips it as a plain
JSON-text string a caller provides via Terraform's jsonencode().

secret is marked Sensitive in the schema, but alerting's own
GET /targets/{id} returns it unredacted (confirmed in
notifystore/store.go -- no redaction at the store or handler layer, an
existing property of alerting's API, not something this provider
introduces). A new client test
(TestGetNotificationTargetReturnsSecretUnredacted) documents that real
behavior so a future change to it would be caught here, not discovered
by surprise. Sensitive keeps the value out of plan/apply console output;
it does not keep it out of Terraform state, the standard caveat for any
sensitive attribute, named explicitly in the schema description and
README rather than left implicit.

Examples updated end to end: sentry_alert_rule's example now creates a
real sentry_notification_target and references its .id, instead of a
placeholder string.

Verified: client tests are real httptest.Server round trips. Schema
validation needs no Terraform binary.
TestAccNotificationTargetResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the other two, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create, and (since secret really does round-trip
unredacted) a real ImportStateVerify on the secret attribute rather than
one papered over with ImportStateVerifyIgnore. Not run against a live
stack in this environment, same disclosed gap as everything else
Docker-gated in this repo.
2026-08-15 10:20:52 -07:00

319 lines
11 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)
}
}
func TestCreateRuleSendsExpectedRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/rules" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body rule
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding request body: %v", err)
}
if body.Name != "High Error Rate" || body.ConditionType != "threshold" {
t.Errorf("unexpected request body: %+v", body)
}
if body.Comparator == nil || *body.Comparator != "gt" {
t.Errorf("Comparator = %v, want gt", body.Comparator)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
comparator := "gt"
threshold := 5.0
_ = json.NewEncoder(w).Encode(rule{
ID: "rule-1", TenantID: "acme", Name: body.Name,
ConditionType: "threshold", Comparator: &comparator, ThresholdValue: &threshold,
EvalIntervalSeconds: 60, NotificationTargetID: "target-1",
})
}))
defer srv.Close()
comparator := "gt"
threshold := 5.0
c := newClient(srv.URL, "")
out, err := c.createRule(context.Background(), &rule{
Name: "High Error Rate", Query: "status>=500 | stats count", ConditionType: "threshold",
Comparator: &comparator, ThresholdValue: &threshold,
EvalIntervalSeconds: 60, NotificationTargetID: "target-1",
})
if err != nil {
t.Fatalf("createRule: %v", err)
}
if out.ID != "rule-1" || out.EvalIntervalSeconds != 60 {
t.Fatalf("unexpected response: %+v", out)
}
}
func TestGetRuleParsesFlattenedRuleWithStateResponse(t *testing.T) {
// alerting/internal/httpapi's GET /rules/{id} returns
// rulestore.RuleWithState -- Rule's fields promoted to the top
// level via anonymous embedding, plus a "state" object this
// client's rule type deliberately has no field for (see client.go's
// doc comment). This test proves that extra "state" key doesn't
// break parsing the fields this provider does care about.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id": "rule-1", "tenant_id": "acme", "name": "High Error Rate",
"condition_type": "threshold", "comparator": "gt", "threshold_value": 5,
"eval_interval_seconds": 60, "notification_target_id": "target-1", "enabled": true,
"state": {"rule_id": "rule-1", "state": "ok", "last_eval_status": "ok", "consecutive_errors": 0}
}`))
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.getRule(context.Background(), "rule-1")
if err != nil {
t.Fatalf("getRule: %v", err)
}
if out.Name != "High Error Rate" || out.Comparator == nil || *out.Comparator != "gt" {
t.Fatalf("unexpected response: %+v", out)
}
}
func TestDeleteRuleSendsToCorrectPath(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 != "/rules/rule-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.deleteRule(context.Background(), "rule-1"); err != nil {
t.Fatalf("deleteRule: %v", err)
}
if !called {
t.Fatal("expected the server to receive a DELETE request")
}
}
func TestCreateNotificationTargetSendsExpectedRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/targets" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body notificationTarget
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding request body: %v", err)
}
if body.Name != "Ops Webhook" || body.Kind != "webhook" {
t.Errorf("unexpected request body: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(notificationTarget{
ID: "target-1", TenantID: "acme", Name: body.Name, Kind: body.Kind, WebhookURL: body.WebhookURL,
})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.createNotificationTarget(context.Background(), &notificationTarget{
Name: "Ops Webhook", Kind: "webhook", WebhookURL: "https://example.com/hook",
})
if err != nil {
t.Fatalf("createNotificationTarget: %v", err)
}
if out.ID != "target-1" {
t.Fatalf("unexpected response: %+v", out)
}
}
func TestGetNotificationTargetReturnsSecretUnredacted(t *testing.T) {
// Documents real, existing alerting behavior (notifystore's Get
// query selects the secret column with no redaction) -- this test
// exists so a future change to alerting's redaction posture would
// be caught here too, not just discovered by surprise.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(notificationTarget{ID: "target-1", Name: "Ops Webhook", Kind: "webhook", Secret: strPtr("shh")})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.getNotificationTarget(context.Background(), "target-1")
if err != nil {
t.Fatalf("getNotificationTarget: %v", err)
}
if out.Secret == nil || *out.Secret != "shh" {
t.Fatalf("Secret = %v, want it echoed back unredacted", out.Secret)
}
}
func TestDeleteNotificationTargetSendsToCorrectPath(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 != "/targets/target-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.deleteNotificationTarget(context.Background(), "target-1"); err != nil {
t.Fatalf("deleteNotificationTarget: %v", err)
}
if !called {
t.Fatal("expected the server to receive a DELETE request")
}
}
func strPtr(s string) *string { return &s }