a4fbd60441
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
265 lines
8.5 KiB
Go
265 lines
8.5 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// The bootstrap user is an administrator; everybody it creates afterwards is
|
|
// not. These tests are about the line between them.
|
|
|
|
// id64 spells an id into a path segment.
|
|
func id64(n int64) string { return strconv.FormatInt(n, 10) }
|
|
|
|
// member creates an ordinary user and an API key for it, and returns a caller
|
|
// that authenticates as them. Minting the key goes through the admin's own
|
|
// credentials, which is how a real install hands one out.
|
|
func member(t *testing.T, s *ts, username string) (id int64, call func(method, path string, body any) *http.Response) {
|
|
t.Helper()
|
|
|
|
resp := s.req(t, http.MethodPost, "/api/users",
|
|
map[string]string{"username": username, "email": username + "@test.com"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create %s: %d", username, resp.StatusCode)
|
|
}
|
|
var user struct {
|
|
ID int64 `json:"id"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
decode(t, resp, &user)
|
|
if user.IsAdmin {
|
|
t.Fatalf("a created user must not be an administrator")
|
|
}
|
|
|
|
// Into the default team as a plain member: being in a team is what lets
|
|
// somebody work its incidents, and is separate from administering accounts.
|
|
resp = s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
|
map[string]any{"user_id": user.ID, "role": "member"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("add %s to the team: %d", username, resp.StatusCode)
|
|
}
|
|
|
|
resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
|
map[string]string{"name": "test"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("mint key for %s: %d", username, resp.StatusCode)
|
|
}
|
|
var key struct {
|
|
Key string `json:"key"`
|
|
}
|
|
decode(t, resp, &key)
|
|
|
|
return user.ID, 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
|
|
}
|
|
}
|
|
|
|
// The whole point of the release: a user who is not an administrator cannot
|
|
// manage other people's accounts. Every one of these was open to any
|
|
// authenticated caller before.
|
|
func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
|
|
s := newTS(t)
|
|
memberID, call := member(t, s, "member")
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body any
|
|
}{
|
|
{"create a user", http.MethodPost, "/api/users",
|
|
map[string]string{"username": "sneaky", "email": "sneaky@test.com"}},
|
|
{"delete the admin", http.MethodDelete, "/api/users/1", nil},
|
|
{"grant themselves admin", http.MethodPut, "/api/users/" + id64(memberID) + "/admin",
|
|
map[string]bool{"is_admin": true}},
|
|
{"set the admin's password", http.MethodPut, "/api/users/1/password",
|
|
map[string]string{"password": "hunter2-hunter2"}},
|
|
{"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys",
|
|
map[string]string{"name": "borrowed"}},
|
|
{"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify",
|
|
map[string]string{"ntfy_topic": "attacker-topic"}},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Being refused other people's accounts must not cost a user their own.
|
|
func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) {
|
|
s := newTS(t)
|
|
memberID, call := member(t, s, "member")
|
|
self := "/api/users/" + id64(memberID)
|
|
|
|
resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("own notify target: %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("own password: %d", resp.StatusCode)
|
|
}
|
|
|
|
// An API key carries exactly the rights of its owner, so minting your own
|
|
// is no more than signing in again.
|
|
resp = call(http.MethodPost, self+"/api-keys", map[string]string{"name": "laptop"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("own API key: %d", resp.StatusCode)
|
|
}
|
|
|
|
// And the queue still has to be able to name people.
|
|
resp = call(http.MethodGet, "/api/users", nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("list users: %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// Incident work is everybody's job; none of it is administration.
|
|
func TestAdmin_MemberCanWorkIncidents(t *testing.T) {
|
|
s := newTS(t)
|
|
_, call := member(t, s, "responder")
|
|
postWebhook(t, s, []map[string]any{
|
|
amAlert("fp-admin", "DiskFull", "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
|
})
|
|
|
|
for _, c := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
}{
|
|
{"list", http.MethodGet, "/api/incidents"},
|
|
{"acknowledge", http.MethodPost, "/api/incidents/1/acknowledge"},
|
|
{"resolve", http.MethodPost, "/api/incidents/1/resolve"},
|
|
} {
|
|
resp := call(c.method, c.path, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("%s: expected 200, got %d", c.name, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An install must never be left with nobody who can administer it.
|
|
func TestAdmin_LastAdministratorIsProtected(t *testing.T) {
|
|
s := newTS(t)
|
|
|
|
resp := s.req(t, http.MethodPut, "/api/users/1/admin", map[string]bool{"is_admin": false})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Errorf("self-demotion: expected 409, got %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = s.req(t, http.MethodDelete, "/api/users/1", nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Errorf("deleting yourself: expected 409, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// With a second administrator the first may stand down, but not while they
|
|
// are the only one — which is the same rule from the other side.
|
|
otherID, _ := member(t, s, "second")
|
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(otherID)+"/admin", map[string]bool{"is_admin": true})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("granting admin: %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = s.req(t, http.MethodDelete, "/api/users/"+id64(otherID), nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("deleting the second admin: expected 204, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A promoted user gets the powers with the flag, and loses them with it.
|
|
func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
|
s := newTS(t)
|
|
memberID, call := member(t, s, "promotee")
|
|
admin := "/api/users/" + id64(memberID) + "/admin"
|
|
|
|
resp := call(http.MethodPost, "/api/users", map[string]string{"username": "a", "email": "a@test.com"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("before the grant: %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": true})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("grant: %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "b", "email": "b@test.com"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("after the grant: expected 201, got %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": false})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
|
}
|
|
|
|
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "c", "email": "c@test.com"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("after the revoke: expected 403, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// The flag has to reach the client, or the web UI cannot decide what to show.
|
|
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
|
s := newTS(t)
|
|
|
|
var me struct {
|
|
User struct {
|
|
IsAdmin bool `json:"is_admin"`
|
|
} `json:"user"`
|
|
}
|
|
decode(t, s.req(t, http.MethodGet, "/api/me", nil), &me)
|
|
if !me.User.IsAdmin {
|
|
t.Error("the bootstrap user should be an administrator")
|
|
}
|
|
|
|
_, call := member(t, s, "plain")
|
|
var theirs struct {
|
|
User struct {
|
|
IsAdmin bool `json:"is_admin"`
|
|
} `json:"user"`
|
|
}
|
|
decode(t, call(http.MethodGet, "/api/me", nil), &theirs)
|
|
if theirs.User.IsAdmin {
|
|
t.Error("a created user should not be an administrator")
|
|
}
|
|
}
|