package api_test import ( "net/http" "testing" "time" "git.ryuvia.com/niklas/terdut-server/internal/api" ) func testNotify() api.NotifyConfig { return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute} } type memberView struct { Username string `json:"username"` Role string `json:"role"` Status string `json:"status"` OnCall bool `json:"on_call"` NextShift *string `json:"next_shift"` Pageable bool `json:"pageable"` Problem string `json:"problem"` LastActiveAt *string `json:"last_active_at"` } func readMembers(t *testing.T, s *ts) map[string]memberView { t.Helper() var list []memberView decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list) out := map[string]memberView{} for _, m := range list { out[m.Username] = m } return out } // The list says who is on call, who could not be woken, and who is merely // there — and an on-call person who cannot be paged is the red one. func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) { s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic teamUser(t, s, "reachable", "terdut-reachable") silent := teamUser(t, s, "silent", "terdut-silent") s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent) got := readMembers(t, s) if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable { t.Errorf("the person on call should read on call, got %+v", m) } if m := got["reachable"]; m.Status != "reachable" || m.OnCall { t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m) } if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" { t.Errorf("no topic means they cannot be paged, got %+v", m) } // Being on call does not rescue an account that cannot be woken. s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'") if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall { t.Errorf("an on-call person with no topic is the red case, got %+v", m) } s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent) if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" { t.Errorf("a disabled account should say so, got %+v", m) } } // The next shift is the next day after today, not today itself. func TestMembers_NextShiftIsAfterToday(t *testing.T) { s, _ := notifyTS(t, testNotify()) tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02") resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", map[string]any{"user_id": 1, "dates": []string{tomorrow}}) resp.Body.Close() m := readMembers(t, s)["admin"] if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow { t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m) } teamUser(t, s, "idle", "terdut-idle") if m := readMembers(t, s)["idle"]; m.NextShift != nil { t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift) } } // Last active is the newer of a session and an API key, and absent when neither // has ever been used. func TestMembers_LastActive(t *testing.T) { s, _ := notifyTS(t, testNotify()) idle := teamUser(t, s, "idle", "terdut-idle") if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil { t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt) } old := time.Now().Add(-48 * time.Hour).Unix() s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old) s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at) VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix()) m := readMembers(t, s)["idle"] if m.LastActiveAt == nil { t.Fatal("expected a last active time") } got, _ := time.Parse(time.RFC3339, *m.LastActiveAt) if got.Unix() != old+3600 { t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix()) } } // The last owner can be neither removed nor demoted; with another owner in // place, both are fine. func TestMembers_LastOwnerIsProtected(t *testing.T) { s, _ := notifyTS(t, testNotify()) tm := newTeam(t, s, "red") base := "/api/teams/" + id64(tm.id) + "/members" // Creating a team makes the creator an owner too; step the admin out so // "red-user" is the only one left. resp := s.req(t, http.MethodDelete, base+"/1", nil) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("removing the creator: %d", resp.StatusCode) } var members []map[string]any decode(t, tm.call(http.MethodGet, base, nil), &members) var owner int64 for _, m := range members { if m["username"] == "red-user" { owner = int64(m["user_id"].(float64)) } } resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"}) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode) } resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode) } // A second owner frees the first to step down. resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"}) resp.Body.Close() resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"}) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode) } }