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

This commit is contained in:
2026-07-16 09:51:53 -03:00
parent f202be458a
commit 8db1db888f
6 changed files with 811 additions and 5 deletions

386
haven-notify/v2_test.go Normal file
View 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
}