Files
terdut-server/internal/api/channels_test.go
T
Niklas Ye b114251f45 Add notification channels and a provider abstraction
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
2026-10-12 21:00:40 +02:00

530 lines
18 KiB
Go

package api_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// ---------------------------------------------------------------------------
// A second provider
// ---------------------------------------------------------------------------
// fakeMail is a provider that cannot render action buttons, which is what the
// notifier's capability handling is for. Registering it next to ntfy is also the
// proof that nothing in the notifier still assumes there is only one.
type fakeMail struct {
mu sync.Mutex
got []notify.Message
fail error
}
func (m *fakeMail) Type() string { return "mail" }
func (m *fakeMail) Label() string { return "Mail" }
func (m *fakeMail) AddressLabel() string { return "Email address" }
func (m *fakeMail) AddressHint() string { return "Where to send pages." }
func (m *fakeMail) Capabilities() notify.Capabilities {
return notify.Capabilities{ActionButtons: false}
}
func (m *fakeMail) Validate(address string) (string, error) {
address = strings.TrimSpace(address)
if !strings.Contains(address, "@") {
return "", errors.New("that does not look like an email address")
}
return address, nil
}
func (m *fakeMail) Send(_ context.Context, msg notify.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.fail != nil {
return m.fail
}
m.got = append(m.got, msg)
return nil
}
func (m *fakeMail) messages() []notify.Message {
m.mu.Lock()
defer m.mu.Unlock()
return append([]notify.Message(nil), m.got...)
}
func (m *fakeMail) failWith(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.fail = err
}
// twoProviderTS is notifyTS with ntfy and the fake mail provider both
// registered, the admin on call, and a channel of each kind on the admin.
func twoProviderTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy, *fakeMail) {
t.Helper()
f := newFakeNtfy(t)
mail := &fakeMail{}
cfg.Providers = notify.NewRegistry(notify.NewNtfy(f.URL, ""), mail)
s := newTS(t, cfg)
putOnCall(t, s, 1)
addNtfyChannel(t, s, 1, "terdut-admin")
addChannel(t, s, 1, "mail", "admin@example.com")
return s, f, mail
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
func addChannel(t *testing.T, s *ts, userID int, typ, address string) {
t.Helper()
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID),
map[string]any{"type": typ, "address": address})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add %s channel returned %d", typ, resp.StatusCode)
}
}
// ---------------------------------------------------------------------------
// Fan-out
// ---------------------------------------------------------------------------
// A page goes to every enabled channel the person has, each in its own format.
func TestChannels_PageFansOutToEveryEnabledChannel(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Fatalf("expected 1 ntfy push, got %d", got)
}
if got := len(mail.messages()); got != 1 {
t.Fatalf("expected 1 mail message, got %d", got)
}
if to := mail.messages()[0].Address; to != "admin@example.com" {
t.Errorf("mail went to %q", to)
}
// One event per delivery, naming the channel type but never the address.
events := notifyEvents(t, s, 1)
if len(events) != 2 {
t.Fatalf("expected an event per channel, got %v", events)
}
seen := map[string]bool{}
for _, e := range events {
detail, _ := e["detail"].(string)
seen[detail] = true
if strings.Contains(detail, "terdut-admin") || strings.Contains(detail, "example.com") {
t.Errorf("the timeline must not carry an address, got %q", detail)
}
}
if !seen["triggered via ntfy"] || !seen["triggered via mail"] {
t.Errorf("expected one event per channel type, got %v", seen)
}
}
// A provider that cannot render a button still gets a way to acknowledge,
// as text; the one that can gets the button.
func TestChannels_AckIsAButtonOrAnInlineLinkByCapability(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
s.sweepNotify(t)
if m := f.messages()[0]; len(m.Actions) != 1 || m.Actions[0].Label != "Acknowledge" {
t.Errorf("ntfy should carry the Acknowledge button, got %+v", m.Actions)
}
m := mail.messages()[0]
if m.AckURL != "" {
t.Errorf("a provider without action buttons must not be handed an AckURL, got %q", m.AckURL)
}
if !strings.Contains(m.Body, "Acknowledge: https://terdut.example.com/incidents/1") {
t.Errorf("expected the acknowledge link in the body, got %q", m.Body)
}
if m.ClickURL != "https://terdut.example.com/incidents/1" {
t.Errorf("expected the incident link as click URL, got %q", m.ClickURL)
}
// Only the button needs a token; minting one for text nobody can use is waste.
var tokens int
if err := s.db.QueryRow("SELECT COUNT(*) FROM incident_ack_tokens").Scan(&tokens); err != nil {
t.Fatal(err)
}
if tokens != 1 {
t.Errorf("expected one ack token (for the ntfy button), got %d", tokens)
}
}
// One channel failing must not hold back the other, and its failure is recorded
// with the channel it happened on.
func TestChannels_OneFailingChannelDoesNotBlockTheOther(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
mail.failWith(errors.New("smtp is down"))
fireCritical(t, s)
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Fatalf("ntfy should have delivered, got %d", got)
}
var lastError string
if err := s.db.QueryRow(
"SELECT last_error FROM notifications WHERE channel_type = 'mail'").Scan(&lastError); err != nil {
t.Fatalf("read the mail row: %v", err)
}
if !strings.Contains(lastError, "smtp is down") {
t.Errorf("expected the failure kept on the row, got %q", lastError)
}
// Back up: the retry (made due) lands without re-sending the ntfy page.
mail.failWith(nil)
s.exec(t, "UPDATE notifications SET send_after = 0 WHERE sent_at IS NULL")
s.sweepNotify(t)
if got := len(mail.messages()); got != 1 {
t.Errorf("expected the retry to deliver the mail, got %d", got)
}
if got := len(f.messages()); got != 1 {
t.Errorf("ntfy must not be paged twice by the other channel's retry, got %d", got)
}
}
// Reminders and the all-clear go to every channel the original page went to.
func TestChannels_RemindersAndAllClearReachEveryChannel(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{
PublicURL: "https://terdut.example.com",
RepeatEvery: 15 * time.Minute,
})
fireCritical(t, s)
s.sweepNotify(t)
s.ageNotifications(t, 20*time.Minute)
s.sweepNotify(t)
if got := len(f.messages()); got != 2 {
t.Errorf("expected a reminder on ntfy, got %d message(s)", got)
}
if got := len(mail.messages()); got != 2 {
t.Errorf("expected a reminder on mail, got %d message(s)", got)
}
// The incident resolves by itself: the all-clear follows the last batch.
postWebhook(t, s, []map[string]any{
amAlert("fp-notify", "DiskFull", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T10:30:00Z",
map[string]string{"severity": "critical"}),
}, "{}:{alertname=\"DiskFull\"}")
s.sweepNotify(t)
if got := len(f.messages()); got != 3 {
t.Errorf("expected the all-clear on ntfy, got %d message(s)", got)
}
if got := len(mail.messages()); got != 3 {
t.Errorf("expected the all-clear on mail, got %d message(s)", got)
}
}
// Turning a channel off is the off switch for it: pages skip it, including the
// reminders of an incident that already paged it.
func TestChannels_DisabledChannelIsSkipped(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{
PublicURL: "https://terdut.example.com",
RepeatEvery: 15 * time.Minute,
})
fireCritical(t, s)
s.sweepNotify(t)
var mailChannel int
if err := s.db.QueryRow(
"SELECT id FROM notification_channels WHERE type = 'mail'").Scan(&mailChannel); err != nil {
t.Fatal(err)
}
resp := s.req(t, http.MethodPatch, fmt.Sprintf("/api/users/1/channels/%d", mailChannel),
map[string]any{"enabled": false})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable returned %d", resp.StatusCode)
}
s.ageNotifications(t, 20*time.Minute)
s.sweepNotify(t)
if got := len(f.messages()); got != 2 {
t.Errorf("ntfy should still be reminded, got %d message(s)", got)
}
if got := len(mail.messages()); got != 1 {
t.Errorf("a disabled channel must not be reminded, got %d message(s)", got)
}
// And a new incident does not page it at all.
postWebhook(t, s, []map[string]any{
amAlert("fp-second", "OtherAlert", "firing", "2026-05-20T11:00:00Z", zeroTime,
map[string]string{"severity": "critical"}),
}, "{}:{alertname=\"OtherAlert\"}")
s.sweepNotify(t)
if got := len(mail.messages()); got != 1 {
t.Errorf("a disabled channel must not be paged, got %d message(s)", got)
}
}
// A channel whose provider this server no longer has is skipped at enqueue rather
// than queued to fail eight times.
func TestChannels_UnconfiguredProviderIsSkipped(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
s.exec(t, "INSERT INTO notification_channels (user_id, type, address) VALUES (1, 'mail', 'admin@example.com')")
fireCritical(t, s)
if got := s.countNotifications(t, ""); got != 1 {
t.Errorf("expected only the ntfy row queued, got %d", got)
}
s.sweepNotify(t)
if got := len(f.messages()); got != 1 {
t.Errorf("expected the ntfy page, got %d", got)
}
}
// ---------------------------------------------------------------------------
// The channel API
// ---------------------------------------------------------------------------
func TestChannelsAPI_CreateValidatesTypeAndAddress(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
cases := []struct {
name string
body map[string]any
want int
}{
{"unknown type", map[string]any{"type": "carrier-pigeon", "address": "x"}, http.StatusBadRequest},
{"address the provider rejects", map[string]any{"type": "mail", "address": "not-an-address"}, http.StatusBadRequest},
{"empty topic", map[string]any{"type": "ntfy", "address": " "}, http.StatusBadRequest},
{"topic with a slash", map[string]any{"type": "ntfy", "address": "a/b"}, http.StatusBadRequest},
{"duplicate", map[string]any{"type": "ntfy", "address": "terdut-admin"}, http.StatusConflict},
{"fine", map[string]any{"type": "ntfy", "address": "another-topic"}, http.StatusCreated},
}
for _, c := range cases {
resp := s.req(t, http.MethodPost, "/api/users/1/channels", c.body)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
}
func TestChannelsAPI_CapsChannelsPerAccount(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
for i := 0; i < 8; i++ { // the two from setup make ten
addNtfyChannel(t, s, 1, fmt.Sprintf("topic-%d", i))
}
resp := s.req(t, http.MethodPost, "/api/users/1/channels",
map[string]any{"type": "ntfy", "address": "one-too-many"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("expected 409 at the cap, got %d", resp.StatusCode)
}
}
func TestChannelsAPI_ListUpdateDelete(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
var channels []struct {
ID int64 `json:"id"`
Type string `json:"type"`
Address string `json:"address"`
Enabled bool `json:"enabled"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/channels", nil), &channels)
if len(channels) != 2 || channels[0].Type != "ntfy" || channels[0].Address != "terdut-admin" || !channels[0].Enabled {
t.Fatalf("expected the two channels in creation order, got %+v", channels)
}
path := fmt.Sprintf("/api/users/1/channels/%d", channels[0].ID)
resp := s.req(t, http.MethodPatch, path, map[string]any{"address": "renamed-topic", "enabled": false})
var updated struct {
Address string `json:"address"`
Enabled bool `json:"enabled"`
}
decode(t, resp, &updated)
if updated.Address != "renamed-topic" || updated.Enabled {
t.Errorf("expected the update applied, got %+v", updated)
}
if resp := s.req(t, http.MethodPatch, path, map[string]any{}); resp.StatusCode != http.StatusBadRequest {
t.Errorf("an empty patch should be a 400, got %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("delete returned %d", resp.StatusCode)
}
resp = s.req(t, http.MethodDelete, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting twice should be a 404, got %d", resp.StatusCode)
}
}
// Somebody else's channels are not yours to read or change, and an administrator
// manages them without ever seeing where they go.
func TestChannelsAPI_Authorization(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
memberID, asMember := member(t, s, "member")
memberPath := "/api/users/" + id64(memberID) + "/channels"
// The member owns a channel; the admin can see that it exists, not its address.
resp := asMember(http.MethodPost, memberPath, map[string]any{"type": "ntfy", "address": "member-secret"})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("a member adds their own channel: %d", resp.StatusCode)
}
var own []struct {
Address string `json:"address"`
}
decode(t, asMember(http.MethodGet, memberPath, nil), &own)
if len(own) != 1 || own[0].Address != "member-secret" {
t.Errorf("the owner should see their address, got %+v", own)
}
var seenByAdmin []struct {
Type string `json:"type"`
Address string `json:"address"`
}
decode(t, s.req(t, http.MethodGet, memberPath, nil), &seenByAdmin)
if len(seenByAdmin) != 1 || seenByAdmin[0].Type != "ntfy" || seenByAdmin[0].Address != "" {
t.Errorf("an administrator should see the channel but not its address, got %+v", seenByAdmin)
}
// A member cannot touch the admin's.
for _, c := range []struct{ method, path string }{
{http.MethodGet, "/api/users/1/channels"},
{http.MethodPost, "/api/users/1/channels"},
{http.MethodPatch, "/api/users/1/channels/1"},
{http.MethodDelete, "/api/users/1/channels/1"},
{http.MethodPost, "/api/users/1/channels/1/test"},
} {
resp := asMember(c.method, c.path, map[string]any{"type": "ntfy", "address": "x", "enabled": false})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s: expected 403, got %d", c.method, c.path, resp.StatusCode)
}
}
// The administrator may manage the member's channels but not page them.
var list []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, memberPath, nil), &list)
resp = s.req(t, http.MethodPost, fmt.Sprintf("%s/%d/test", memberPath, list[0].ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("an administrator must not be able to test someone else's channel, got %d", resp.StatusCode)
}
}
func TestChannelsAPI_TestSendsThroughTheProvider(t *testing.T) {
s, f, mail := twoProviderTS(t, api.NotifyConfig{})
var channels []struct {
ID int64 `json:"id"`
Type string `json:"type"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/channels", nil), &channels)
for _, c := range channels {
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/1/channels/%d/test", c.ID), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("test of %s returned %d", c.Type, resp.StatusCode)
}
}
if msgs := f.messages(); len(msgs) != 1 || msgs[0].Topic != "terdut-admin" || msgs[0].Title != "terdut test" {
t.Errorf("expected a test push on the admin's topic, got %+v", msgs)
}
if msgs := mail.messages(); len(msgs) != 1 || msgs[0].Kind != notify.KindTest {
t.Errorf("expected a test message on mail, got %+v", msgs)
}
// The provider's complaint is the answer: it is why nothing arrived.
mail.failWith(errors.New("relay denied"))
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/1/channels/%d/test", channels[1].ID), nil)
body := readBody(t, resp)
if resp.StatusCode != http.StatusBadGateway || !strings.Contains(body, "relay denied") {
t.Errorf("expected a 502 carrying the reason, got %d %q", resp.StatusCode, body)
}
}
func TestChannelsAPI_ProvidersListsOnlyWhatIsConfigured(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
var providers []struct {
Type string `json:"type"`
Label string `json:"label"`
AddressLabel string `json:"address_label"`
AddressHint string `json:"address_hint"`
}
decode(t, s.req(t, http.MethodGet, "/api/notify/providers", nil), &providers)
if len(providers) != 2 || providers[0].Type != "mail" || providers[1].Type != "ntfy" {
t.Fatalf("expected mail and ntfy in type order, got %+v", providers)
}
if providers[1].AddressLabel != "Topic" || providers[1].AddressHint == "" {
t.Errorf("a provider should describe its address field, got %+v", providers[1])
}
none := newTS(t)
providers = nil
decode(t, none.req(t, http.MethodGet, "/api/notify/providers", nil), &providers)
if len(providers) != 0 {
t.Errorf("a server with no provider should list none, got %+v", providers)
}
}
// ---------------------------------------------------------------------------
// Fallback target
// ---------------------------------------------------------------------------
func TestChannelsAPI_FallbackMustBeADeliverableTarget(t *testing.T) {
s, _, _ := twoProviderTS(t, api.NotifyConfig{})
put := func(fallback map[string]string) int {
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
"repeat_count": 0, "fallback": fallback, "levels": []any{},
})
resp.Body.Close()
return resp.StatusCode
}
if got := put(map[string]string{"type": "ntfy", "address": "team-fallback"}); got != http.StatusOK {
t.Errorf("a configured provider should be accepted, got %d", got)
}
if got := put(map[string]string{"type": "", "address": ""}); got != http.StatusOK {
t.Errorf("no fallback should be accepted, got %d", got)
}
if got := put(map[string]string{"type": "ntfy"}); got != http.StatusBadRequest {
t.Errorf("a type without an address should be rejected, got %d", got)
}
if got := put(map[string]string{"type": "carrier-pigeon", "address": "x"}); got != http.StatusBadRequest {
t.Errorf("a provider this server lacks should be rejected, got %d", got)
}
if got := put(map[string]string{"type": "mail", "address": "nope"}); got != http.StatusBadRequest {
t.Errorf("an address the provider rejects should be rejected, got %d", got)
}
}