Files
cairnobs/terraform/internal/provider/client_test.go
T
jcoffey-dev b98f397221 Add sentry_alert_rule, the Terraform provider's second resource
Confirmed with the project owner first: alerting's REST API has no
PUT /rules/{id} at all -- confirmed down to rulestore.Store, which has
Create/List/Get/Delete but no Update method to even wire one to, a real
pre-existing gap in alerting's own API, not something new to this task.
Decided to model sentry_alert_rule as create/destroy only rather than
fake an in-place update via delete-then-recreate inside the resource:
every attribute carries a RequiresReplace plan modifier, so a config
change destroys and recreates the rule, surfacing in the plan output the
real side effect that has (alert_state/delivery-log continuity resets)
instead of hiding it. Adding a real PUT /rules/{id} to alerting would
remove this constraint but is a change to a different module's REST
API, out of scope here.

internal/provider/client.go's new rule type and createRule/getRule/
deleteRule methods talk the exact same JSON contract
sentryctl alerts apply already uses against alerting/internal/httpapi.
GET /rules/{id} actually returns rulestore.RuleWithState (Rule's fields
promoted via anonymous embedding, plus a "state" object) -- the local
rule type has no field for "state" by design, and a new client test
proves that extra key doesn't break parsing.

alerting is a genuinely separate service from api (its own base URL),
so this needed the provider to talk to more than one Sentry service for
the first time: providerData now wraps two *client instances (api,
alerting), with a new alerting_endpoint provider attribute defaulting
the same way sentryctl's --alerting-api/$SENTRYCTL_ALERTING_API_URL
does. dashboardResource's Configure updated to pull .api out of the new
wrapper type instead of a bare *client.

Schema mirrors sentry_dashboard's established pattern: comparator/
threshold_value/renotify_interval_minutes stay nullable (only meaningful
for threshold-condition rules), enabled/for_minutes/query_language are
Optional+Computed with a Terraform-side default matching the API's own
default (true/0/"") rather than leaving the API as sole source of truth
the way dashboard's default_earliest/default_latest deliberately do --
these three have no *pointer* type in the API's Rule struct, so their
"default when omitted" is unconditional, not a real API-side default
that could drift independently.

Verified: client tests are real httptest.Server round trips (same
pattern as sentry_dashboard's). Schema validation needs no Terraform
binary. TestAccAlertRuleResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the dashboard one, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create -- the concrete, checked version of the
"create/destroy only" design decision, not just a comment. Not run
against a live stack in this environment, same disclosed gap as
everything else Docker-gated in this repo.
2026-08-15 00:15:45 -07:00

244 lines
8.3 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")
}
}