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) } }