774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.
Four real fixes land alongside the refactor, not just the restructuring:
1. callerMayManageServiceAccount gains the one load-bearing branch this
exists for: an instance-scoped service account may now manage (mint or
revoke a key on) any team-scoped account, not only a human admin, that
team's human owner, or the account itself. handleCreateServiceAccount
already let an instance-scoped caller *create* a team-scoped account for
any team; adopting or rotating one it didn't just create in the same
call -- terdut-operator's own documented crash-window recovery -- had no
equivalent permission and 403'd forever. Closes terdut-operator#3.
2. handleCreateInvite wrote a service-account caller's zero-value user id
straight into invites.created_by (nullable, but never passed as nil),
which foreign-key-violates against users(id) -- a 500, not success, for
any team-scoped service account minting an invite. Fixed the same way
handleCreateServiceAccount already handles the analogous case. Found
live while verifying this change, not filed separately since it's fixed
in the same place it was found.
3. handleMe and handleTestNotification 500'd for a service-account caller
(fetchUser/the ntfy_topic lookup against a zero-value user id that
matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
WHERE id = 0). All three now call Caller.AsHuman() and return an
explicit 403 ("this endpoint is for human accounts only").
4. Ratifies, rather than further narrows, two capabilities a team-scoped
service account already had by construction and this document's own
text once called "a gap acknowledged rather than closed": owner-equivalent
reach over membership/invites, and minting another service account for
its own team. terdut-operator's new TerdutTeam invite-minting feature is
about to depend on the first one, so this makes it documented, tested,
intentional behavior instead of an accident nobody was supposed to rely
on.
AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.
SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
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_topic": "", "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))
|
|
}
|
|
}
|