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) } }