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)) } }