Files
terdut-server/internal/api/teams_test.go
T
Niklas Ye bc9f793f1f
CI / chart (push) Successful in 1s
CI / security (push) Successful in 1m15s
CI / test (push) Successful in 5m48s
Add GET /api/teams?name= (TEAM-LOOKUP.md)
Resolves the gap TEAM-LOOKUP.md raised: an instance-scoped service
account had no way to recover a team's id after a 409 on POST
/api/teams, unlike the already-solved equivalent for service accounts
themselves (GET /api/service-accounts?name=).

Same route, extended the same way handleListServiceAccounts already
branches on ?name=: unset behaves exactly as before (the caller's own
teams via team_members); set looks up one team by exact name, open to
any authenticated caller -- not gated by isInstanceServiceAccount or
AdminOnly, since what it discloses (a name is taken, nothing about who's
in it) is the same low sensitivity that lookup already accepts for
service-account names.

Tests cover the exact motivating scenario (create, 409 on a retry,
recover the id via ?name=), the empty-array-not-an-error case, that no
role/source is reported for a non-member match, and that a caller who
isn't a member of the matched team still gets it.
2026-10-01 10:49:11 +02:00

531 lines
18 KiB
Go

package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
// The whole point of #4: two teams sharing one server must not see each other's
// work. These tests build two of them and check the boundary from both sides.
type teamFixture struct {
id int64
key string // integration key: how alerts get in
call func(method, path string, body any) *http.Response
}
// newTeam creates a team with its own member, integration key and API key. The
// admin does the creating, as an install's first user would.
func newTeam(t *testing.T, s *ts, name string) teamFixture {
t.Helper()
var team struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": name}), &team)
var integration struct {
Key string `json:"key"`
URL string `json:"url"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/integrations",
map[string]string{"name": name + " alertmanager"}), &integration)
if integration.Key == "" {
t.Fatalf("%s: integration key was not returned", name)
}
// A member of this team and no other.
var user struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": name + "-user", "email": name + "@test.com"}), &user)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/members",
map[string]any{"user_id": user.ID, "role": "owner"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("%s: add member: %d", name, resp.StatusCode)
}
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
return teamFixture{
id: team.ID,
key: integration.Key,
call: func(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.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
},
}
}
// postToIntegration sends one firing alert on a team's integration key, the way
// a real Alertmanager receiver would.
func postToIntegration(t *testing.T, s *ts, key, fingerprint, name string) {
t.Helper()
payload := map[string]any{
"version": "4",
"status": "firing",
"groupKey": "{}:{alertname=\"" + name + "\"}",
"groupLabels": map[string]string{"alertname": name},
"alerts": []map[string]any{
amAlert(fingerprint, name, "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/integrations/"+key+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post alert: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("post alert: %d", resp.StatusCode)
}
}
func list(t *testing.T, resp *http.Response) []map[string]any {
t.Helper()
var out []map[string]any
decode(t, resp, &out)
return out
}
// An alert posted on one team's key opens an incident in that team and nowhere
// else, and neither team can read the other's queue.
func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
postToIntegration(t, s, blue.key, "fp-blue", "BlueDiskFull")
redIncidents := list(t, red.call(http.MethodGet, "/api/incidents", nil))
if len(redIncidents) != 1 {
t.Fatalf("red should see exactly its own incident, saw %d", len(redIncidents))
}
if title := redIncidents[0]["title"]; title != "RedDiskFull" {
t.Errorf("red saw %v", title)
}
if teamID := int64(redIncidents[0]["team_id"].(float64)); teamID != red.id {
t.Errorf("red's incident belongs to team %d, want %d", teamID, red.id)
}
blueIncidents := list(t, blue.call(http.MethodGet, "/api/incidents", nil))
if len(blueIncidents) != 1 || blueIncidents[0]["title"] != "BlueDiskFull" {
t.Fatalf("blue should see exactly its own incident, saw %v", blueIncidents)
}
// Reading the other team's incident by id is not found rather than
// forbidden: its existence is the other team's business.
otherID := int64(blueIncidents[0]["id"].(float64))
for _, path := range []string{
"/api/incidents/" + id64(otherID),
"/api/incidents/" + id64(otherID) + "/alerts",
"/api/incidents/" + id64(otherID) + "/timeline",
} {
resp := red.call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("red reading %s: expected 404, got %d", path, resp.StatusCode)
}
}
// And cannot act on it either.
for _, path := range []string{"/acknowledge", "/resolve", "/archive"} {
resp := red.call(http.MethodPost, "/api/incidents/"+id64(otherID)+path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("red posting %s: expected 404, got %d", path, resp.StatusCode)
}
}
}
// Alerts, the raw signal record, are scoped the same way.
func TestTeams_AlertsAndStatsAreScoped(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
postToIntegration(t, s, blue.key, "fp-blue-1", "BlueDiskFull")
postToIntegration(t, s, blue.key, "fp-blue-2", "BlueMemory")
if alerts := list(t, red.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 1 {
t.Errorf("red should see 1 alert, saw %d", len(alerts))
}
if alerts := list(t, blue.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 2 {
t.Errorf("blue should see 2 alerts, saw %d", len(alerts))
}
// Statistics count your own work only — otherwise a team's volume, and the
// names of its alerts, leak through the totals.
var stats map[string]any
decode(t, red.call(http.MethodGet, "/api/stats/alerts", nil), &stats)
if total := stats["total"].(float64); total != 1 {
t.Errorf("red's alert stats counted %v alerts, want 1", total)
}
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
for _, row := range top {
if name := row["name"].(string); name != "RedDiskFull" {
t.Errorf("red's top alerts named %q, which is not theirs", name)
}
}
}
// The same fingerprint, the same groupKey and the same date are all legitimate
// in two teams at once: two clusters running the same rules, two rotas.
func TestTeams_SameFingerprintInTwoTeams(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
postToIntegration(t, s, red.key, "fp-shared", "DiskFull")
postToIntegration(t, s, blue.key, "fp-shared", "DiskFull")
for _, team := range []struct {
name string
f teamFixture
}{{"red", red}, {"blue", blue}} {
incidents := list(t, team.f.call(http.MethodGet, "/api/incidents", nil))
if len(incidents) != 1 {
t.Errorf("%s: expected its own incident for the shared fingerprint, saw %d",
team.name, len(incidents))
}
}
// And both rotas can name somebody for the same day.
for _, team := range []struct {
name string
f teamFixture
}{{"red", red}, {"blue", blue}} {
var members []map[string]any
decode(t, team.f.call(http.MethodGet, "/api/teams/"+id64(team.f.id)+"/members", nil), &members)
userID := int64(members[0]["user_id"].(float64))
resp := team.f.call(http.MethodPost, "/api/teams/"+id64(team.f.id)+"/schedule",
map[string]any{"user_id": userID, "dates": []string{"2026-10-01"}})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("%s: taking 2026-10-01 returned %d", team.name, resp.StatusCode)
}
}
}
// An unknown key delivers nothing, and says so rather than accepting silently.
func TestTeams_UnknownIntegrationKeyIsRejected(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
resp, err := http.Post(s.URL+"/api/integrations/not-a-real-key/alertmanager",
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401 for an unknown key, got %d", resp.StatusCode)
}
if incidents := list(t, team.call(http.MethodGet, "/api/incidents", nil)); len(incidents) != 0 {
t.Errorf("a rejected payload opened %d incident(s)", len(incidents))
}
}
// Team configuration is an owner's job; working incidents is a member's.
func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
// A plain member of the same team.
var user struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
call := func(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.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
}
base := "/api/teams/" + id64(team.id)
for _, c := range []struct {
name string
method string
path string
body any
}{
{"mint an integration key", http.MethodPost, base + "/integrations",
map[string]string{"name": "mine"}},
{"take a shift", http.MethodPost, base + "/schedule",
map[string]any{"user_id": user.ID, "dates": []string{"2026-11-01"}}},
{"add a member", http.MethodPost, base + "/members",
map[string]any{"user_id": 1}},
{"delete the team", http.MethodDelete, base, nil},
} {
resp := call(c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
}
}
// But they can read what the team is doing.
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
resp := call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("reading %s: expected 200, got %d", path, resp.StatusCode)
}
}
}
// A team's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := func(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.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
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
blue := newTeam(t, s, "blue")
base := "/api/teams/" + id64(red.id)
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
resp := blue.call(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("blue reading %s: expected 404, got %d", path, resp.StatusCode)
}
}
// /api/teams lists your own, never the install's.
teams := list(t, blue.call(http.MethodGet, "/api/teams", nil))
if len(teams) != 1 || teams[0]["name"] != "blue" {
t.Errorf("blue's team list: %v", teams)
}
}
// ---------------------------------------------------------------------------
// GET /api/teams?name= (TEAM-LOOKUP.md)
// ---------------------------------------------------------------------------
func TestListTeamsByName_FindsExactMatch(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamID := createTeamAs(t, s, instanceKey, "platform")
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=platform", nil))
if len(teams) != 1 {
t.Fatalf("expected exactly one match for ?name=platform, got %d: %v", len(teams), teams)
}
if int64(teams[0]["id"].(float64)) != teamID {
t.Errorf("id = %v, want %d", teams[0]["id"], teamID)
}
// No membership, so no role to report (models.Team's own doc comment:
// "empty when nobody in particular is asking").
if _, has := teams[0]["role"]; has {
t.Errorf("expected no role on a name-lookup match, got %v", teams[0]["role"])
}
}
func TestListTeamsByName_NoMatchIsAnEmptyArrayNotAnError(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=does-not-exist", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on no match, got %d", resp.StatusCode)
}
teams := list(t, resp)
if len(teams) != 0 {
t.Errorf("expected an empty array, got %v", teams)
}
}
// The actual motivating scenario (TEAM-LOOKUP.md): a service account that
// already created a team, interrupted before it could remember the id,
// recovers it via ?name= on the same name its own POST 409s on.
func TestListTeamsByName_RecoversAfterCreateConflict(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
original := createTeamAs(t, s, instanceKey, "recovered")
conflict := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "recovered"})
if conflict.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 recreating the same name, got %d", conflict.StatusCode)
}
conflict.Body.Close()
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=recovered", nil))
if len(teams) != 1 || int64(teams[0]["id"].(float64)) != original {
t.Fatalf("expected to recover the original team %d via ?name=, got %v", original, teams)
}
}
// Not gated by isInstanceServiceAccount or AdminOnly (TEAM-LOOKUP.md): any
// authenticated caller may ask whether a name is taken, the same low
// sensitivity GET /api/service-accounts?name= already accepts.
func TestListTeamsByName_OpenToAnyAuthenticatedCaller(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
_ = createTeamAs(t, s, s.key, "blue-target")
// red's own member, not a member of "blue-target", still gets a match.
teams := list(t, red.call(http.MethodGet, "/api/teams?name=blue-target", nil))
if len(teams) != 1 || teams[0]["name"] != "blue-target" {
t.Errorf("expected a non-member caller to still find the team by name, got %v", teams)
}
}