adding api/v2 endpoints
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
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
This commit is contained in:
386
haven-notify/v2_test.go
Normal file
386
haven-notify/v2_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const v2TestTimestamp = "2026-07-16T12:34:56Z"
|
||||
|
||||
func TestV2NotificationPayloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
want discordPayload
|
||||
}{
|
||||
{
|
||||
name: "generic message",
|
||||
path: "/api/v2/messages",
|
||||
body: `{"title":"Maintenance","message":"The storage job completed.","source":"scheduler","occurredAt":"2026-07-16T09:34:56-03:00","extra":[{"name":"Region","value":"home"},{"name":"Details","value":"first line\nsecond line"}]}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "💬 Maintenance",
|
||||
Description: "The storage job completed.",
|
||||
Color: v2ColorBrand,
|
||||
Timestamp: v2TestTimestamp,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "Source", Value: "scheduler", Inline: true},
|
||||
{Name: "Region", Value: "home", Inline: true},
|
||||
{Name: "Details", Value: "first line\nsecond line", Inline: false},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "Haven Notify"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backup",
|
||||
path: "/api/v2/notifications/backup",
|
||||
body: `{"title":"Nightly backup","asset":"Photos","sizeBytes":1610612736,"durationSeconds":125,"source":"restic","extra":[{"name":"Host","value":"nebula"}]}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "📦 Nightly backup",
|
||||
Description: "**Photos** was backed up successfully.",
|
||||
Color: v2ColorSuccess,
|
||||
Timestamp: v2TestTimestamp,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "Size", Value: "1.50 GiB", Inline: true},
|
||||
{Name: "Duration", Value: "2m 5s", Inline: true},
|
||||
{Name: "Source", Value: "restic", Inline: true},
|
||||
{Name: "Host", Value: "nebula", Inline: true},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "Haven Notify"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
path: "/api/v2/notifications/update",
|
||||
body: `{"host":"nexus","asset":"k3s","durationSeconds":42.5,"fromVersion":"1.32.5","toVersion":"1.33.1","source":"system-updater"}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "🔄 Update completed",
|
||||
Description: "**k3s** was updated on **nexus**.",
|
||||
Color: v2ColorUpdate,
|
||||
Timestamp: v2TestTimestamp,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "Version", Value: "1.32.5 → 1.33.1", Inline: true},
|
||||
{Name: "Duration", Value: "42.5s", Inline: true},
|
||||
{Name: "Source", Value: "system-updater", Inline: true},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "Haven Notify"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "critical error",
|
||||
path: "/api/v2/notifications/error",
|
||||
body: `{"source":"backup-job","message":"The repository is unavailable.","severity":"critical","errorCode":"E_REPOSITORY","extra":[{"name":"Path","value":"/mnt/backups"}]}`,
|
||||
want: discordPayload{
|
||||
Embeds: []discordEmbed{{
|
||||
Title: "🛑 Critical error",
|
||||
Description: "The repository is unavailable.",
|
||||
Color: v2ColorCritical,
|
||||
Timestamp: v2TestTimestamp,
|
||||
Fields: []discordEmbedField{
|
||||
{Name: "Source", Value: "backup-job", Inline: true},
|
||||
{Name: "Error code", Value: "E_REPOSITORY", Inline: true},
|
||||
{Name: "Path", Value: "/mnt/backups", Inline: true},
|
||||
},
|
||||
Footer: &discordEmbedFooter{Text: "Haven Notify"},
|
||||
}},
|
||||
AllowedMentions: discordAllowedMentions{Parse: []string{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := sendV2Request(t, test.path, test.body)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(test.want, "", " ")
|
||||
t.Fatalf("payload = %s\nwant = %s", gotJSON, wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2DefaultsAndVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
wantTitle string
|
||||
wantColor int
|
||||
wantFields []discordEmbedField
|
||||
}{
|
||||
{
|
||||
name: "message default title",
|
||||
path: "/api/v2/messages",
|
||||
body: `{"message":"hello"}`,
|
||||
wantTitle: "💬 Message",
|
||||
wantColor: v2ColorBrand,
|
||||
},
|
||||
{
|
||||
name: "backup defaults",
|
||||
path: "/api/v2/notifications/backup",
|
||||
body: `{"asset":"config","sizeBytes":0}`,
|
||||
wantTitle: "📦 Backup completed",
|
||||
wantColor: v2ColorSuccess,
|
||||
wantFields: []discordEmbedField{
|
||||
{Name: "Size", Value: "0 B", Inline: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update previous version",
|
||||
path: "/api/v2/notifications/update",
|
||||
body: `{"host":"nexus","asset":"k3s","durationSeconds":60,"fromVersion":"1.0"}`,
|
||||
wantTitle: "🔄 Update completed",
|
||||
wantColor: v2ColorUpdate,
|
||||
wantFields: []discordEmbedField{
|
||||
{Name: "Previous version", Value: "1.0", Inline: true},
|
||||
{Name: "Duration", Value: "1m", Inline: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update installed version",
|
||||
path: "/api/v2/notifications/update",
|
||||
body: `{"host":"nexus","asset":"k3s","durationSeconds":0,"toVersion":"1.1"}`,
|
||||
wantTitle: "🔄 Update completed",
|
||||
wantColor: v2ColorUpdate,
|
||||
wantFields: []discordEmbedField{
|
||||
{Name: "Installed version", Value: "1.1", Inline: true},
|
||||
{Name: "Duration", Value: "0s", Inline: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "warning",
|
||||
path: "/api/v2/notifications/error",
|
||||
body: `{"source":"job","message":"slow","severity":"warning"}`,
|
||||
wantTitle: "⚠️ Warning",
|
||||
wantColor: v2ColorWarning,
|
||||
wantFields: []discordEmbedField{
|
||||
{Name: "Source", Value: "job", Inline: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
path: "/api/v2/notifications/error",
|
||||
body: `{"source":"job","message":"failed","severity":"error"}`,
|
||||
wantTitle: "❌ Error",
|
||||
wantColor: v2ColorError,
|
||||
wantFields: []discordEmbedField{
|
||||
{Name: "Source", Value: "job", Inline: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload := sendV2Request(t, test.path, test.body)
|
||||
embed := payload.Embeds[0]
|
||||
if embed.Title != test.wantTitle || embed.Color != test.wantColor || embed.Timestamp != v2TestTimestamp || !reflect.DeepEqual(embed.Fields, test.wantFields) {
|
||||
t.Fatalf("embed = %+v", embed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2RequestValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "message missing", path: "/api/v2/messages", body: `{}`, wantStatus: 400},
|
||||
{name: "message blank", path: "/api/v2/messages", body: `{"message":" "}`, wantStatus: 400},
|
||||
{name: "blank optional title", path: "/api/v2/messages", body: `{"message":"m","title":" "}`, wantStatus: 400},
|
||||
{name: "blank optional source", path: "/api/v2/messages", body: `{"message":"m","source":" "}`, wantStatus: 400},
|
||||
{name: "invalid timestamp", path: "/api/v2/messages", body: `{"message":"m","occurredAt":"yesterday"}`, wantStatus: 400},
|
||||
{name: "blank timestamp", path: "/api/v2/messages", body: `{"message":"m","occurredAt":" "}`, wantStatus: 400},
|
||||
{name: "null extras", path: "/api/v2/messages", body: `{"message":"m","extra":null}`, wantStatus: 400},
|
||||
{name: "invalid extra", path: "/api/v2/messages", body: `{"message":"m","extra":[{"name":"n"}]}`, wantStatus: 400},
|
||||
{name: "duplicate common key", path: "/api/v2/messages", body: `{"message":"m","source":"one","SOURCE":"two"}`, wantStatus: 400},
|
||||
{name: "duplicate nested key", path: "/api/v2/messages", body: `{"message":"m","extra":[{"name":"one","NAME":"two","value":"v"}]}`, wantStatus: 400},
|
||||
{name: "backup missing asset", path: "/api/v2/notifications/backup", body: `{"sizeBytes":1}`, wantStatus: 400},
|
||||
{name: "backup missing size", path: "/api/v2/notifications/backup", body: `{"asset":"a"}`, wantStatus: 400},
|
||||
{name: "backup negative size", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":-1}`, wantStatus: 400},
|
||||
{name: "backup fractional size", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":1.5}`, wantStatus: 400},
|
||||
{name: "backup negative duration", path: "/api/v2/notifications/backup", body: `{"asset":"a","sizeBytes":1,"durationSeconds":-1}`, wantStatus: 400},
|
||||
{name: "update missing host", path: "/api/v2/notifications/update", body: `{"asset":"a","durationSeconds":1}`, wantStatus: 400},
|
||||
{name: "update missing asset", path: "/api/v2/notifications/update", body: `{"host":"h","durationSeconds":1}`, wantStatus: 400},
|
||||
{name: "update missing duration", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a"}`, wantStatus: 400},
|
||||
{name: "update negative duration", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a","durationSeconds":-1}`, wantStatus: 400},
|
||||
{name: "update blank version", path: "/api/v2/notifications/update", body: `{"host":"h","asset":"a","durationSeconds":1,"fromVersion":" "}`, wantStatus: 400},
|
||||
{name: "error missing source", path: "/api/v2/notifications/error", body: `{"message":"m","severity":"error"}`, wantStatus: 400},
|
||||
{name: "error missing message", path: "/api/v2/notifications/error", body: `{"source":"s","severity":"error"}`, wantStatus: 400},
|
||||
{name: "error missing severity", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m"}`, wantStatus: 400},
|
||||
{name: "error invalid severity", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m","severity":"ERROR"}`, wantStatus: 400},
|
||||
{name: "error blank code", path: "/api/v2/notifications/error", body: `{"source":"s","message":"m","severity":"error","errorCode":" "}`, wantStatus: 400},
|
||||
{name: "wrong media type", path: "/api/v2/messages", contentType: "text/plain", body: `{"message":"m"}`, wantStatus: 415},
|
||||
{name: "unknown accepted", path: "/api/v2/messages", body: `{"message":"m","future":true}`, wantStatus: 200},
|
||||
{name: "case insensitive accepted", path: "/api/v2/messages", body: `{"MESSAGE":"m","EXTRA":[{"NAME":"n","VALUE":"v"}]}`, wantStatus: 200},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
contentType := test.contentType
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
recorder := &webhookRecorder{}
|
||||
response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, contentType, test.body)
|
||||
if response.Code != test.wantStatus {
|
||||
t.Fatalf("response = (%d, %q), want %d", response.Code, response.Body.String(), test.wantStatus)
|
||||
}
|
||||
wantCalls := 0
|
||||
if test.wantStatus == http.StatusOK {
|
||||
wantCalls = 1
|
||||
}
|
||||
if calls := len(recorder.bodies()); calls != wantCalls {
|
||||
t.Fatalf("webhook calls = %d, want %d", calls, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2RoutingContracts(t *testing.T) {
|
||||
handler := testApplication(t, nil, nil).handler()
|
||||
for _, path := range []string{
|
||||
"/api/v2/messages",
|
||||
"/api/v2/notifications/backup",
|
||||
"/api/v2/notifications/update",
|
||||
"/api/v2/notifications/error",
|
||||
} {
|
||||
response := performRequest(handler, http.MethodGet, path, "", "")
|
||||
if response.Code != http.StatusMethodNotAllowed || response.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatalf("GET %s = (%d, Allow %q)", path, response.Code, response.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
response := performRequest(handler, http.MethodPost, "/api/v2/messages/", "application/json", `{"message":"m"}`)
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("trailing slash status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2RequestBodyLimit(t *testing.T) {
|
||||
prefix, suffix := `{"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
|
||||
response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, "/api/v2/messages", "application/json", body)
|
||||
if response.Code != test.want {
|
||||
t.Fatalf("response status = %d, want %d", response.Code, test.want)
|
||||
}
|
||||
if calls := len(recorder.bodies()); calls != test.wantCalls {
|
||||
t.Fatalf("webhook calls = %d, want %d", calls, test.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ExtrasRespectDiscordLimits(t *testing.T) {
|
||||
extra := make([]string, 30)
|
||||
for index := range extra {
|
||||
extra[index] = fmt.Sprintf(`{"name":"field-%02d","value":"value"}`, index)
|
||||
}
|
||||
body := fmt.Sprintf(`{"host":"nexus","asset":"k3s","durationSeconds":1,"fromVersion":"1","toVersion":"2","source":"updater","extra":[%s]}`, strings.Join(extra, ","))
|
||||
payload := sendV2Request(t, "/api/v2/notifications/update", body)
|
||||
fields := payload.Embeds[0].Fields
|
||||
if len(fields) != 25 {
|
||||
t.Fatalf("fields = %d, want 25", len(fields))
|
||||
}
|
||||
if fields[0].Name != "Version" || fields[1].Name != "Duration" || fields[2].Name != "Source" || fields[24].Name != "field-21" {
|
||||
t.Fatalf("field order = %+v", fields)
|
||||
}
|
||||
|
||||
longValue := strings.Repeat("x", v2InlineFieldLimit+1)
|
||||
body = fmt.Sprintf(`{"message":"m","source":"source","extra":[{"name":"long","value":%q}]}`, longValue)
|
||||
payload = sendV2Request(t, "/api/v2/messages", body)
|
||||
if payload.Embeds[0].Fields[0].Name != "Source" || payload.Embeds[0].Fields[1].Inline {
|
||||
t.Fatalf("inline fields = %+v", payload.Embeds[0].Fields)
|
||||
}
|
||||
|
||||
body = fmt.Sprintf(`{"message":%q,"source":"source","extra":[{"name":"large","value":%q}]}`, strings.Repeat("m", 5000), strings.Repeat("x", 2000))
|
||||
payload = sendV2Request(t, "/api/v2/messages", body)
|
||||
embed := payload.Embeds[0]
|
||||
if utf8.RuneCountInString(embed.Description) > 4096 || discordEmbedCharacters(payload.Embeds) > 6000 || len(embed.Fields) == 0 || embed.Fields[0].Name != "Source" {
|
||||
t.Fatalf("normalized embed = %+v, characters = %d", embed, discordEmbedCharacters(payload.Embeds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2FormattingHelpers(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{1023, "1023 B"},
|
||||
{1024, "1.00 KiB"},
|
||||
{1536, "1.50 KiB"},
|
||||
{1 << 30, "1.00 GiB"},
|
||||
} {
|
||||
if got := formatBytes(test.bytes); got != test.want {
|
||||
t.Errorf("formatBytes(%d) = %q, want %q", test.bytes, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
seconds float64
|
||||
want string
|
||||
}{
|
||||
{0, "0s"},
|
||||
{42.5, "42.5s"},
|
||||
{60, "1m"},
|
||||
{125, "2m 5s"},
|
||||
{125.1, "2m 5.1s"},
|
||||
{3723, "1h 2m 3s"},
|
||||
} {
|
||||
if got := formatV2Duration(test.seconds); got != test.want {
|
||||
t.Errorf("formatV2Duration(%v) = %q, want %q", test.seconds, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, value := range []*float64{nil, floatPointer(-1), floatPointer(math.NaN()), floatPointer(math.Inf(1))} {
|
||||
if validNumber(value) {
|
||||
t.Fatalf("validNumber(%v) = true", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendV2Request(t *testing.T, path, body string) discordPayload {
|
||||
t.Helper()
|
||||
recorder := &webhookRecorder{}
|
||||
response := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, path, "application/json", body)
|
||||
if response.Code != http.StatusOK || response.Body.String() != "Notification sent" {
|
||||
t.Fatalf("response = (%d, %q)", response.Code, response.Body.String())
|
||||
}
|
||||
bodies := recorder.bodies()
|
||||
if len(bodies) != 1 {
|
||||
t.Fatalf("webhook calls = %d, want 1", len(bodies))
|
||||
}
|
||||
var payload discordPayload
|
||||
if err := json.Unmarshal(bodies[0], &payload); err != nil {
|
||||
t.Fatalf("decode webhook payload: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user