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) } } // An administrator passes every team-owner check without being in the team, // which is what lets them repair a team whose owner has left. It has been true // since teams landed and nothing pinned it, so a later reading of the epic's // "an admin is not implicitly in every team" could quietly take it away. // // The line it draws: configuring a team, yes; reading what the team owns, no. // The queue below is the half that stays shut. func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) { s := newTS(t) // A team the admin is deliberately not a member of. It is created by // somebody else, so the admin's only claim on it is the flag. _, call := member(t, s, "founder") var team struct { ID int64 `json:"id"` } decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team) if team.ID == 0 { t.Fatal("no team was created") } var mine []struct { ID int64 `json:"id"` } decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine) for _, m := range mine { if m.ID == team.ID { t.Fatalf("the admin should not be a member of team %d", team.ID) } } path := "/api/teams/" + id64(team.ID) for _, c := range []struct { name string method string path string body any want int }{ {"rename it", http.MethodPut, path, map[string]string{"name": "theirs, renamed"}, http.StatusNoContent}, {"mint an invite", http.MethodPost, path + "/invites", map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated}, {"add a member", http.MethodPost, path + "/members", map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent}, {"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent}, } { resp := s.req(t, c.method, c.path, c.body) resp.Body.Close() if resp.StatusCode != c.want { t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode) } } // The other half of the rule. An incident in that team is not the admin's // to read, because administration is about accounts — and the last case // above has just taken the admin back out of the membership. var integration struct { Key string `json:"key"` } decode(t, call(http.MethodPost, path+"/integrations", map[string]string{"name": "theirs alertmanager"}), &integration) postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull") var incidents []struct { ID int64 `json:"id"` } decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents) if len(incidents) != 0 { t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents)) } } // The team page at /admin/teams/{id} needs the one question the test above // leaves shut: who is in a team the administrator is not in. // // It is answered by a separate endpoint under AdminOnly rather than by letting // the admin flag through requireTeamMember, and the second half of this test is // the reason — /api/teams/{id}/members must keep answering 404, so that "member // means membership and nothing else" stays true of the endpoint it was said // about. Reading a team's shape and reading a team's work are different things. func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) { s := newTS(t) founderID, call := member(t, s, "founder") var team struct { ID int64 `json:"id"` } decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team) if team.ID == 0 { t.Fatal("no team was created") } // The admin reads it whole, without being in it. var got struct { Team struct { ID int64 `json:"id"` Name string `json:"name"` Members int64 `json:"members"` OpenIncidents int64 `json:"open_incidents"` } `json:"team"` Members []struct { UserID int64 `json:"user_id"` Username string `json:"username"` Role string `json:"role"` } `json:"members"` } decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got) if got.Team.ID != team.ID || got.Team.Name != "theirs" { t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name) } if got.Team.Members != 1 { t.Errorf("expected a member count of 1, got %d", got.Team.Members) } if len(got.Members) != 1 { t.Fatalf("expected one member, got %d", len(got.Members)) } if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" { t.Errorf("expected founder (%d), got %q (%d)", founderID, got.Members[0].Username, got.Members[0].UserID) } // Whoever creates a team owns it, and the page's role toggle depends on // that being reported rather than assumed. if got.Members[0].Role != "owner" { t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role) } // The rule this endpoint exists in order not to break. Same admin, same // team, the member-only endpoint: still not found. resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d", resp.StatusCode) } // And the new one is administration, not membership: being in the team is // not enough. resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode) } for _, c := range []struct { name string path string want int }{ {"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound}, {"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest}, } { resp := s.req(t, http.MethodGet, c.path, nil) resp.Body.Close() if resp.StatusCode != c.want { t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode) } } } // A team name is trimmed when it is created, and renaming had not been, so " " // was a legal name to rename to and an illegal one to start with. func TestRenameTeam_TrimsTheName(t *testing.T) { s := newTS(t) var team struct { ID int64 `json:"id"` } decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team) path := "/api/teams/" + id64(team.ID) resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("a blank name must be refused, got %d", resp.StatusCode) } resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "}) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) } var got struct { Team struct { Name string `json:"name"` } `json:"team"` } decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got) if got.Team.Name != "padded" { t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name) } } // The admin page's per-user view asks what somebody is in. Self or admin, like // the rest of the per-user endpoints. func TestUserTeams_SelfOrAdmin(t *testing.T) { s := newTS(t) memberID, call := member(t, s, "joiner") path := "/api/users/" + id64(memberID) + "/teams" // member() puts them in the default team, so both readings agree on one. for _, c := range []struct { name string do func() *http.Response }{ {"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }}, {"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }}, } { var teams []struct { ID int64 `json:"id"` Name string `json:"name"` Role string `json:"role"` } decode(t, c.do(), &teams) if len(teams) != 1 { t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams)) } if teams[0].Role != "member" { t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role) } } // Somebody else's is not theirs to read. otherID, _ := member(t, s, "nosy") resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode) } // A user who does not exist is a 404 rather than an empty list, which is // how the page tells "no teams" from "no such person". resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("a missing user: expected 404, 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") } }