Files
terdut-server/internal/api/admin_test.go
T
Niklas Ye b114251f45 Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:00:40 +02:00

508 lines
17 KiB
Go

package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// 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"}},
{"add a channel to the admin's notifications", http.MethodPost, "/api/users/1/channels",
map[string]string{"type": "ntfy", "address": "attacker-topic"}},
{"read the admin's channels", http.MethodGet, "/api/users/1/channels", nil},
}
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, api.NotifyConfig{Providers: newFakeNtfy(t).providers()})
memberID, call := member(t, s, "member")
self := "/api/users/" + id64(memberID)
resp := call(http.MethodPost, self+"/channels", map[string]string{"type": "ntfy", "address": "terdut-member"})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("own notification channel: %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")
}
}