adding test containers
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled

This commit is contained in:
2026-07-16 09:14:51 -03:00
parent 0043c4ae00
commit b1cdf15cc8
19 changed files with 2390 additions and 298 deletions

450
haven-notify/server_test.go Normal file
View File

@@ -0,0 +1,450 @@
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,
})
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(""))}
}