a6fa673e08
The Admin tab's team list was growing controls the way the user list did beforeac9af8e: a Rename button behind window.prompt, a Delete beside it, and -- on the Users page, of all places -- an invite form with a team picker in front of it. The picker was the admission that an invite is a fact about a team rather than about the server, and a prompt() is the wrong place to read a 409 about a name already taken. So a team is now a subject with a page, at /admin/teams/{id}, the mirror of /admin/users/{id}: when it was created, how many are in it and how much is open, a field to rename it, the members with their roles, the invites into it, and deletion. The list goes back to being a list, and the name in it is the way in. The member list is the one thing there that needed a new endpoint. GET /api/teams/{id}/members is requireTeamMember and answers 404 to an administrator who is not in the team, and that stays exactly as it is: member means membership and nothing else. Reading a team's shape is a different question from reading its work, so it gets an endpoint of its own under AdminOnly -- GET /api/admin/teams/{id}, returning {"team", "members"} -- rather than an exception carved into that rule. It is a wrapper and not a team with the members hung off it, because "members" already means a count on the list endpoint and one name must not be a number in one answer and an array in the next. The query and its ordering are copied from handleListTeamMembers so the two answers to "who is in this team" cannot disagree. An administrator still sees none of that team's incidents, alerts or rota. Nothing about what the flag may do changed; it could already rename and delete any team, and staff one it is not in. Rename now trims what it is given, as creation has always trimmed. Before this, " " was a legal name to rename a team to but not to create one with, which is one rule stated twice and applied once. Nobody has looked at this in a browser, the caveatac9af8eand07914d5both carried. What is checked is the wiring: admin_test.go covers the new endpoint for an administrator outside the team, the 404 the member-only endpoint still gives that same administrator, the 403 for a member who is not one, a 404 for a team that does not exist, a 400 for an id that is not a number, and the trim; the module graph evaluates at /admin/teams/{id}, and the server serves index.html there, so a reload survives. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
505 lines
17 KiB
Go
505 lines
17 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|