5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
457 lines
15 KiB
Go
457 lines
15 KiB
Go
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'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)
|
|
}
|
|
}
|