package main import ( "bytes" "context" "encoding/json" "errors" "io" "log" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "text/template" "time" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } type webhookRecorder struct { mu sync.Mutex requests [][]byte status int err error } func (r *webhookRecorder) RoundTrip(request *http.Request) (*http.Response, error) { body, _ := io.ReadAll(request.Body) r.mu.Lock() r.requests = append(r.requests, body) status, err := r.status, r.err r.mu.Unlock() if err != nil { return nil, err } if status == 0 { status = http.StatusNoContent } return response(status, nil), nil } func (r *webhookRecorder) bodies() [][]byte { r.mu.Lock() defer r.mu.Unlock() return append([][]byte(nil), r.requests...) } func testApplication(t testing.TB, transport http.RoundTripper, logger *log.Logger) *application { t.Helper() if transport == nil { transport = &webhookRecorder{} } app, err := newApplication(applicationConfig{ WebhookURL: "http://discord.invalid/webhook", HTTPClient: &http.Client{Transport: transport}, Sleep: func(context.Context, time.Duration) error { return nil }, Logger: logger, Now: func() time.Time { return time.Date(2026, time.July, 16, 12, 34, 56, 0, time.UTC) }, }) if err != nil { t.Fatalf("newApplication() error = %v", err) } return app } func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder { request := httptest.NewRequest(method, path, strings.NewReader(body)) if contentType != "" { request.Header.Set("Content-Type", contentType) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) return recorder } func TestProbeAndRoutingContracts(t *testing.T) { handler := testApplication(t, nil, nil).handler() tests := []struct { name string method string path string wantStatus int wantBody string wantAllow string }{ {name: "ready get", method: http.MethodGet, path: "/ready", wantStatus: 200, wantBody: "Ready"}, {name: "ready head", method: http.MethodHead, path: "/ready", wantStatus: 200, wantBody: "Ready"}, {name: "live get", method: http.MethodGet, path: "/live", wantStatus: 200, wantBody: "Alive"}, {name: "probe wrong method", method: http.MethodPost, path: "/live", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "GET, HEAD"}, {name: "notify wrong method", method: http.MethodGet, path: "/notify", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, {name: "backup wrong method", method: http.MethodGet, path: "/template/notify/backup", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, {name: "update wrong method", method: http.MethodPut, path: "/template/notify/update", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, {name: "error wrong method", method: http.MethodDelete, path: "/template/notify/error", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, {name: "unknown template", method: http.MethodPost, path: "/template/notify/missing", wantStatus: 404, wantBody: "Not found"}, {name: "trailing slash is unknown", method: http.MethodPost, path: "/notify/", wantStatus: 404, wantBody: "Not found"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := performRequest(handler, test.method, test.path, "", "") if got.Code != test.wantStatus || got.Body.String() != test.wantBody { t.Fatalf("response = (%d, %q), want (%d, %q)", got.Code, got.Body.String(), test.wantStatus, test.wantBody) } if got.Header().Get("Allow") != test.wantAllow { t.Fatalf("Allow = %q, want %q", got.Header().Get("Allow"), test.wantAllow) } if got.Header().Get("Content-Type") != "text/plain; charset=utf-8" { t.Fatalf("Content-Type = %q", got.Header().Get("Content-Type")) } }) } } func TestRequestValidationContracts(t *testing.T) { tests := []struct { name string path string contentType string body string wantStatus int }{ {name: "missing content type", path: "/notify", body: `{}`, wantStatus: 415}, {name: "wrong content type", path: "/notify", contentType: "text/plain", body: `{}`, wantStatus: 415}, {name: "media type parameters", path: "/notify", contentType: "application/json; charset=utf-8", body: `{"title":"t","message":"m"}`, wantStatus: 200}, {name: "empty", path: "/notify", contentType: "application/json", body: "", wantStatus: 400}, {name: "array", path: "/notify", contentType: "application/json", body: `[]`, wantStatus: 400}, {name: "malformed", path: "/notify", contentType: "application/json", body: `{`, wantStatus: 400}, {name: "trailing document", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"m"}{}`, wantStatus: 400}, {name: "missing required", path: "/notify", contentType: "application/json", body: `{"title":"t"}`, wantStatus: 400}, {name: "missing title", path: "/notify", contentType: "application/json", body: `{"message":"m"}`, wantStatus: 400}, {name: "blank required", path: "/notify", contentType: "application/json", body: `{"title":" ","message":"m"}`, wantStatus: 400}, {name: "blank message", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"\t"}`, wantStatus: 400}, {name: "wrong type", path: "/notify", contentType: "application/json", body: `{"title":1,"message":"m"}`, wantStatus: 400}, {name: "wrong message type", path: "/notify", contentType: "application/json", body: `{"title":"t","message":false}`, wantStatus: 400}, {name: "known duplicate", path: "/notify", contentType: "application/json", body: `{"title":"one","TITLE":"two","message":"m"}`, wantStatus: 400}, {name: "unknown accepted", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"m","future":true}`, wantStatus: 200}, {name: "case insensitive", path: "/notify", contentType: "application/json", body: `{"TITLE":"t","MESSAGE":"m"}`, wantStatus: 200}, {name: "backup negative size", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":-1}`, wantStatus: 400}, {name: "backup missing size", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a"}`, wantStatus: 400}, {name: "backup wrong size type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":"large"}`, wantStatus: 400}, {name: "backup missing title", path: "/template/notify/backup", contentType: "application/json", body: `{"asset":"a","backupSizeInMB":1}`, wantStatus: 400}, {name: "backup blank title", path: "/template/notify/backup", contentType: "application/json", body: `{"title":" ","asset":"a","backupSizeInMB":1}`, wantStatus: 400}, {name: "backup wrong title type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":1,"asset":"a","backupSizeInMB":1}`, wantStatus: 400}, {name: "backup missing asset", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","backupSizeInMB":1}`, wantStatus: 400}, {name: "backup blank asset", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":" ","backupSizeInMB":1}`, wantStatus: 400}, {name: "backup wrong asset type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":1,"backupSizeInMB":1}`, wantStatus: 400}, {name: "backup wrong extra type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":1,"extra":{}}`, wantStatus: 400}, {name: "update negative time", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a","time":-1}`, wantStatus: 400}, {name: "update missing host", path: "/template/notify/update", contentType: "application/json", body: `{"asset":"a","time":1}`, wantStatus: 400}, {name: "update blank host", path: "/template/notify/update", contentType: "application/json", body: `{"host":" ","asset":"a","time":1}`, wantStatus: 400}, {name: "update wrong host type", path: "/template/notify/update", contentType: "application/json", body: `{"host":1,"asset":"a","time":1}`, wantStatus: 400}, {name: "update missing asset", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","time":1}`, wantStatus: 400}, {name: "update blank asset", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":" ","time":1}`, wantStatus: 400}, {name: "update wrong asset type", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":false,"time":1}`, wantStatus: 400}, {name: "update missing time", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a"}`, wantStatus: 400}, {name: "update wrong time type", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a","time":"soon"}`, wantStatus: 400}, {name: "error missing critical", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m"}`, wantStatus: 400}, {name: "error wrong critical type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":"yes"}`, wantStatus: 400}, {name: "error missing caller", path: "/template/notify/error", contentType: "application/json", body: `{"message":"m","critical":false}`, wantStatus: 400}, {name: "error blank caller", path: "/template/notify/error", contentType: "application/json", body: `{"caller":" ","message":"m","critical":false}`, wantStatus: 400}, {name: "error wrong caller type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":1,"message":"m","critical":false}`, wantStatus: 400}, {name: "error missing message", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","critical":false}`, wantStatus: 400}, {name: "error blank message", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":" ","critical":false}`, wantStatus: 400}, {name: "error wrong message type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":[],"critical":false}`, wantStatus: 400}, {name: "invalid extra", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"n"}]}`, wantStatus: 400}, {name: "null extra", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":null}`, wantStatus: 400}, {name: "duplicate nested known key", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"one","NAME":"two","value":"v"}]}`, wantStatus: 400}, {name: "case insensitive nested fields", path: "/template/notify/error", contentType: "application/json", body: `{"CALLER":"c","MESSAGE":"m","CRITICAL":false,"EXTRA":[{"NAME":"n","VALUE":"v"}]}`, wantStatus: 200}, {name: "unknown nested field accepted", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"n","value":"v","future":true}]}`, wantStatus: 200}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { recorder := &webhookRecorder{} got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, test.contentType, test.body) if got.Code != test.wantStatus { t.Fatalf("status = %d, body = %q, want %d", got.Code, got.Body.String(), test.wantStatus) } wantBody := map[int]string{ http.StatusOK: "Notification sent", http.StatusBadRequest: "Invalid payload", http.StatusUnsupportedMediaType: "Content-Type must be application/json", }[test.wantStatus] if got.Body.String() != wantBody { t.Fatalf("body = %q, want %q", got.Body.String(), wantBody) } if got.Header().Get("Content-Type") != "text/plain; charset=utf-8" { t.Fatalf("Content-Type = %q", got.Header().Get("Content-Type")) } wantCalls := 0 if test.wantStatus == http.StatusOK { wantCalls = 1 } if len(recorder.bodies()) != wantCalls { t.Fatalf("webhook calls = %d, want %d", len(recorder.bodies()), wantCalls) } }) } } func TestRequestBodyLimit(t *testing.T) { prefix, suffix := `{"title":"t","message":"`, `"}` for _, test := range []struct { name string bodyBytes int want int wantCalls int }{ {name: "exactly at limit", bodyBytes: maxRequestBody, want: http.StatusOK, wantCalls: 1}, {name: "one byte over limit", bodyBytes: maxRequestBody + 1, want: http.StatusBadRequest}, } { t.Run(test.name, func(t *testing.T) { recorder := &webhookRecorder{} body := prefix + strings.Repeat("x", test.bodyBytes-len(prefix)-len(suffix)) + suffix if len(body) != test.bodyBytes { t.Fatalf("body length = %d, want %d", len(body), test.bodyBytes) } got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, "/notify", "application/json", body) if got.Code != test.want { t.Fatalf("status = %d, want %d", got.Code, test.want) } if len(recorder.bodies()) != test.wantCalls { t.Fatalf("webhook calls = %d, want %d", len(recorder.bodies()), test.wantCalls) } }) } } func TestNotificationPayloads(t *testing.T) { tests := []struct { name string path string body string check func(*testing.T, discordPayload) }{ { name: "plain preserves unicode and quotes", path: "/notify", body: `{"title":"Héllo \"world\"","message":"line 1\nline 2 @everyone"}`, check: func(t *testing.T, payload discordPayload) { if payload.Content != "**Héllo \"world\"**\nline 1\nline 2 @everyone" { t.Fatalf("content = %q", payload.Content) } }, }, { name: "backup formats gibibytes and extras", path: "/template/notify/backup", body: `{"title":"Nightly","asset":"photos","backupSizeInMB":1536,"extra":[{"name":"Host","value":"nebula"}]}`, check: func(t *testing.T, payload discordPayload) { embed := payload.Embeds[0] if embed.Title != "📦 Backup - Nightly" || embed.Fields[0].Value != "1.50 GiB" || embed.Fields[1].Value != "nebula" { t.Fatalf("unexpected backup embed: %+v", embed) } }, }, { name: "update accepts zero time", path: "/template/notify/update", body: `{"host":"nexus","asset":"k3s","time":0}`, check: func(t *testing.T, payload discordPayload) { if payload.Embeds[0].Fields[0].Value != "0 seconds" { t.Fatalf("time = %q", payload.Embeds[0].Fields[0].Value) } }, }, { name: "error does not HTML escape", path: "/template/notify/error", body: `{"caller":"backup","message":"line 1\n\"quoted\" ","critical":true}`, check: func(t *testing.T, payload discordPayload) { embed := payload.Embeds[0] if embed.Color != 15158332 || embed.Fields[0].Value != "line 1\n\"quoted\" " { t.Fatalf("unexpected error embed: %+v", embed) } }, }, { name: "noncritical color", path: "/template/notify/error", body: `{"caller":"backup","message":"failure","critical":false}`, check: func(t *testing.T, payload discordPayload) { if payload.Embeds[0].Color != 15844367 { t.Fatalf("color = %d", payload.Embeds[0].Color) } }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { recorder := &webhookRecorder{} got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, "application/json", test.body) if got.Code != http.StatusOK || got.Body.String() != "Notification sent" { t.Fatalf("response = (%d, %q)", got.Code, got.Body.String()) } bodies := recorder.bodies() if len(bodies) != 1 || !json.Valid(bodies[0]) { t.Fatalf("webhook bodies = %q", bodies) } var payload discordPayload if err := json.Unmarshal(bodies[0], &payload); err != nil { t.Fatal(err) } if payload.AllowedMentions.Parse == nil || len(payload.AllowedMentions.Parse) != 0 { t.Fatalf("allowed mentions = %#v", payload.AllowedMentions) } test.check(t, payload) }) } } func TestHandlerMapsWebhookErrorsAndDoesNotLogSecrets(t *testing.T) { tests := []struct { name string err error wantStatus int wantBody string }{ {name: "delivery", err: errors.New("network down"), wantStatus: 502, wantBody: "Failed to send notification"}, {name: "timeout", err: context.DeadlineExceeded, wantStatus: 504, wantBody: "Webhook timed out"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var logs bytes.Buffer transport := roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, test.err }) app := testApplication(t, transport, log.New(&logs, "", 0)) got := performRequest(app.handler(), http.MethodPost, "/notify", "application/json", `{"title":"TOP-SECRET","message":"private-message"}`) if got.Code != test.wantStatus || got.Body.String() != test.wantBody { t.Fatalf("response = (%d, %q)", got.Code, got.Body.String()) } for _, secret := range []string{"TOP-SECRET", "private-message", "discord.invalid"} { if strings.Contains(logs.String(), secret) { t.Fatalf("logs exposed %q: %s", secret, logs.String()) } } }) } } func TestConfigurationAndServerLifecycle(t *testing.T) { for _, webhookURL := range []string{"", "not a URL", "ftp://example.com/hook", "https:///missing-host"} { if _, err := newApplication(applicationConfig{WebhookURL: webhookURL}); err == nil { t.Fatalf("newApplication(%q) succeeded", webhookURL) } } app := testApplication(t, nil, nil) server := newHTTPServer(app.handler()) if server.ReadHeaderTimeout != 5*time.Second || server.ReadTimeout != 10*time.Second || server.WriteTimeout != 15*time.Second || server.IdleTimeout != 60*time.Second { t.Fatalf("unexpected server timeouts: %+v", server) } listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- serve(ctx, server, listener) }() cancel() select { case err := <-done: if !errors.Is(err, http.ErrServerClosed) { t.Fatalf("serve() error = %v", err) } case <-time.After(2 * time.Second): t.Fatal("server did not shut down") } } func TestRunLifecycleAndListenError(t *testing.T) { if err := run(context.Background(), "", "127.0.0.1:0", log.New(io.Discard, "", 0)); err == nil { t.Fatal("run() accepted missing configuration") } originalListen := netListen netListen = func(string, string) (net.Listener, error) { return nil, errors.New("listen failed") } if err := run(context.Background(), "http://discord.invalid/hook", "bad", log.New(io.Discard, "", 0)); err == nil || !strings.Contains(err.Error(), "listen failed") { t.Fatalf("run() error = %v", err) } netListen = originalListen ctx, cancel := context.WithCancel(context.Background()) cancel() if err := run(ctx, "http://discord.invalid/hook", "127.0.0.1:0", log.New(io.Discard, "", 0)); !errors.Is(err, http.ErrServerClosed) { t.Fatalf("run() error = %v", err) } } func TestTemplateHandlerRenderFailures(t *testing.T) { app := testApplication(t, nil, nil) app.renderer = &templateRenderer{templates: map[string]*template.Template{}} tests := []struct { path string body string }{ {path: "/template/notify/backup", body: `{"title":"t","asset":"a","backupSizeInMB":1}`}, {path: "/template/notify/update", body: `{"host":"h","asset":"a","time":1}`}, {path: "/template/notify/error", body: `{"caller":"c","message":"m","critical":false}`}, } for _, test := range tests { got := performRequest(app.handler(), http.MethodPost, test.path, "application/json", test.body) if got.Code != http.StatusInternalServerError || got.Body.String() != "Failed to render notification" { t.Fatalf("%s response = (%d, %q)", test.path, got.Code, got.Body.String()) } } } func FuzzErrorNotificationHandler(f *testing.F) { seeds := []string{ `{"caller":"seed","message":"ok","critical":false}`, `{"caller":"quotes","message":"line\n\"quoted\" ","critical":true}`, `{}`, `not-json`, } for _, seed := range seeds { f.Add([]byte(seed)) } transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { body, err := io.ReadAll(request.Body) if err != nil || !json.Valid(body) { return nil, errors.New("invalid outgoing JSON") } return response(http.StatusNoContent, nil), nil }) handler := testApplication(f, transport, nil).handler() f.Fuzz(func(t *testing.T, body []byte) { request := httptest.NewRequest(http.MethodPost, "/template/notify/error", bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code < 200 || recorder.Code > 599 { t.Fatalf("invalid status %d", recorder.Code) } }) } func response(status int, headers http.Header) *http.Response { if headers == nil { headers = make(http.Header) } return &http.Response{StatusCode: status, Header: headers, Body: io.NopCloser(strings.NewReader(""))} }