All checks were successful
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Haven Notify Build and Deploy / Test Haven Notify (push) Successful in 46s
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (push) Has been skipped
Haven Notify Build and Deploy / Build Haven Notify Image (push) Successful in 20s
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (push) Successful in 3s
452 lines
21 KiB
Go
452 lines
21 KiB
Go
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\" <tag>","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\" <tag>" {
|
|
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\" <tag>","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(""))}
|
|
}
|