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() } // --------------------------------------------------------------------------- // Key rotation // --------------------------------------------------------------------------- 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)) } }