package api_test import ( "bytes" "encoding/json" "io" "net/http" "testing" ) // 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 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) } }