b114251f45
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
508 lines
17 KiB
Go
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")
|
|
}
|
|
}
|