b114251f45
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
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, "DELETE FROM notification_channels WHERE user_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 channel who is off the rota is reachable, got %+v", m)
|
|
}
|
|
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no notification channel" {
|
|
t.Errorf("no channel means they cannot be paged, got %+v", m)
|
|
}
|
|
|
|
// Being on call does not rescue an account that cannot be woken.
|
|
s.exec(t, "DELETE FROM notification_channels WHERE user_id = (SELECT id FROM users WHERE username = 'admin')")
|
|
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
|
|
t.Errorf("an on-call person with no channel 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)
|
|
}
|
|
}
|