85ad2d65ee
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
terdut-server pages the on-call person through ntfy, but none of it was reachable from here. A user's topic could only be set with curl, so a new user silently got no pages and quietly fell back to the shared fallback topic — which carries no Acknowledge button. And nothing said whether anybody had been paged at all. The Users section grows an Ntfy Topic column and t to edit it, prefilled with the current value. Submitting an empty field clears the topic rather than being rejected as a mistake: clearing is how somebody is taken off their own topic, and it is what the server means by an empty string. Nil and empty arrive as the same thing, because the server stores a blank topic as NULL, so User.Topic flattens the two instead of leaving every caller to. The incident timeline renders the server's notified and notify_failed events. No new fetch — the timeline endpoint already carried them, and unknown types already fell through to a generic label; this is about saying something useful. An event with no user means the fallback topic, not "the server acted", which is the difference between somebody having been paged and the rota having been empty. Both need terdut-server v0.6.0 or later, and the timeline entries a server newer than that. Against an older one the column stays empty and editing a topic reports the server's 404, which is the honest answer.
344 lines
11 KiB
Go
344 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// call records what the client actually put on the wire. The paths and methods
|
|
// are the contract with terdut-server, and getting one wrong is exactly how this
|
|
// client broke when the server split alerts from incidents.
|
|
type call struct {
|
|
method string
|
|
path string
|
|
query string
|
|
body string
|
|
auth string
|
|
}
|
|
|
|
// stub serves one canned response and records the request that fetched it.
|
|
func stub(t *testing.T, status int, response string) (*Client, *call) {
|
|
t.Helper()
|
|
got := &call{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
|
|
got.body, got.auth = string(body), r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
io.WriteString(w, response)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return NewClient(srv.URL, "test-key"), got
|
|
}
|
|
|
|
func TestClient_SendsBearerToken(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListIncidents("", false, false, 0); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got.auth != "Bearer test-key" {
|
|
t.Errorf("expected bearer token, got %q", got.auth)
|
|
}
|
|
}
|
|
|
|
// Every incident action, with the method and path terdut-server exposes.
|
|
func TestClient_IncidentEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
invoke func(*Client) error
|
|
method string
|
|
path string
|
|
// resp defaults to a JSON object; endpoints returning a list need an array.
|
|
resp string
|
|
}{
|
|
{"get", func(c *Client) error { _, err := c.GetIncident(7); return err },
|
|
http.MethodGet, "/api/incidents/7", ""},
|
|
{"timeline", func(c *Client) error { _, err := c.GetIncidentTimeline(7); return err },
|
|
http.MethodGet, "/api/incidents/7/timeline", `[]`},
|
|
{"acknowledge", func(c *Client) error { _, err := c.AcknowledgeIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/acknowledge", ""},
|
|
{"unacknowledge", func(c *Client) error { return c.UnacknowledgeIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/acknowledge", ""},
|
|
{"resolve", func(c *Client) error { _, err := c.ResolveIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/resolve", ""},
|
|
{"assign", func(c *Client) error { _, err := c.AssignIncident(7, 3); return err },
|
|
http.MethodPost, "/api/incidents/7/assign", ""},
|
|
{"snooze", func(c *Client) error { _, err := c.SnoozeIncident(7, "2h"); return err },
|
|
http.MethodPost, "/api/incidents/7/snooze", ""},
|
|
{"unsnooze", func(c *Client) error { return c.UnsnoozeIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/snooze", ""},
|
|
{"archive", func(c *Client) error { _, err := c.ArchiveIncident(7); return err },
|
|
http.MethodPost, "/api/incidents/7/archive", ""},
|
|
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
|
|
http.MethodDelete, "/api/incidents/7/archive", ""},
|
|
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err },
|
|
http.MethodPost, "/api/incidents/7/notes", ""},
|
|
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
|
|
http.MethodDelete, "/api/incidents/7/notes/12", ""},
|
|
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
|
|
http.MethodGet, "/api/stats/incidents", ""},
|
|
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
|
|
http.MethodPut, "/api/users/7/notify", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resp := tt.resp
|
|
if resp == "" {
|
|
resp = `{}`
|
|
}
|
|
c, got := stub(t, http.StatusOK, resp)
|
|
if err := tt.invoke(c); err != nil {
|
|
t.Fatalf("%s: %v", tt.name, err)
|
|
}
|
|
if got.method != tt.method || got.path != tt.path {
|
|
t.Errorf("expected %s %s, got %s %s", tt.method, tt.path, got.method, got.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListIncidents_Filters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
archived bool
|
|
snoozed bool
|
|
limit int
|
|
want string
|
|
}{
|
|
{"default is the open queue", "", false, false, 0, ""},
|
|
{"status", "triggered", false, false, 0, "status=triggered"},
|
|
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"},
|
|
{"snoozed", "", false, true, 0, "snoozed=true"},
|
|
{"limit", "", false, false, 500, "limit=500"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got.query != tt.want {
|
|
t.Errorf("expected query %q, got %q", tt.want, got.query)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_RequestBodies(t *testing.T) {
|
|
t.Run("assign", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.AssignIncident(1, 42); err != nil {
|
|
t.Fatalf("assign: %v", err)
|
|
}
|
|
var body struct {
|
|
UserID int64 `json:"user_id"`
|
|
}
|
|
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
|
|
t.Fatalf("decode body %q: %v", got.body, err)
|
|
}
|
|
if body.UserID != 42 {
|
|
t.Errorf("expected user_id 42, got %d", body.UserID)
|
|
}
|
|
})
|
|
|
|
t.Run("snooze", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SnoozeIncident(1, "90m"); err != nil {
|
|
t.Fatalf("snooze: %v", err)
|
|
}
|
|
var body struct {
|
|
Duration string `json:"duration"`
|
|
}
|
|
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
|
|
t.Fatalf("decode body %q: %v", got.body, err)
|
|
}
|
|
if body.Duration != "90m" {
|
|
t.Errorf("expected duration 90m, got %q", body.Duration)
|
|
}
|
|
})
|
|
|
|
t.Run("set notify target", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
|
|
t.Fatalf("set notify target: %v", err)
|
|
}
|
|
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
|
|
t.Errorf("unexpected body %q", got.body)
|
|
}
|
|
})
|
|
|
|
// Clearing has to put an explicit empty string on the wire: omitting the
|
|
// field would leave the topic untouched instead of removing it.
|
|
t.Run("clear notify target", func(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `{}`)
|
|
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
|
|
t.Fatalf("clear notify target: %v", err)
|
|
}
|
|
if got.body != `{"ntfy_topic":""}` {
|
|
t.Errorf("expected an explicit empty topic, got %q", got.body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
|
|
var users []User
|
|
if err := json.Unmarshal([]byte(
|
|
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
|
|
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
want := []string{"", "", "terdut-c"}
|
|
for i, u := range users {
|
|
if got := u.Topic(); got != want[i] {
|
|
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The 409 on re-resolving is the server telling the user why nothing happened,
|
|
// so the message has to survive into the error the TUI displays.
|
|
func TestClient_SurfacesServerErrorMessage(t *testing.T) {
|
|
c, _ := stub(t, http.StatusConflict, `{"error":"incident is resolved"}`)
|
|
_, err := c.ResolveIncident(1)
|
|
if err == nil {
|
|
t.Fatal("expected an error on 409")
|
|
}
|
|
if !strings.Contains(err.Error(), "incident is resolved") || !strings.Contains(err.Error(), "409") {
|
|
t.Errorf("expected status and server message in %q", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestClient_ErrorWithoutBody(t *testing.T) {
|
|
c, _ := stub(t, http.StatusInternalServerError, ``)
|
|
if _, err := c.GetIncident(1); err == nil || !strings.Contains(err.Error(), "500") {
|
|
t.Errorf("expected a 500 error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Nobody on call is a normal state, not a failure.
|
|
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) {
|
|
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`)
|
|
entry, err := c.GetCurrentOnCall()
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if entry != nil {
|
|
t.Errorf("expected nil entry, got %+v", entry)
|
|
}
|
|
}
|
|
|
|
// Optional fields are omitted by the server rather than sent null, so decoding
|
|
// has to leave them zero instead of failing.
|
|
func TestIncident_DecodesSparseServerShape(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK, `{
|
|
"id": 1,
|
|
"group_key": "{}:{alertname=\"DiskFull\"}",
|
|
"title": "DiskFull (namespace=prod)",
|
|
"group_labels": {"alertname": "DiskFull", "namespace": "prod"},
|
|
"status": "triggered",
|
|
"severity": "critical",
|
|
"triggered_at": "2026-07-30T10:00:00Z"
|
|
}`)
|
|
|
|
inc, err := c.GetIncident(1)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if inc.Title != "DiskFull (namespace=prod)" || inc.Severity != "critical" {
|
|
t.Errorf("unexpected incident %+v", inc)
|
|
}
|
|
if inc.GroupLabels["namespace"] != "prod" {
|
|
t.Errorf("expected group labels decoded, got %v", inc.GroupLabels)
|
|
}
|
|
if !inc.IsOpen() {
|
|
t.Error("an incident with no resolved_at is open")
|
|
}
|
|
if inc.IsSnoozed() {
|
|
t.Error("an incident with no snoozed_until is not snoozed")
|
|
}
|
|
if inc.AcknowledgedByID != nil || inc.AssignedToID != nil {
|
|
t.Error("expected acknowledgement and assignment to be absent")
|
|
}
|
|
}
|
|
|
|
// A snooze expires by falling into the past; the server sweeps nothing, so the
|
|
// client is what decides a stale snooze no longer counts.
|
|
func TestIncident_IsSnoozed(t *testing.T) {
|
|
past := time.Now().Add(-time.Hour)
|
|
future := time.Now().Add(time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
until *time.Time
|
|
want bool
|
|
}{
|
|
{"never snoozed", nil, false},
|
|
{"snooze in the past has expired", &past, false},
|
|
{"snooze in the future holds", &future, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := (Incident{SnoozedUntil: tt.until}).IsSnoozed(); got != tt.want {
|
|
t.Errorf("expected %v, got %v", tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIncident_IsOpen(t *testing.T) {
|
|
now := time.Now()
|
|
if !(Incident{}).IsOpen() {
|
|
t.Error("no resolved_at means open")
|
|
}
|
|
if (Incident{ResolvedAt: &now}).IsOpen() {
|
|
t.Error("resolved_at means closed")
|
|
}
|
|
}
|
|
|
|
func TestAlert_DecodesIncidentLink(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK, `{"id":3,"name":"DiskFull","status":"firing","incident_id":7}`)
|
|
a, err := c.GetAlert(3)
|
|
if err != nil {
|
|
t.Fatalf("get alert: %v", err)
|
|
}
|
|
if a.IncidentID == nil || *a.IncidentID != 7 {
|
|
t.Errorf("expected incident_id 7, got %v", a.IncidentID)
|
|
}
|
|
}
|
|
|
|
func TestListAlerts_ArchivedFilter(t *testing.T) {
|
|
c, got := stub(t, http.StatusOK, `[]`)
|
|
if _, err := c.ListAlerts("", true, 50); err != nil {
|
|
t.Fatalf("list alerts: %v", err)
|
|
}
|
|
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
|
|
t.Errorf("unexpected request %s?%s", got.path, got.query)
|
|
}
|
|
}
|
|
|
|
// MTTA and MTTR are null until something has been acknowledged or resolved. That
|
|
// is "no data", and it must not decode to a confident zero.
|
|
func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
|
|
c, _ := stub(t, http.StatusOK,
|
|
`{"total":2,"triggered":2,"acknowledged":0,"resolved":0,"mtta_seconds":null,"mttr_seconds":null}`)
|
|
stats, err := c.GetIncidentStats()
|
|
if err != nil {
|
|
t.Fatalf("stats: %v", err)
|
|
}
|
|
if stats.Total != 2 || stats.Triggered != 2 {
|
|
t.Errorf("unexpected counts %+v", stats)
|
|
}
|
|
if stats.MTTASeconds != nil || stats.MTTRSeconds != nil {
|
|
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
|
|
}
|
|
}
|