package api import ( "encoding/json" "errors" "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 cookie string // authz is the Authorization header, which the client no longer sends at all. authz 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.authz = string(body), r.Header.Get("Authorization") if ck, err := r.Cookie(SessionCookie); err == nil { got.cookie = ck.Value } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) io.WriteString(w, response) })) t.Cleanup(srv.Close) c := NewClient(srv.URL) c.SetSession("test-session") return c, got } func TestClient_SendsTheSessionCookie(t *testing.T) { c, got := stub(t, http.StatusOK, `[]`) if _, err := c.ListIncidents(0, "", false, false, 0); err != nil { t.Fatalf("list: %v", err) } if got.cookie != "test-session" { t.Errorf("expected the session cookie, got %q", got.cookie) } // The server judges a request with an Authorization header on that alone and // never falls back to the cookie, so sending one would defeat the session. if got.authz != "" { t.Errorf("expected no Authorization header, got %q", got.authz) } } // Login has to work over plain http, where a cookie jar would discard the // Secure cookie a server behind https sets. func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) { var body string var sentCookie bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) body = string(b) _, err := r.Cookie(SessionCookie) sentCookie = err == nil http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true}) w.WriteHeader(http.StatusOK) io.WriteString(w, `{"user":{"id":1},"has_password":true}`) })) t.Cleanup(srv.Close) c := NewClient(srv.URL) c.SetSession("stale") token, err := c.Login("niklas", "correct horse") if err != nil { t.Fatalf("login: %v", err) } if token != "fresh" || !c.HasSession() { t.Errorf("expected the new token to be kept, got %q", token) } if body != `{"username":"niklas","password":"correct horse"}` { t.Errorf("unexpected body %q", body) } if sentCookie { t.Error("a stale session must not ride along on the login that replaces it") } } func TestLogin_RefusalCarriesTheServersWords(t *testing.T) { c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`) _, err := c.Login("niklas", "wrong") if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") { t.Errorf("expected the server's 401 message, got %v", err) } c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`) if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) { t.Errorf("a rate limit is not an authentication failure, got %v", err) } } func TestLogin_NoCookieIsAnError(t *testing.T) { c, _ := stub(t, http.StatusOK, `{}`) if _, err := c.Login("niklas", "pw"); err == nil { t.Error("a 200 without a session cookie is not a sign-in") } } func TestLogout_ForgetsTheSession(t *testing.T) { c, got := stub(t, http.StatusNoContent, ``) if err := c.Logout(); err != nil { t.Fatalf("logout: %v", err) } if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" { t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie) } if c.HasSession() { t.Error("the session should be gone locally") } } func TestIsUnauthorized(t *testing.T) { if !IsUnauthorized(&StatusError{Code: 401}) { t.Error("a 401 is unauthorized") } if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) { t.Error("only a 401 means the session is refused; a 403 is a permission") } } // 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 teamID int64 status string archived bool snoozed bool limit int want string }{ {"default is the open queue", 0, "", false, false, 0, ""}, {"status", 0, "triggered", false, false, 0, "status=triggered"}, {"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"}, {"snoozed", 0, "", false, true, 0, "snoozed=true"}, {"limit", 0, "", false, false, 500, "limit=500"}, {"one team", 4, "", false, false, 0, "team_id=4"}, {"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c, got := stub(t, http.StatusOK, `[]`) if _, err := c.ListIncidents(tt.teamID, 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) } }) // replace is what takes a day off its current holder, so it has to reach the // wire when asked for — and stay off it when not. t.Run("assign schedule", func(t *testing.T) { c, got := stub(t, http.StatusCreated, `[]`) if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, false); err != nil { t.Fatalf("assign: %v", err) } if got.method != "POST" || got.path != "/api/teams/9/schedule" { t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path) } if got.body != `{"user_id":3,"dates":["2026-07-27"]}` { t.Errorf("unexpected body %q", got.body) } }) t.Run("assign schedule with replace", func(t *testing.T) { c, got := stub(t, http.StatusCreated, `[]`) if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, true); err != nil { t.Fatalf("assign: %v", err) } if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` { t.Errorf("unexpected body %q", got.body) } }) 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: the server answers with an // empty list, one entry per team that has somebody scheduled. func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) { c, got := stub(t, http.StatusOK, `[ {"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"}, {"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`) entries, err := c.GetCurrentOnCall() if err != nil { t.Fatalf("on call: %v", err) } if got.path != "/api/schedule/current" { t.Errorf("unexpected path %q", got.path) } if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" { t.Errorf("unexpected entries %+v", entries) } c, _ = stub(t, http.StatusOK, `[]`) if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 { t.Errorf("expected no entries and no error, got %v, %v", entries, err) } } // Schedules belong to a team, so every call for one has to say which. func TestSchedule_IsPerTeam(t *testing.T) { c, got := stub(t, http.StatusOK, `[]`) if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil { t.Fatalf("get schedule: %v", err) } if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" { t.Errorf("unexpected request %s?%s", got.path, got.query) } c, got = stub(t, http.StatusNoContent, ``) if err := c.DeleteScheduleEntry(7, 12); err != nil { t.Fatalf("delete: %v", err) } if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" { t.Errorf("unexpected request %s %s", got.method, got.path) } } func TestTeams(t *testing.T) { c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`) teams, err := c.ListTeams() if err != nil { t.Fatalf("list teams: %v", err) } if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" { t.Errorf("unexpected %s %+v", got.path, teams) } c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`) members, err := c.ListTeamMembers(3) if err != nil { t.Fatalf("list members: %v", err) } if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 { t.Errorf("unexpected %s %+v", got.path, members) } } // A server that predates teams has no /api/teams, and the TUI recognises one by // that 404, so it must come back as a StatusError carrying the code. func TestListTeams_OldServerIs404(t *testing.T) { c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`) _, err := c.ListTeams() var se *StatusError if !errors.As(err, &se) || se.Code != http.StatusNotFound { t.Errorf("expected a 404 StatusError, got %v", err) } } func TestUser_DecodesAdminAndDisabled(t *testing.T) { var u User if err := json.Unmarshal([]byte( `{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil { t.Fatalf("decode: %v", err) } if !u.IsAdmin || !u.IsDisabled() { t.Errorf("expected an admin who is disabled, got %+v", u) } var other User if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() { t.Errorf("a user with no disabled_at must not be disabled") } } // 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(0, "", 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) } } func TestClient_Me(t *testing.T) { c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`) me, err := c.Me() if err != nil { t.Fatalf("me: %v", err) } if got.method != http.MethodGet || got.path != "/api/me" { t.Errorf("expected GET /api/me, got %s %s", got.method, got.path) } if me.User.ID != 3 || !me.HasPassword { t.Errorf("unexpected decode %+v", me) } } func TestClient_SetPassword(t *testing.T) { c, got := stub(t, http.StatusNoContent, ``) if err := c.SetPassword(2, "a brand new secret", ""); err != nil { t.Fatalf("set password: %v", err) } if got.method != http.MethodPut || got.path != "/api/users/2/password" { t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path) } // Setting someone else's password carries no current_password at all, // rather than an empty one. if got.body != `{"password":"a brand new secret"}` { t.Errorf("unexpected body %s", got.body) } c, got = stub(t, http.StatusNoContent, ``) c.SetPassword(1, "a brand new secret", "the old one") if !strings.Contains(got.body, `"current_password":"the old one"`) { t.Errorf("current password missing from %s", got.body) } } // Older servers have no /api/me; the caller tells that apart by the status // code, so the typed error has to carry it. func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) { c, _ := stub(t, http.StatusNotFound, `404 page not found`) _, err := c.Me() var se *StatusError if !errors.As(err, &se) || se.Code != http.StatusNotFound { t.Fatalf("expected a 404 StatusError, got %v", err) } if err.Error() != "server returned 404" { t.Errorf("message changed: %q", err.Error()) } }