check / check (push) Waiting to run
With debug logging on, each delivery logged the full Slack, Mattermost or ntfy URL, whose path or query lets anyone who has it post to the channel or topic. The two debug lines now give the endpoint's name and the title; sendSlack is told which of Slack or Mattermost it sends to. A webhook URL that does not parse gave a startup error quoting the whole URL; it now gives only the reason. Errors from a failed request come from the transport, not from an HTTP client, and carry no part of the path or query; a test now checks that too. Model: opus-5-5
1353 lines
28 KiB
Go
1353 lines
28 KiB
Go
package notify_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
|
)
|
|
|
|
// Color constants used across multiple tests.
|
|
const (
|
|
colorError = "#dc3545"
|
|
colorWarning = "#ffc107"
|
|
colorSuccess = "#28a745"
|
|
colorInfo = "#17a2b8"
|
|
colorDefault = "#6c757d"
|
|
)
|
|
|
|
// Priority strings used across multiple tests.
|
|
const (
|
|
prioError = "error"
|
|
prioWarning = "warning"
|
|
prioSuccess = "success"
|
|
prioInfo = "info"
|
|
prioUnknown = "unknown"
|
|
prioDefault = "default"
|
|
prioUrgent = "urgent"
|
|
)
|
|
|
|
// Endpoint names, as the log gives them, used across multiple tests.
|
|
const (
|
|
endpointNtfy = "ntfy"
|
|
endpointSlack = "slack"
|
|
endpointMattermost = "mattermost"
|
|
)
|
|
|
|
// testHost is the hostname used in request construction tests.
|
|
const testHost = "example.com"
|
|
|
|
// errSimulated is a static error for transport failures.
|
|
var errSimulated = errors.New("simulated transport failure")
|
|
|
|
// failingTransport always returns an error on RoundTrip.
|
|
type failingTransport struct {
|
|
err error
|
|
}
|
|
|
|
func (ft *failingTransport) RoundTrip(
|
|
_ *http.Request,
|
|
) (*http.Response, error) {
|
|
return nil, ft.err
|
|
}
|
|
|
|
// waitForCondition polls until fn returns true or the test
|
|
// times out. This accommodates the goroutine-based dispatch
|
|
// in SendNotification.
|
|
func waitForCondition(t *testing.T, fn func() bool) {
|
|
t.Helper()
|
|
|
|
const (
|
|
maxAttempts = 200
|
|
pollDelay = 10 * time.Millisecond
|
|
)
|
|
|
|
for range maxAttempts {
|
|
if fn() {
|
|
return
|
|
}
|
|
|
|
time.Sleep(pollDelay)
|
|
}
|
|
|
|
t.Fatal("condition not met within timeout")
|
|
}
|
|
|
|
// slackCapture holds values captured from a Slack/Mattermost
|
|
// webhook request, protected by a mutex for goroutine safety.
|
|
type slackCapture struct {
|
|
mu sync.Mutex
|
|
called bool
|
|
payload notify.SlackPayload
|
|
}
|
|
|
|
func newSlackCaptureServer() (
|
|
*httptest.Server, *slackCapture,
|
|
) {
|
|
c := &slackCapture{}
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.called = true
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &c.payload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return srv, c
|
|
}
|
|
|
|
// ── ntfyPriority ──────────────────────────────────────────
|
|
|
|
func TestNtfyPriority(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, prioUrgent},
|
|
{prioWarning, "high"},
|
|
{prioSuccess, prioDefault},
|
|
{prioInfo, "low"},
|
|
{"", prioDefault},
|
|
{prioUnknown, prioDefault},
|
|
{"critical", prioDefault},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := notify.NtfyPriority(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf(
|
|
"NtfyPriority(%q) = %q, want %q",
|
|
tc.input, got, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── slackColor ────────────────────────────────────────────
|
|
|
|
func TestSlackColor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, colorError},
|
|
{prioWarning, colorWarning},
|
|
{prioSuccess, colorSuccess},
|
|
{prioInfo, colorInfo},
|
|
{"", colorDefault},
|
|
{prioUnknown, colorDefault},
|
|
{"critical", colorDefault},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := notify.SlackColor(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf(
|
|
"SlackColor(%q) = %q, want %q",
|
|
tc.input, got, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── newRequest ────────────────────────────────────────────
|
|
|
|
func TestNewRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := &url.URL{
|
|
Scheme: "https",
|
|
Host: testHost,
|
|
Path: "/webhook",
|
|
}
|
|
body := bytes.NewBufferString("hello")
|
|
ctx := context.Background()
|
|
|
|
req := notify.NewRequestForTest(
|
|
ctx, http.MethodPost, target, body,
|
|
)
|
|
|
|
if req.Method != http.MethodPost {
|
|
t.Errorf("Method = %q, want POST", req.Method)
|
|
}
|
|
|
|
if req.URL.String() != "https://example.com/webhook" {
|
|
t.Errorf(
|
|
"URL = %q, want %q",
|
|
req.URL.String(),
|
|
"https://example.com/webhook",
|
|
)
|
|
}
|
|
|
|
if req.Host != testHost {
|
|
t.Errorf(
|
|
"Host = %q, want %q", req.Host, testHost,
|
|
)
|
|
}
|
|
|
|
if req.Header == nil {
|
|
t.Error("Header map should be initialized")
|
|
}
|
|
|
|
got, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
t.Fatalf("reading body: %v", err)
|
|
}
|
|
|
|
if string(got) != "hello" {
|
|
t.Errorf("Body = %q, want %q", string(got), "hello")
|
|
}
|
|
}
|
|
|
|
func TestNewRequestPreservesContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type ctxKey string
|
|
|
|
ctx := context.WithValue(
|
|
context.Background(),
|
|
ctxKey("k"),
|
|
"v",
|
|
)
|
|
target := &url.URL{Scheme: "https", Host: testHost}
|
|
|
|
req := notify.NewRequestForTest(
|
|
ctx, http.MethodGet, target, http.NoBody,
|
|
)
|
|
|
|
if req.Context().Value(ctxKey("k")) != "v" {
|
|
t.Error("context value not preserved")
|
|
}
|
|
}
|
|
|
|
// ── sendNtfy ──────────────────────────────────────────────
|
|
|
|
// ntfyCapture holds values captured from an ntfy request.
|
|
type ntfyCapture struct {
|
|
method string
|
|
title string
|
|
priority string
|
|
body string
|
|
}
|
|
|
|
func newNtfyCaptureServer() (*httptest.Server, *ntfyCapture) {
|
|
c := &ntfyCapture{}
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
c.method = r.Method
|
|
c.title = r.Header.Get("Title")
|
|
c.priority = r.Header.Get("Priority")
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
c.body = string(b)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return srv, c
|
|
}
|
|
|
|
func TestSendNtfyHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv, captured := newNtfyCaptureServer()
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
topicURL, _ := url.Parse(srv.URL + "/test-topic")
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(),
|
|
topicURL,
|
|
"Test Title",
|
|
"Test message body",
|
|
"error",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendNtfy returned error: %v", err)
|
|
}
|
|
|
|
if captured.method != http.MethodPost {
|
|
t.Errorf("method = %q, want POST", captured.method)
|
|
}
|
|
|
|
if captured.title != "Test Title" {
|
|
t.Errorf(
|
|
"Title header = %q, want %q",
|
|
captured.title, "Test Title",
|
|
)
|
|
}
|
|
|
|
if captured.priority != prioUrgent {
|
|
t.Errorf(
|
|
"Priority header = %q, want %q",
|
|
captured.priority, prioUrgent,
|
|
)
|
|
}
|
|
|
|
if captured.body != "Test message body" {
|
|
t.Errorf(
|
|
"body = %q, want %q",
|
|
captured.body, "Test message body",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyAllPriorities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
priorities := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{prioError, prioUrgent},
|
|
{prioWarning, "high"},
|
|
{prioSuccess, prioDefault},
|
|
{prioInfo, "low"},
|
|
}
|
|
|
|
for _, tc := range priorities {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotPriority string
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
gotPriority = r.Header.Get("Priority")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(
|
|
srv.Client().Transport,
|
|
)
|
|
topicURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(),
|
|
topicURL, "t", "m", tc.input,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendNtfy error: %v", err)
|
|
}
|
|
|
|
if gotPriority != tc.want {
|
|
t.Errorf(
|
|
"priority %q: got %q, want %q",
|
|
tc.input, gotPriority, tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertSendStatusError verifies that send returns an error
|
|
// wrapping wantErr when the server responds with status.
|
|
func assertSendStatusError(
|
|
t *testing.T,
|
|
status int,
|
|
wantErr error,
|
|
send func(*notify.Service, *url.URL) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(status)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
target, _ := url.Parse(srv.URL)
|
|
|
|
err := send(svc, target)
|
|
if err == nil {
|
|
t.Fatalf("expected error for %d response", status)
|
|
}
|
|
|
|
if !errors.Is(err, wantErr) {
|
|
t.Errorf("error = %v, want %v", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func sendNtfyInfo(
|
|
svc *notify.Service, target *url.URL,
|
|
) error {
|
|
return svc.SendNtfy(
|
|
context.Background(), target, "t", "m", prioInfo,
|
|
)
|
|
}
|
|
|
|
func sendSlackInfo(
|
|
svc *notify.Service, target *url.URL,
|
|
) error {
|
|
return svc.SendSlack(
|
|
context.Background(), target, notify.ErrSlackFailed,
|
|
"t", "m", prioInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfyClientError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusForbidden,
|
|
notify.ErrNtfyFailed, sendNtfyInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfyServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusInternalServerError,
|
|
notify.ErrNtfyFailed, sendNtfyInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendNtfySuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
topicURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(), topicURL, "t", "m", "info",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected success for 200: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendNtfyNetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &failingTransport{err: errSimulated}
|
|
|
|
svc := notify.NewTestService(transport)
|
|
topicURL, _ := url.Parse(
|
|
"http://unreachable.invalid/topic",
|
|
)
|
|
|
|
err := svc.SendNtfy(
|
|
context.Background(), topicURL, "t", "m", "info",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for network failure")
|
|
}
|
|
}
|
|
|
|
// ── sendSlack ─────────────────────────────────────────────
|
|
|
|
func TestSendSlackPayloadFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
gotContentType string
|
|
gotPayload notify.SlackPayload
|
|
gotMethod string
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &gotPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(srv.Client().Transport)
|
|
webhookURL, _ := url.Parse(srv.URL + "/hooks/test")
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(),
|
|
webhookURL,
|
|
notify.ErrSlackFailed,
|
|
"Alert Title",
|
|
"Alert body text",
|
|
"warning",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendSlack returned error: %v", err)
|
|
}
|
|
|
|
assertSlackPayload(
|
|
t,
|
|
gotMethod,
|
|
gotContentType,
|
|
gotPayload,
|
|
"Alert Title",
|
|
"Alert body text",
|
|
colorWarning,
|
|
)
|
|
}
|
|
|
|
func assertSlackPayload(
|
|
t *testing.T,
|
|
method, contentType string,
|
|
payload notify.SlackPayload,
|
|
wantTitle, wantText, wantColor string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if method != http.MethodPost {
|
|
t.Errorf("method = %q, want POST", method)
|
|
}
|
|
|
|
if contentType != "application/json" {
|
|
t.Errorf(
|
|
"Content-Type = %q, want application/json",
|
|
contentType,
|
|
)
|
|
}
|
|
|
|
if len(payload.Attachments) != 1 {
|
|
t.Fatalf(
|
|
"attachments length = %d, want 1",
|
|
len(payload.Attachments),
|
|
)
|
|
}
|
|
|
|
att := payload.Attachments[0]
|
|
|
|
if att.Title != wantTitle {
|
|
t.Errorf(
|
|
"title = %q, want %q", att.Title, wantTitle,
|
|
)
|
|
}
|
|
|
|
if att.Text != wantText {
|
|
t.Errorf("text = %q, want %q", att.Text, wantText)
|
|
}
|
|
|
|
if att.Color != wantColor {
|
|
t.Errorf(
|
|
"color = %q, want %q", att.Color, wantColor,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendSlackAllColors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
colors := []struct {
|
|
priority string
|
|
want string
|
|
}{
|
|
{prioError, colorError},
|
|
{prioWarning, colorWarning},
|
|
{prioSuccess, colorSuccess},
|
|
{prioInfo, colorInfo},
|
|
{prioUnknown, colorDefault},
|
|
}
|
|
|
|
for _, tc := range colors {
|
|
t.Run(tc.priority, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotPayload notify.SlackPayload
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &gotPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
svc := notify.NewTestService(
|
|
srv.Client().Transport,
|
|
)
|
|
webhookURL, _ := url.Parse(srv.URL)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(),
|
|
webhookURL, notify.ErrSlackFailed,
|
|
"t", "m", tc.priority,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendSlack error: %v", err)
|
|
}
|
|
|
|
if len(gotPayload.Attachments) == 0 {
|
|
t.Fatal("no attachments in payload")
|
|
}
|
|
|
|
if gotPayload.Attachments[0].Color != tc.want {
|
|
t.Errorf(
|
|
"priority %q: color = %q, want %q",
|
|
tc.priority,
|
|
gotPayload.Attachments[0].Color,
|
|
tc.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSendSlackClientError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusBadRequest,
|
|
notify.ErrSlackFailed, sendSlackInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendSlackServerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendStatusError(
|
|
t, http.StatusBadGateway,
|
|
notify.ErrSlackFailed, sendSlackInfo,
|
|
)
|
|
}
|
|
|
|
func TestSendSlackNetworkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &failingTransport{err: errSimulated}
|
|
|
|
svc := notify.NewTestService(transport)
|
|
webhookURL, _ := url.Parse(
|
|
"http://unreachable.invalid/hooks",
|
|
)
|
|
|
|
err := svc.SendSlack(
|
|
context.Background(), webhookURL, notify.ErrSlackFailed,
|
|
"t", "m", "info",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for network failure")
|
|
}
|
|
}
|
|
|
|
// ── SendNotification (exported) ───────────────────────────
|
|
|
|
// endpointResult captures concurrent results from all three
|
|
// notification endpoints.
|
|
type endpointResult struct {
|
|
mu sync.Mutex
|
|
ntfyCalled bool
|
|
ntfyTitle string
|
|
slackCalled bool
|
|
slackPayload notify.SlackPayload
|
|
mmCalled bool
|
|
mmPayload notify.SlackPayload
|
|
}
|
|
|
|
func newEndpointServers(
|
|
r *endpointResult,
|
|
) (*httptest.Server, *httptest.Server, *httptest.Server) {
|
|
ntfy := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.ntfyCalled = true
|
|
r.ntfyTitle = req.Header.Get("Title")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
slack := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.slackCalled = true
|
|
|
|
b, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(b, &r.slackPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
mm := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.mmCalled = true
|
|
|
|
b, _ := io.ReadAll(req.Body)
|
|
_ = json.Unmarshal(b, &r.mmPayload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
return ntfy, slack, mm
|
|
}
|
|
|
|
func assertAllEndpointsResult(
|
|
t *testing.T, r *endpointResult,
|
|
) {
|
|
t.Helper()
|
|
|
|
if r.ntfyTitle != "DNS Changed" {
|
|
t.Errorf(
|
|
"ntfy Title = %q, want %q",
|
|
r.ntfyTitle, "DNS Changed",
|
|
)
|
|
}
|
|
|
|
if len(r.slackPayload.Attachments) == 0 {
|
|
t.Fatal("slack payload has no attachments")
|
|
}
|
|
|
|
if r.slackPayload.Attachments[0].Title != "DNS Changed" {
|
|
t.Errorf(
|
|
"slack title = %q, want %q",
|
|
r.slackPayload.Attachments[0].Title,
|
|
"DNS Changed",
|
|
)
|
|
}
|
|
|
|
if len(r.mmPayload.Attachments) == 0 {
|
|
t.Fatal("mattermost payload has no attachments")
|
|
}
|
|
|
|
wantText := "example.com A record updated"
|
|
if r.mmPayload.Attachments[0].Text != wantText {
|
|
t.Errorf(
|
|
"mattermost text = %q, want %q",
|
|
r.mmPayload.Attachments[0].Text,
|
|
wantText,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationAllEndpoints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := &endpointResult{}
|
|
ntfySrv, slackSrv, mmSrv := newEndpointServers(result)
|
|
|
|
defer ntfySrv.Close()
|
|
defer slackSrv.Close()
|
|
defer mmSrv.Close()
|
|
|
|
ntfyURL, _ := url.Parse(ntfySrv.URL)
|
|
slackURL, _ := url.Parse(slackSrv.URL)
|
|
mmURL, _ := url.Parse(mmSrv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetNtfyURL(ntfyURL)
|
|
svc.SetSlackWebhookURL(slackURL)
|
|
svc.SetMattermostWebhookURL(mmURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"DNS Changed",
|
|
"example.com A record updated",
|
|
"warning",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
result.mu.Lock()
|
|
defer result.mu.Unlock()
|
|
|
|
return result.ntfyCalled &&
|
|
result.slackCalled &&
|
|
result.mmCalled
|
|
})
|
|
|
|
result.mu.Lock()
|
|
defer result.mu.Unlock()
|
|
|
|
assertAllEndpointsResult(t, result)
|
|
}
|
|
|
|
func TestSendNotificationNoWebhooks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
|
|
// All URL fields are nil — this should be a no-op.
|
|
svc.SendNotification(
|
|
context.Background(), "Title", "Message", "info",
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationNtfyOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
called bool
|
|
gotTitle string
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
called = true
|
|
gotTitle = r.Header.Get("Title")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
ntfyURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetNtfyURL(ntfyURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "Only Ntfy", "body", "info",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return called
|
|
})
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if gotTitle != "Only Ntfy" {
|
|
t.Errorf(
|
|
"title = %q, want %q", gotTitle, "Only Ntfy",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationSlackOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
called bool
|
|
payload notify.SlackPayload
|
|
)
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
called = true
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &payload)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
slackURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetSlackWebhookURL(slackURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"Slack Only", "body", "error",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return called
|
|
})
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(payload.Attachments) == 0 {
|
|
t.Fatal("no attachments")
|
|
}
|
|
|
|
if payload.Attachments[0].Color != colorError {
|
|
t.Errorf(
|
|
"color = %q, want %q",
|
|
payload.Attachments[0].Color, colorError,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendNotificationMattermostOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv, capture := newSlackCaptureServer()
|
|
defer srv.Close()
|
|
|
|
mmURL, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
svc.SetMattermostWebhookURL(mmURL)
|
|
|
|
svc.SendNotification(
|
|
context.Background(),
|
|
"MM Only", "body", "success",
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
capture.mu.Lock()
|
|
defer capture.mu.Unlock()
|
|
|
|
return capture.called
|
|
})
|
|
|
|
capture.mu.Lock()
|
|
defer capture.mu.Unlock()
|
|
|
|
if len(capture.payload.Attachments) == 0 {
|
|
t.Fatal("no attachments")
|
|
}
|
|
|
|
att := capture.payload.Attachments[0]
|
|
|
|
if att.Title != "MM Only" {
|
|
t.Errorf(
|
|
"title = %q, want %q", att.Title, "MM Only",
|
|
)
|
|
}
|
|
|
|
if att.Color != colorSuccess {
|
|
t.Errorf(
|
|
"color = %q, want %q", att.Color, colorSuccess,
|
|
)
|
|
}
|
|
}
|
|
|
|
// assertSendNotificationTolerates verifies SendNotification
|
|
// neither panics nor blocks when the endpoint configured by
|
|
// setURL responds with status.
|
|
func assertSendNotificationTolerates(
|
|
t *testing.T,
|
|
status int,
|
|
priority string,
|
|
setURL func(*notify.Service, *url.URL),
|
|
) {
|
|
t.Helper()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(status)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
target, _ := url.Parse(srv.URL)
|
|
|
|
svc := notify.NewTestService(http.DefaultTransport)
|
|
setURL(svc, target)
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", priority,
|
|
)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestSendNotificationNtfyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusInternalServerError, prioError,
|
|
(*notify.Service).SetNtfyURL,
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationSlackError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusForbidden, prioError,
|
|
(*notify.Service).SetSlackWebhookURL,
|
|
)
|
|
}
|
|
|
|
func TestSendNotificationMattermostError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSendNotificationTolerates(
|
|
t, http.StatusBadGateway, prioWarning,
|
|
(*notify.Service).SetMattermostWebhookURL,
|
|
)
|
|
}
|
|
|
|
// TestSendNotificationErrorNamesEndpoint verifies that, with both
|
|
// Slack and Mattermost set, a failed delivery's logged error names
|
|
// the endpoint that failed. Both are sent by the Slack sender.
|
|
func TestSendNotificationErrorNamesEndpoint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
target, _ := url.Parse(srv.URL)
|
|
|
|
svc, logs := newLoggingService(http.DefaultTransport)
|
|
svc.SetSlackWebhookURL(target)
|
|
svc.SetMattermostWebhookURL(target)
|
|
svc.SetSleepFunc(instantSleep)
|
|
svc.SetRetryConfig(notify.RetryConfig{
|
|
MaxRetries: 1,
|
|
BaseDelay: time.Millisecond,
|
|
MaxDelay: time.Millisecond,
|
|
})
|
|
|
|
svc.SendNotification(
|
|
context.Background(), "t", "m", prioError,
|
|
)
|
|
|
|
waitForCondition(t, func() bool {
|
|
return svc.OutstandingDeliveries() == 0
|
|
})
|
|
|
|
got := map[string]string{}
|
|
|
|
for line := range strings.Lines(logs.String()) {
|
|
var record struct {
|
|
Msg string `json:"msg"`
|
|
Endpoint string `json:"endpoint"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
_ = json.Unmarshal([]byte(line), &record)
|
|
|
|
if record.Msg == "failed to send notification after retries" {
|
|
got[record.Endpoint] = record.Error
|
|
}
|
|
}
|
|
|
|
want := map[string]string{
|
|
"slack": "slack notification failed: status 503",
|
|
"mattermost": "mattermost notification failed: status 503",
|
|
}
|
|
|
|
if !maps.Equal(got, want) {
|
|
t.Errorf("logged errors = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// ── webhook URLs kept out of the log ──────────────────────
|
|
|
|
// webhookPathAndQuery follows the server's address in every
|
|
// endpoint's URL in the tests below. A webhook URL's path or query
|
|
// lets anyone post to the channel or topic, so neither word marked
|
|
// secret in it may reach the log.
|
|
const webhookPathAndQuery = "/hooks/secret-in-path?token=secret-in-query"
|
|
|
|
// assertNoWebhookSecret fails the test if text holds either word
|
|
// marked secret in webhookPathAndQuery.
|
|
func assertNoWebhookSecret(t *testing.T, text string) {
|
|
t.Helper()
|
|
|
|
for _, secret := range []string{"secret-in-path", "secret-in-query"} {
|
|
if strings.Contains(text, secret) {
|
|
t.Errorf("%q from the webhook URL is in:\n%s", secret, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// logRecord is the part of a JSON log line the tests below read.
|
|
type logRecord struct {
|
|
Level string `json:"level"`
|
|
Msg string `json:"msg"`
|
|
Endpoint string `json:"endpoint"`
|
|
Title string `json:"title"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// parseLog decodes each line of a JSON log.
|
|
func parseLog(t *testing.T, logs string) []logRecord {
|
|
t.Helper()
|
|
|
|
var records []logRecord
|
|
|
|
for line := range strings.Lines(logs) {
|
|
var record logRecord
|
|
|
|
err := json.Unmarshal([]byte(line), &record)
|
|
if err != nil {
|
|
t.Fatalf("decoding log line %q: %v", line, err)
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records
|
|
}
|
|
|
|
// sendToEachEndpoint sends one notification with the given title to
|
|
// ntfy, Slack and Mattermost, each at baseURL followed by
|
|
// webhookPathAndQuery, and retries a failed delivery once. When
|
|
// every delivery has finished it returns the JSON log written,
|
|
// debug lines included.
|
|
func sendToEachEndpoint(t *testing.T, baseURL, title string) string {
|
|
t.Helper()
|
|
|
|
target, err := url.Parse(baseURL + webhookPathAndQuery)
|
|
if err != nil {
|
|
t.Fatalf("parsing webhook URL: %v", err)
|
|
}
|
|
|
|
svc, logs := newLoggingService(http.DefaultTransport)
|
|
svc.SetNtfyURL(target)
|
|
svc.SetSlackWebhookURL(target)
|
|
svc.SetMattermostWebhookURL(target)
|
|
svc.SetSleepFunc(instantSleep)
|
|
svc.SetRetryConfig(notify.RetryConfig{
|
|
MaxRetries: 1,
|
|
BaseDelay: time.Millisecond,
|
|
MaxDelay: time.Millisecond,
|
|
})
|
|
|
|
svc.SendNotification(context.Background(), title, "m", prioInfo)
|
|
|
|
waitForCondition(t, func() bool {
|
|
return svc.OutstandingDeliveries() == 0
|
|
})
|
|
|
|
return logs.String()
|
|
}
|
|
|
|
// TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug
|
|
// logging on, a delivery to each endpoint logs the endpoint's name and
|
|
// the title, and no part of the URL's path or query.
|
|
func TestSendNotificationDebugLogOmitsWebhookURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const title = "Port Change"
|
|
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
defer srv.Close()
|
|
|
|
logs := sendToEachEndpoint(t, srv.URL, title)
|
|
|
|
assertNoWebhookSecret(t, logs)
|
|
|
|
got := map[string]string{}
|
|
|
|
for _, record := range parseLog(t, logs) {
|
|
if record.Level == slog.LevelDebug.String() {
|
|
got[record.Endpoint] = record.Title
|
|
}
|
|
}
|
|
|
|
want := map[string]string{
|
|
endpointNtfy: title,
|
|
endpointSlack: title,
|
|
endpointMattermost: title,
|
|
}
|
|
|
|
if !maps.Equal(got, want) {
|
|
t.Errorf("debug lines' endpoint and title = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestSendNotificationErrorLogOmitsWebhookURL checks that a delivery
|
|
// to each endpoint that cannot connect logs the transport's error,
|
|
// which names the refused connection, on its retry line and on its
|
|
// final failure line, and that no line holds any part of the URL's
|
|
// path or query.
|
|
func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Closed before anything is sent, so every attempt fails to
|
|
// connect.
|
|
srv := httptest.NewServer(http.NotFoundHandler())
|
|
srv.Close()
|
|
|
|
logs := sendToEachEndpoint(t, srv.URL, "t")
|
|
|
|
assertNoWebhookSecret(t, logs)
|
|
|
|
// For each endpoint, the messages of its lines whose error names
|
|
// the refused connection, in the order they were logged.
|
|
got := map[string][]string{}
|
|
|
|
for _, record := range parseLog(t, logs) {
|
|
if strings.Contains(record.Error, "connection refused") {
|
|
got[record.Endpoint] = append(
|
|
got[record.Endpoint], record.Msg,
|
|
)
|
|
}
|
|
}
|
|
|
|
lines := []string{
|
|
"notification delivery failed, retrying",
|
|
"failed to send notification after retries",
|
|
}
|
|
want := map[string][]string{
|
|
endpointNtfy: lines,
|
|
endpointSlack: lines,
|
|
endpointMattermost: lines,
|
|
}
|
|
|
|
if !maps.EqualFunc(got, want, slices.Equal) {
|
|
t.Errorf("lines whose error names the refused connection = %v, "+
|
|
"want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// ── SlackPayload JSON marshaling ──────────────────────────
|
|
|
|
func TestSlackPayloadJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := notify.SlackPayload{
|
|
Text: "fallback",
|
|
Attachments: []notify.SlackAttachment{
|
|
{
|
|
Color: colorSuccess,
|
|
Title: "Test",
|
|
Text: "body",
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var decoded notify.SlackPayload
|
|
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if decoded.Text != "fallback" {
|
|
t.Errorf(
|
|
"Text = %q, want %q", decoded.Text, "fallback",
|
|
)
|
|
}
|
|
|
|
if len(decoded.Attachments) != 1 {
|
|
t.Fatalf(
|
|
"Attachments len = %d, want 1",
|
|
len(decoded.Attachments),
|
|
)
|
|
}
|
|
|
|
att := decoded.Attachments[0]
|
|
|
|
if att.Color != colorSuccess {
|
|
t.Errorf(
|
|
"Color = %q, want %q", att.Color, colorSuccess,
|
|
)
|
|
}
|
|
|
|
if att.Title != "Test" {
|
|
t.Errorf("Title = %q, want %q", att.Title, "Test")
|
|
}
|
|
|
|
if att.Text != "body" {
|
|
t.Errorf("Text = %q, want %q", att.Text, "body")
|
|
}
|
|
}
|
|
|
|
func TestSlackPayloadEmptyAttachments(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := notify.SlackPayload{Text: "no attachments"}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
var raw map[string]json.RawMessage
|
|
|
|
err = json.Unmarshal(data, &raw)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal raw: %v", err)
|
|
}
|
|
|
|
if _, exists := raw["attachments"]; exists {
|
|
t.Error(
|
|
"attachments should be omitted when empty",
|
|
)
|
|
}
|
|
}
|