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
530 lines
18 KiB
Go
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)
|
|
}
|
|
}
|