Add an admin page, and move the behaviour settings into the database
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m1s

Closes #5. Three of the server's tunables were environment variables,
which meant changing how long an incident waits before being paged again
required editing a chart, merging it and waiting for a reconcile. They
are behaviour rather than infrastructure, and the difference is who needs
to change them and how often.

The split is by who owns the value. What stays in the environment is
where the server is plugged in: the listen address, the DSN, the ntfy URL
and token, the public URL. Those are needed before the database is open
and two of them are credentials -- the settings endpoint reports that
ntfy is configured and that a token is set, and never what either is.

What moves is how it behaves: the notify repeat interval, the stale
window and the archive window. The environment variable becomes the seed
rather than the setting, written once on first start and never
overwritten, so a redeploy cannot put a chart's default back over an
administrator's edit -- the rule the per-team dead man's switches already
follow. The loops read the current value per tick, so a change at 02:00
is obeyed at 02:00.

Key/value rather than a column per knob: #6 and #7 will both add
settings, and a table shaped one-column-per-setting needs a migration for
each. The cost is that values are text and the accessor has to say what
type it wanted, which settings.go does in one place. Unknown keys are
refused rather than stored -- a typo that wrote notify_repeat_second
would otherwise sit in the table looking like configuration and doing
nothing -- and each value has bounds loose enough to catch a slipped
decimal point without having an opinion about anybody's rota.

Disabling an account is new, and is not deleting one. Deleting a user
nulls acknowledged_by and assigned_to, which quietly rewrites who did
what during an incident months after the fact. A disabled user cannot
authenticate by either credential, loses their sessions immediately, and
stays the name on every acknowledgement they made. The check is part of
the lookup in serveAs rather than a test afterwards, so there is no path
where the row is loaded and the flag is then forgotten.

The page itself is a fourth tab, shown only to an administrator and only
as a courtesy: every endpoint under it is refused with 403 regardless, so
somebody who types /admin gets an explanation rather than a blank screen.
It lists teams with their size and open-incident count, users with their
flags, and the settings with their bounds -- plus the environment half,
read-only, so somebody hunting for the ntfy URL learns where it lives
instead of concluding the server has none.

Delete is disabled rather than offered-and-refused for a team with open
incidents, and neither admin action is offered on your own account, since
the server refuses both.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-20 18:23:46 +02:00
parent 303e7a3365
commit b0a02c010b
19 changed files with 1110 additions and 15 deletions
+275
View File
@@ -0,0 +1,275 @@
package api_test
import (
"net/http"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// The settings an administrator can change, and the ones they cannot.
func TestSettings_EditableAndReadOnly(t *testing.T) {
s := newTS(t)
var got struct {
Editable map[string]struct {
Seconds int64 `json:"seconds"`
Description string `json:"description"`
MinSeconds int64 `json:"min_seconds"`
MaxSeconds int64 `json:"max_seconds"`
} `json:"editable"`
FromEnv map[string]string `json:"from_env"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
// Seeded from the environment the server started with, not from zero.
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
}
if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
}
if got.Editable["archive_after_seconds"].Description == "" {
t.Error("a setting without a description is a number nobody can act on")
}
// The environment half is visible so somebody can see where it lives, but
// never the credentials themselves.
if _, ok := got.FromEnv["public_url"]; !ok {
t.Error("public_url should be reported as environment-configured")
}
for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
if v, ok := got.FromEnv[leak]; ok {
t.Errorf("%s must not be in the settings response (got %q)", leak, v)
}
}
}
// Changing a setting takes effect on the next tick, without a restart. This is
// the whole point of moving them out of the environment.
func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
s := newTS(t)
// An alert whose last webhook was two hours ago. Under the seeded
// stale_after of six hours the sweeper leaves it alone.
postWebhook(t, s, []map[string]any{
amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
sweep(t, s, noArchive)
if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
t.Fatalf("before the change the alert should still be firing, got %q", status)
}
// Shorten it to an hour. Nothing restarts.
resp := s.req(t, http.MethodPut, "/api/admin/settings",
map[string]int64{"stale_after_seconds": 3600})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change setting: %d", resp.StatusCode)
}
sweep(t, s, noArchive)
status, source, _ := s.alertRow(t, "fp-settings")
if status != "resolved" {
t.Errorf("after the change the alert should have expired, got %q", status)
}
if source == nil || *source != "expiry" {
t.Errorf("expected resolution_source expiry, got %v", source)
}
}
// A typo must not look like configuration, and a slipped decimal point must not
// produce a server that sweeps every second.
func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
s := newTS(t)
for _, c := range []struct {
name string
body map[string]int64
}{
{"unknown key", map[string]int64{"notify_repeat_second": 60}},
{"below the floor", map[string]int64{"stale_after_seconds": 30}},
{"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
{"nothing at all", map[string]int64{}},
} {
resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
}
}
}
// Settings are the server's behaviour, so only an administrator may change
// them — or see where the rest of the configuration comes from.
func TestSettings_AreAdminOnly(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "member")
for _, c := range []struct {
method string
body any
}{
{http.MethodGet, nil},
{http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
} {
resp := call(c.method, "/api/admin/settings", c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
}
}
resp := call(http.MethodGet, "/api/admin/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
}
}
// An administrator sees every team, including ones they are not in — which is
// exactly what /api/teams must not show them.
func TestSettings_AdminSeesEveryTeam(t *testing.T) {
s := newTS(t)
newTeam(t, s, "red")
newTeam(t, s, "blue")
all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
if len(all) != 3 { // Default, red, blue
t.Fatalf("admin should see all 3 teams, saw %d", len(all))
}
for _, team := range all {
if _, ok := team["members"]; !ok {
t.Error("the admin listing should say how big each team is")
}
}
// The admin created them, so they own them — but they are not a member of
// a team somebody else makes, and /api/teams still answers "what am I in".
mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
if len(mine) != 3 {
t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
}
}
// Disabling is not deleting: the account stops working and the history stays.
func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "leaver")
// They acknowledge an incident, so there is history to preserve.
postWebhook(t, s, []map[string]any{
amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("acknowledge: %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
map[string]bool{"disabled": true})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable: %d", resp.StatusCode)
}
// Their API key stops working.
resp = call(http.MethodGet, "/api/incidents", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
}
// The acknowledgement still names them.
var incident map[string]any
decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
if incident["acknowledged_by"] != "leaver" {
t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
}
if incident["status"] != "acknowledged" {
t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
}
// And re-enabling gives the account back.
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
map[string]bool{"disabled": false})
resp.Body.Close()
resp = call(http.MethodGet, "/api/incidents", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
}
}
// The same two guards as deleting and demoting: an install must keep somebody
// who can administer it.
func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
}
}
// Renaming a team is an owner's job, and the name stays unique.
func TestSettings_TeamRename(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("rename: %d", resp.StatusCode)
}
teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
found := false
for _, x := range teams {
if x["name"] == "Platform" {
found = true
}
}
if !found {
t.Error("the renamed team should be listed under its new name")
}
// Taking a name that exists is a conflict, not a silent second team with
// the same label.
resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
}
}
// The seed runs once. A redeploy must not put the chart's default back over an
// administrator's edit — the rule the dead man's switches already follow.
func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPut, "/api/admin/settings",
map[string]int64{"notify_repeat_seconds": 60})
resp.Body.Close()
// A second start, with the environment still saying 15 minutes.
if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
t.Fatalf("re-seed: %v", err)
}
var got struct {
Editable map[string]struct {
Seconds int64 `json:"seconds"`
} `json:"editable"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
t.Errorf("the edit should survive a restart, got %d", v)
}
}