b114251f45
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
493 lines
20 KiB
Go
493 lines
20 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
)
|
|
|
|
// reqAs is s.req with an arbitrary bearer credential in place of the admin's
|
|
// own key, for exercising a service account's or another user's key.
|
|
func (s *ts) reqAs(t *testing.T, key, method, path string, body any) *http.Response {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
data, _ := json.Marshal(body)
|
|
r = bytes.NewReader(data)
|
|
}
|
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// createServiceAccount creates a service account as callerKey and returns its
|
|
// freshly minted raw key.
|
|
func createServiceAccount(t *testing.T, s *ts, callerKey, name, scope string, teamID int64) string {
|
|
t.Helper()
|
|
body := map[string]any{"name": name, "scope": scope}
|
|
if teamID != 0 {
|
|
body["team_id"] = teamID
|
|
}
|
|
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/service-accounts", body)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
resp.Body.Close()
|
|
t.Fatalf("create service account %s: %d", name, resp.StatusCode)
|
|
}
|
|
var result struct {
|
|
Key struct {
|
|
Key string `json:"key"`
|
|
} `json:"key"`
|
|
}
|
|
decode(t, resp, &result)
|
|
if result.Key.Key == "" {
|
|
t.Fatalf("create service account %s: no key returned", name)
|
|
}
|
|
return result.Key.Key
|
|
}
|
|
|
|
// createTeamAs creates a team as callerKey and returns its id.
|
|
func createTeamAs(t *testing.T, s *ts, callerKey, name string) int64 {
|
|
t.Helper()
|
|
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/teams", map[string]string{"name": name})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
resp.Body.Close()
|
|
t.Fatalf("create team %s: %d", name, resp.StatusCode)
|
|
}
|
|
var team struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
decode(t, resp, &team)
|
|
return team.ID
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Instance scope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceAccount_InstanceScopeCreatesTeamWithNoHumanOwner(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
|
|
if !strings.HasPrefix(instanceKey, "tdsa_") {
|
|
t.Errorf("expected a service-account key to carry the tdsa_ prefix, got %q", instanceKey)
|
|
}
|
|
|
|
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "provisioned"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("instance-scoped account creating a team: %d", resp.StatusCode)
|
|
}
|
|
var team struct {
|
|
ID int64 `json:"id"`
|
|
Role string `json:"role"`
|
|
}
|
|
decode(t, resp, &team)
|
|
if team.Role != "" {
|
|
t.Errorf("expected no role on a team a service account created (no human owner), got %q", team.Role)
|
|
}
|
|
|
|
// It still exists, visible to an administrator, even with no member.
|
|
var admin []map[string]any
|
|
decode(t, s.req(t, http.MethodGet, "/api/admin/teams", nil), &admin)
|
|
found := false
|
|
for _, tm := range admin {
|
|
if int64(tm["id"].(float64)) == team.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected the service-account-created team to appear in /api/admin/teams")
|
|
}
|
|
}
|
|
|
|
func TestServiceAccount_TeamScopeCannotCreateTeam(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams", map[string]string{"name": "should-fail"})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403, a team-scoped account creating a team, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Team scope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The whole point of team scope: bound to its own team, refused everywhere
|
|
// else, the same as an instance-scoped account minting a key per TerdutTeam
|
|
// rather than sharing one server-admin-equivalent credential would need.
|
|
func TestServiceAccount_TeamScopeIsBoundToItsOwnTeam(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
teamB := createTeamAs(t, s, instanceKey, "team-b")
|
|
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
policy := map[string]any{"repeat_count": 0, "fallback": map[string]string{"type": "", "address": ""}, "levels": []any{}}
|
|
|
|
resp := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamA)+"/escalation", policy)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("team-a's own key setting its escalation: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// 404, not 403: the same "does this exist" refusal a human non-member
|
|
// gets from requireTeamMember, not a distinguishable "you may not".
|
|
resp2 := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamB)+"/escalation", policy)
|
|
if resp2.StatusCode != http.StatusNotFound {
|
|
t.Errorf("expected 404 reaching into another team, got %d", resp2.StatusCode)
|
|
}
|
|
resp2.Body.Close()
|
|
}
|
|
|
|
// Team scope is owner-equivalent broadly (SERVICE-ACCOUNTS.md), not limited to
|
|
// one endpoint: escalation, dead man's switches and integrations all work.
|
|
func TestServiceAccount_TeamScopeManagesItsResources(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/deadman/switches",
|
|
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("team-scoped account creating a dead man's switch: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp2 := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
|
map[string]string{"name": "prod"})
|
|
if resp2.StatusCode != http.StatusCreated {
|
|
t.Errorf("team-scoped account creating an integration: %d", resp2.StatusCode)
|
|
}
|
|
resp2.Body.Close()
|
|
}
|
|
|
|
// Documents the capability already granted at create time (handleCreateServiceAccount's
|
|
// own callerOwnsTeam branch) also applies here: a team-scoped account is that
|
|
// team's owner's reach, membership and further accounts included, not just
|
|
// the handful of endpoints exercised above. Kept, not restricted, for
|
|
// symmetry with the now-ratified membership/invite capability below.
|
|
func TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
resp := s.reqAs(t, keyA, http.MethodPost, "/api/service-accounts",
|
|
map[string]any{"name": "team-a-sa-2", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("team-scoped account minting another account for its own team: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// SERVICE-ACCOUNTS.md ratifies this explicitly: a team-scoped account is
|
|
// owner-equivalent for every requireTeamOwner endpoint, membership and
|
|
// invites included — terdut-operator's own invite-minting feature depends on
|
|
// exactly this. No test exercised handleCreateInvite from a service account
|
|
// before this change, and it would have 500'd (created_by written as a bare
|
|
// zero value against a NOT-validated-but-FK'd column) rather than succeeded;
|
|
// see the signup_test.go addition for that half.
|
|
func TestServiceAccount_TeamScopeManagesItsOwnInvites(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
var invite struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("team-scoped account creating an invite: %d", resp.StatusCode)
|
|
}
|
|
decode(t, resp, &invite)
|
|
|
|
var list []map[string]any
|
|
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/teams/"+id64(teamA)+"/invites", nil), &list)
|
|
if len(list) != 1 {
|
|
t.Errorf("expected the invite to list back, got %d", len(list))
|
|
}
|
|
|
|
if resp := s.reqAs(t, keyA, http.MethodDelete,
|
|
"/api/teams/"+id64(teamA)+"/invites/"+id64(invite.ID), nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("team-scoped account revoking its own invite: %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Key rotation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// terdut-operator#3: an instance-scoped account is already trusted to CREATE
|
|
// a team-scoped account for any team (handleCreateServiceAccount's own
|
|
// isInstanceServiceAccount branch) — this pins that it is equally trusted to
|
|
// manage/rotate a key on one that already exists and that it did not just
|
|
// create in this call, which is the exact shape of terdut-operator's own
|
|
// crash-window recovery (mint succeeds, a later step is interrupted before
|
|
// persisting the credential locally, and the next reconcile retries into a
|
|
// 409 then needs to mint a fresh key on the now-existing account). Before
|
|
// this fix, the second POST .../keys below 403'd forever.
|
|
func TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
|
|
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts",
|
|
map[string]any{"name": "team-a-sa", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create team-scoped account: %d", resp.StatusCode)
|
|
}
|
|
var created struct {
|
|
ServiceAccount struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"service_account"`
|
|
}
|
|
decode(t, resp, &created)
|
|
|
|
// Simulates the adopt-on-409 recovery path: this instance-scoped caller
|
|
// did not just create this account in this call (a fresh *tdclient.Client
|
|
// request, same as a second, independent reconcile would issue), yet
|
|
// still needs to mint it a fresh key.
|
|
rotateResp := s.reqAs(t, instanceKey, http.MethodPost,
|
|
"/api/service-accounts/"+id64(created.ServiceAccount.ID)+"/keys", map[string]string{"name": "adopted"})
|
|
if rotateResp.StatusCode != http.StatusCreated {
|
|
t.Errorf("instance-scoped account adopting a team-scoped account's key: %d", rotateResp.StatusCode)
|
|
}
|
|
rotateResp.Body.Close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AdminOnly / requireSelfOrAdmin — unchanged after the Caller refactor
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The Caller abstraction must not have widened AdminOnly/requireSelfOrAdmin:
|
|
// user management and /api/admin/settings stay human-only, for every scope
|
|
// of service account, exactly as before.
|
|
func TestAdminOnly_RefusesEveryServiceAccountScope(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
|
teamKey := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
|
|
|
for _, key := range []string{instanceKey, teamKey} {
|
|
if resp := s.reqAs(t, key, http.MethodGet, "/api/admin/settings", nil); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for a service account reading /api/admin/settings, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
if resp := s.reqAs(t, key, http.MethodPost, "/api/users",
|
|
map[string]string{"username": "nope", "email": "nope@example.com"}); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for a service account creating a user, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
if resp := s.reqAs(t, key, http.MethodGet, "/api/me", nil); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for a service account calling /api/me, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceAccount_SelfRotatesItsOwnKey(t *testing.T) {
|
|
s := newTS(t)
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
|
|
// Self-lookup by name, the pattern that turns /api/bootstrap's 403 into a
|
|
// normal flow instead of an unhandled error.
|
|
var accounts []map[string]any
|
|
decode(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil), &accounts)
|
|
if len(accounts) != 1 {
|
|
t.Fatalf("expected exactly one match for ?name=terdut-operator, got %d", len(accounts))
|
|
}
|
|
id := int64(accounts[0]["id"].(float64))
|
|
|
|
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts/"+id64(id)+"/keys",
|
|
map[string]string{"name": "rotated"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("self-rotation: %d", resp.StatusCode)
|
|
}
|
|
var newKey struct {
|
|
Key string `json:"key"`
|
|
}
|
|
decode(t, resp, &newKey)
|
|
|
|
if resp := s.reqAs(t, newKey.Key, http.MethodPost, "/api/teams", map[string]string{"name": "after-rotation"}); resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("expected the newly rotated key to work, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// Rotation adds a key, it does not itself revoke the old one.
|
|
if resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected the original key to still work until explicitly revoked, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Operator mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Operator mode is exercised against a second router over an
|
|
// already-configured database, rather than turning it on for newTSWith's own
|
|
// setup: that setup creates the default integration with the admin's (human)
|
|
// key, which is precisely the write operator mode exists to refuse, and in
|
|
// the real deployment this flag targets that setup was never done by a human
|
|
// to begin with — the operator itself would have provisioned it.
|
|
func TestOperatorMode_BlocksHumanWritesButAllowsServiceAccounts(t *testing.T) {
|
|
s := newTS(t)
|
|
|
|
conf := testConfig()
|
|
conf.OperatorMode = true
|
|
opSrv := httptest.NewServer(api.NewRouter(s.db, s.notify, conf, "test"))
|
|
t.Cleanup(opSrv.Close)
|
|
do := func(key, method, path string, body any) *http.Response {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
data, _ := json.Marshal(body)
|
|
r = bytes.NewReader(data)
|
|
}
|
|
req, _ := http.NewRequest(method, opSrv.URL+path, r)
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// The bootstrap admin's own key is a human credential: refused.
|
|
resp := do(s.key, http.MethodPost, "/api/teams", map[string]string{"name": "human-team"})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for a human write under operator mode, got %d", resp.StatusCode)
|
|
}
|
|
var refusal map[string]string
|
|
decode(t, resp, &refusal)
|
|
if refusal["reason"] != "operator_managed" {
|
|
t.Errorf("expected reason=operator_managed, got %q", refusal["reason"])
|
|
}
|
|
|
|
// Creating the service account itself is not gated by operator mode —
|
|
// it is how an operator identifies itself, not one of the resources it
|
|
// manages.
|
|
resp2 := do(s.key, http.MethodPost, "/api/service-accounts",
|
|
map[string]any{"name": "terdut-operator", "scope": models.ServiceAccountScopeInstance})
|
|
if resp2.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create service account under operator mode: %d", resp2.StatusCode)
|
|
}
|
|
var result struct {
|
|
Key struct {
|
|
Key string `json:"key"`
|
|
} `json:"key"`
|
|
}
|
|
decode(t, resp2, &result)
|
|
|
|
resp3 := do(result.Key.Key, http.MethodPost, "/api/teams", map[string]string{"name": "operator-team"})
|
|
if resp3.StatusCode != http.StatusCreated {
|
|
t.Fatalf("expected 201 for a service-account write under operator mode, got %d", resp3.StatusCode)
|
|
}
|
|
resp3.Body.Close()
|
|
|
|
// Reads are unaffected regardless of caller.
|
|
if resp := do(s.key, http.MethodGet, "/api/teams", nil); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected reads to stay open under operator mode, got %d", resp.StatusCode)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestOperatorMode_OffLeavesHumanWritesAlone(t *testing.T) {
|
|
s := newTS(t) // testConfig(): OperatorMode false
|
|
resp := s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "still-fine"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("expected a human write to succeed with operator mode off, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Version
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestVersion(t *testing.T) {
|
|
s := newTS(t)
|
|
resp, err := http.Get(s.URL + "/api/version")
|
|
if err != nil {
|
|
t.Fatalf("get version: %v", err)
|
|
}
|
|
var v struct {
|
|
Version string `json:"version"`
|
|
}
|
|
decode(t, resp, &v)
|
|
if v.Version != "test" {
|
|
t.Errorf("expected version %q, got %q", "test", v.Version)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dead man's switch update-in-place
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
|
|
s := newTS(t)
|
|
|
|
var created struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches",
|
|
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"}), &created)
|
|
|
|
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(created.ID),
|
|
map[string]any{"name": "renamed", "matcher": "alertname=Watchdog", "timeout_seconds": 1200, "severity": "warning"})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("update switch: %d", resp.StatusCode)
|
|
}
|
|
var updated struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
decode(t, resp, &updated)
|
|
if updated.ID != created.ID {
|
|
t.Errorf("expected id to stay %d, got %d", created.ID, updated.ID)
|
|
}
|
|
if updated.Name != "renamed" || updated.TimeoutSeconds != 1200 || updated.Severity != "warning" {
|
|
t.Errorf("expected the update to apply, got %+v", updated)
|
|
}
|
|
|
|
var list []map[string]any
|
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil), &list)
|
|
if len(list) != 1 {
|
|
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
|
|
}
|
|
}
|