Show team members as a list, with who is on call and who cannot be paged
Team -> Members was a two-column table and an inline add form. It is now a table in the style of Switches, Sources and Escalation: a status badge, the member, their role, their next rota day, when they were last active and when they joined. Adding a member and changing a role moved into sheets, and removing one asks first. The badge is the one that matters at 03:00: On call if the rota has them today, Reachable if they have an ntfy topic, and Can't be paged when a page to them would go nowhere -- no topic, or a disabled account -- with the reason under their name. Not being pageable wins over being on call, since an on-call person nobody can reach is the case worth seeing before an incident finds it. The rules are the notifier's own. The topic itself is never in the response, only whether one is set. Last active is the newer of a member's newest session and API-key use, and is shown to every member of the team like the rest of the list. Rota days are UTC dates, and the page formats them as such so a day cannot show up as the one before. Removing a member leaves the rota days already assigned to them alone, which the confirm says, so they are reassigned from the Rota tab rather than silently dropped. Demoting the last owner is now refused with 409, as removing them already was: it was the same outcome by another route, a team with nobody who can edit it. API: GET /members gains status, on_call, next_shift, pageable, problem and last_active_at; additive, no migration, and terdut-tui needs nothing. POST /members answers 409 for the last-owner demotion.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user