Files
dnswatcher/internal/notify/delivery_test.go
sneak 9054db8d86
All checks were successful
check / check (push) Successful in 33s
build: update golangci-lint to v2.12.2 with org-standard v2 config
Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5
(v2.12.2) in Dockerfile and script/bootstrap.

Set .golangci.yml to the org-standard v2-schema config already used
across the org's repos (owner-authorized; the same file is being
landed as canonical via a prompts-repo PR). Lint settings live under
linters.settings, so the lll, funlen, cyclop, and dupl thresholds are
actually applied. The informational gomodguard deprecation warning
this config can emit under v2.12 is accepted.

Fix all findings surfaced by the now-active thresholds:

- goconst: shared constants for repeated status, priority, and DNS
  fixture strings in watcher.go and the notify, state, and watcher
  tests
- dupl: consolidate duplicated ntfy/slack HTTP-error tests and
  SendNotification endpoint-error tests behind shared helpers
- lll: wrap long test table entries and comments; shorten one inline
  nolint justification
2026-08-07 20:56:58 +00:00

1112 lines
21 KiB
Go

package notify_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"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"
)
// 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, "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,
"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, "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, "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,
)
}
// ── 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",
)
}
}