From 8db1db888f7991c4ac05c613a32ca978861e2e12 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Thu, 16 Jul 2026 09:51:53 -0300 Subject: [PATCH] adding api/v2 endpoints --- haven-notify/README.md | 110 +++++++++- haven-notify/discord.go | 1 + haven-notify/server.go | 12 +- haven-notify/server_test.go | 1 + haven-notify/v2.go | 306 ++++++++++++++++++++++++++++ haven-notify/v2_test.go | 386 ++++++++++++++++++++++++++++++++++++ 6 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 haven-notify/v2.go create mode 100644 haven-notify/v2_test.go diff --git a/haven-notify/README.md b/haven-notify/README.md index 7142686..4785f05 100644 --- a/haven-notify/README.md +++ b/haven-notify/README.md @@ -20,7 +20,11 @@ It runs as a standalone binary or container. Templates are embedded in the binar All notification endpoints require `POST` and `Content-Type: application/json`. Request bodies are limited to 64 KiB, documented fields are required, and unknown fields are accepted for forward compatibility. -### Send Notification +### V1 API + +The original routes, request bodies, and Discord message appearance remain unchanged. + +#### Send Notification - **Endpoint**: `/notify` - **Request Body**: @@ -32,7 +36,7 @@ All notification endpoints require `POST` and `Content-Type: application/json`. } ``` -### Send Backup Notification +#### Send Backup Notification - **Endpoint**: `/template/notify/backup` - **Request Body**: @@ -51,7 +55,7 @@ All notification endpoints require `POST` and `Content-Type: application/json`. } ``` -### Send Update Notification +#### Send Update Notification - **Endpoint**: `/template/notify/update` - **Request Body**: @@ -66,7 +70,7 @@ All notification endpoints require `POST` and `Content-Type: application/json`. `time` is expressed in seconds. -### Send Error Notification +#### Send Error Notification - **Endpoint**: `/template/notify/error` - **Request Body**: @@ -85,6 +89,104 @@ All notification endpoints require `POST` and `Content-Type: application/json`. } ``` +### V2 API + +V2 notifications use compact, consistently styled Discord embeds. Colors, icons, timestamps, field ordering, and layout are controlled by Haven Notify rather than the caller. + +Every v2 request may include: + +- `source`: a nonblank string identifying the caller. +- `occurredAt`: an RFC3339 timestamp. When omitted, Haven Notify uses the current UTC time. +- `extra`: an array of nonblank `name` and `value` strings. Short single-line values render inline; multiline or long values render full-width. + +V2 preserves caller field order. Fixed notification fields are placed before `extra` fields so that trailing extras are discarded first if Discord's 25-field or 6,000-character embed limits are reached. Oversized text is safely truncated and extra fields may be omitted from the delivered message. + +#### Send a Message + +- **Endpoint**: `/api/v2/messages` +- **Required**: `message` +- **Optional**: `title`, `source`, `occurredAt`, `extra` + +```json +{ + "title": "Maintenance complete", + "message": "The storage job completed successfully.", + "source": "scheduler", + "extra": [ + { + "name": "Region", + "value": "home" + } + ] +} +``` + +#### Send a Backup Notification + +- **Endpoint**: `/api/v2/notifications/backup` +- **Required**: `asset`, nonnegative integer `sizeBytes` +- **Optional**: `title`, `source`, nonnegative `durationSeconds`, `occurredAt`, `extra` + +```json +{ + "title": "Nightly backup", + "asset": "Photos", + "sizeBytes": 1610612736, + "durationSeconds": 125, + "source": "restic", + "extra": [ + { + "name": "Host", + "value": "nebula" + } + ] +} +``` + +`sizeBytes` is displayed using IEC units such as KiB, MiB, and GiB. Durations use a compact form such as `42.5s`, `2m 5s`, or `1h 2m 3s`. + +#### Send an Update Notification + +- **Endpoint**: `/api/v2/notifications/update` +- **Required**: `host`, `asset`, nonnegative `durationSeconds` +- **Optional**: `source`, `fromVersion`, `toVersion`, `occurredAt`, `extra` + +```json +{ + "host": "nexus", + "asset": "k3s", + "durationSeconds": 42.5, + "fromVersion": "1.32.5", + "toVersion": "1.33.1", + "source": "system-updater" +} +``` + +When both versions are supplied, the embed displays a transition such as `1.32.5 → 1.33.1`. A single supplied version is labeled as the previous or installed version. + +#### Send an Error Notification + +- **Endpoint**: `/api/v2/notifications/error` +- **Required**: `source`, `message`, `severity` +- **Optional**: `errorCode`, `occurredAt`, `extra` + +```json +{ + "source": "backup-job", + "message": "The repository is unavailable.", + "severity": "critical", + "errorCode": "E_REPOSITORY", + "extra": [ + { + "name": "Path", + "value": "/mnt/backups" + } + ] +} +``` + +`severity` must be `warning`, `error`, or `critical`; it selects the embed title, icon, and color. + ### Health Probes - `GET` or `HEAD /ready` returns `200 Ready` after configuration and templates initialize. diff --git a/haven-notify/discord.go b/haven-notify/discord.go index 4c50c55..36d9c5b 100644 --- a/haven-notify/discord.go +++ b/haven-notify/discord.go @@ -51,6 +51,7 @@ type discordEmbed struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Color int `json:"color,omitempty"` + Timestamp string `json:"timestamp,omitempty"` Fields []discordEmbedField `json:"fields,omitempty"` Footer *discordEmbedFooter `json:"footer,omitempty"` } diff --git a/haven-notify/server.go b/haven-notify/server.go index d354e3f..cbab093 100644 --- a/haven-notify/server.go +++ b/haven-notify/server.go @@ -32,12 +32,14 @@ type applicationConfig struct { HTTPClient *http.Client Sleep sleepFunc Logger *log.Logger + Now func() time.Time } type application struct { discord *discordClient renderer *templateRenderer logger *log.Logger + now func() time.Time } type notificationRequest struct { @@ -77,6 +79,10 @@ func newApplication(cfg applicationConfig) (*application, error) { if logger == nil { logger = log.New(io.Discard, "", 0) } + now := cfg.Now + if now == nil { + now = time.Now + } renderer, err := newTemplateRenderer() if err != nil { @@ -88,7 +94,7 @@ func newApplication(cfg applicationConfig) (*application, error) { return nil, err } - return &application{discord: discord, renderer: renderer, logger: logger}, nil + return &application{discord: discord, renderer: renderer, logger: logger, now: now}, nil } func (a *application) handler() http.Handler { @@ -97,6 +103,10 @@ func (a *application) handler() http.Handler { mux.HandleFunc("/template/notify/backup", a.requireMethod(http.MethodPost, a.backupHandler)) mux.HandleFunc("/template/notify/update", a.requireMethod(http.MethodPost, a.updateHandler)) mux.HandleFunc("/template/notify/error", a.requireMethod(http.MethodPost, a.errorHandler)) + mux.HandleFunc("/api/v2/messages", a.requireMethod(http.MethodPost, a.v2MessageHandler)) + mux.HandleFunc("/api/v2/notifications/backup", a.requireMethod(http.MethodPost, a.v2BackupHandler)) + mux.HandleFunc("/api/v2/notifications/update", a.requireMethod(http.MethodPost, a.v2UpdateHandler)) + mux.HandleFunc("/api/v2/notifications/error", a.requireMethod(http.MethodPost, a.v2ErrorHandler)) mux.HandleFunc("/ready", a.requireProbeMethod(a.readinessHandler)) mux.HandleFunc("/live", a.requireProbeMethod(a.livenessHandler)) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { diff --git a/haven-notify/server_test.go b/haven-notify/server_test.go index 809da82..2fbe40e 100644 --- a/haven-notify/server_test.go +++ b/haven-notify/server_test.go @@ -61,6 +61,7 @@ func testApplication(t testing.TB, transport http.RoundTripper, logger *log.Logg 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) diff --git a/haven-notify/v2.go b/haven-notify/v2.go new file mode 100644 index 0000000..9994d22 --- /dev/null +++ b/haven-notify/v2.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + v2ColorBrand = 0x5865F2 + v2ColorSuccess = 0x57F287 + v2ColorUpdate = 0x3498DB + v2ColorWarning = 0xFEE75C + v2ColorError = 0xED4245 + v2ColorCritical = 0x992D22 + + v2InlineFieldLimit = 80 +) + +type v2CommonRequest struct { + Source *string `json:"source"` + OccurredAt *string `json:"occurredAt"` + Extra extraRequests `json:"extra"` +} + +type v2MessageRequest struct { + v2CommonRequest + Title *string `json:"title"` + Message *string `json:"message"` +} + +type v2BackupRequest struct { + v2CommonRequest + Title *string `json:"title"` + Asset *string `json:"asset"` + SizeBytes *int64 `json:"sizeBytes"` + DurationSeconds *float64 `json:"durationSeconds"` +} + +type v2UpdateRequest struct { + v2CommonRequest + Host *string `json:"host"` + Asset *string `json:"asset"` + DurationSeconds *float64 `json:"durationSeconds"` + FromVersion *string `json:"fromVersion"` + ToVersion *string `json:"toVersion"` +} + +type v2ErrorRequest struct { + v2CommonRequest + Message *string `json:"message"` + Severity *string `json:"severity"` + ErrorCode *string `json:"errorCode"` +} + +func (a *application) v2MessageHandler(w http.ResponseWriter, r *http.Request) { + var request v2MessageRequest + if err := decodeRequest(w, r, &request, "title", "message", "source", "occurredat", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + when, valid := a.validV2Common(request.v2CommonRequest) + if !valid || !requiredString(request.Message) || !validOptionalString(request.Title) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + if !a.send(w, r, buildV2MessagePayload(request, when)) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) v2BackupHandler(w http.ResponseWriter, r *http.Request) { + var request v2BackupRequest + if err := decodeRequest(w, r, &request, "title", "asset", "sizebytes", "durationseconds", "source", "occurredat", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + when, valid := a.validV2Common(request.v2CommonRequest) + if !valid || !requiredString(request.Asset) || !validOptionalString(request.Title) || request.SizeBytes == nil || *request.SizeBytes < 0 || !validOptionalNumber(request.DurationSeconds) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + if !a.send(w, r, buildV2BackupPayload(request, when)) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) v2UpdateHandler(w http.ResponseWriter, r *http.Request) { + var request v2UpdateRequest + if err := decodeRequest(w, r, &request, "host", "asset", "durationseconds", "fromversion", "toversion", "source", "occurredat", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + when, valid := a.validV2Common(request.v2CommonRequest) + if !valid || !requiredString(request.Host) || !requiredString(request.Asset) || !validNumber(request.DurationSeconds) || !validOptionalString(request.FromVersion) || !validOptionalString(request.ToVersion) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + if !a.send(w, r, buildV2UpdatePayload(request, when)) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) v2ErrorHandler(w http.ResponseWriter, r *http.Request) { + var request v2ErrorRequest + if err := decodeRequest(w, r, &request, "source", "message", "severity", "errorcode", "occurredat", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + when, valid := a.validV2Common(request.v2CommonRequest) + if !valid || !requiredString(request.Source) || !requiredString(request.Message) || !validV2Severity(request.Severity) || !validOptionalString(request.ErrorCode) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + if !a.send(w, r, buildV2ErrorPayload(request, when)) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) validV2Common(request v2CommonRequest) (time.Time, bool) { + if !validOptionalString(request.Source) || !validExtra(request.Extra) { + return time.Time{}, false + } + if request.OccurredAt == nil { + return a.now().UTC(), true + } + if !requiredString(request.OccurredAt) { + return time.Time{}, false + } + when, err := time.Parse(time.RFC3339, *request.OccurredAt) + if err != nil { + return time.Time{}, false + } + return when.UTC(), true +} + +func validOptionalString(value *string) bool { + return value == nil || requiredString(value) +} + +func validNumber(value *float64) bool { + return value != nil && *value >= 0 && !math.IsNaN(*value) && !math.IsInf(*value, 0) +} + +func validOptionalNumber(value *float64) bool { + return value == nil || validNumber(value) +} + +func validV2Severity(value *string) bool { + if value == nil { + return false + } + switch *value { + case "warning", "error", "critical": + return true + default: + return false + } +} + +func buildV2MessagePayload(request v2MessageRequest, when time.Time) discordPayload { + title := "Message" + if request.Title != nil { + title = *request.Title + } + embed := newV2Embed("💬 "+title, *request.Message, v2ColorBrand, when) + embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) + return discordPayload{Embeds: []discordEmbed{embed}} +} + +func buildV2BackupPayload(request v2BackupRequest, when time.Time) discordPayload { + title := "Backup completed" + if request.Title != nil { + title = *request.Title + } + embed := newV2Embed("📦 "+title, fmt.Sprintf("**%s** was backed up successfully.", *request.Asset), v2ColorSuccess, when) + embed.Fields = append(embed.Fields, v2Field("Size", formatBytes(*request.SizeBytes))) + if request.DurationSeconds != nil { + embed.Fields = append(embed.Fields, v2Field("Duration", formatV2Duration(*request.DurationSeconds))) + } + embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) + return discordPayload{Embeds: []discordEmbed{embed}} +} + +func buildV2UpdatePayload(request v2UpdateRequest, when time.Time) discordPayload { + embed := newV2Embed("🔄 Update completed", fmt.Sprintf("**%s** was updated on **%s**.", *request.Asset, *request.Host), v2ColorUpdate, when) + switch { + case request.FromVersion != nil && request.ToVersion != nil: + embed.Fields = append(embed.Fields, v2Field("Version", *request.FromVersion+" → "+*request.ToVersion)) + case request.FromVersion != nil: + embed.Fields = append(embed.Fields, v2Field("Previous version", *request.FromVersion)) + case request.ToVersion != nil: + embed.Fields = append(embed.Fields, v2Field("Installed version", *request.ToVersion)) + } + embed.Fields = append(embed.Fields, v2Field("Duration", formatV2Duration(*request.DurationSeconds))) + embed.Fields = appendV2SourceAndExtras(embed.Fields, request.Source, request.Extra) + return discordPayload{Embeds: []discordEmbed{embed}} +} + +func buildV2ErrorPayload(request v2ErrorRequest, when time.Time) discordPayload { + title, color := v2ErrorStyle(*request.Severity) + embed := newV2Embed(title, *request.Message, color, when) + embed.Fields = append(embed.Fields, v2Field("Source", *request.Source)) + if request.ErrorCode != nil { + embed.Fields = append(embed.Fields, v2Field("Error code", *request.ErrorCode)) + } + embed.Fields = appendV2Extras(embed.Fields, request.Extra) + return discordPayload{Embeds: []discordEmbed{embed}} +} + +func newV2Embed(title, description string, color int, when time.Time) discordEmbed { + return discordEmbed{ + Title: title, + Description: description, + Color: color, + Timestamp: when.UTC().Format(time.RFC3339), + Footer: &discordEmbedFooter{Text: "Haven Notify"}, + } +} + +func v2ErrorStyle(severity string) (string, int) { + switch severity { + case "warning": + return "⚠️ Warning", v2ColorWarning + case "critical": + return "🛑 Critical error", v2ColorCritical + default: + return "❌ Error", v2ColorError + } +} + +func appendV2SourceAndExtras(fields []discordEmbedField, source *string, extra extraRequests) []discordEmbedField { + if source != nil { + fields = append(fields, v2Field("Source", *source)) + } + return appendV2Extras(fields, extra) +} + +func appendV2Extras(fields []discordEmbedField, extra extraRequests) []discordEmbedField { + for _, field := range extra { + value := *field.Value + fields = append(fields, discordEmbedField{ + Name: *field.Name, + Value: value, + Inline: !strings.ContainsAny(value, "\r\n") && utf8.RuneCountInString(value) <= v2InlineFieldLimit, + }) + } + return fields +} + +func v2Field(name, value string) discordEmbedField { + return discordEmbedField{Name: name, Value: value, Inline: true} +} + +func formatBytes(bytes int64) string { + if bytes < 1024 { + return strconv.FormatInt(bytes, 10) + " B" + } + value := float64(bytes) + for _, unit := range []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} { + value /= 1024 + if value < 1024 || unit == "EiB" { + return fmt.Sprintf("%.2f %s", value, unit) + } + } + return strconv.FormatInt(bytes, 10) + " B" +} + +func formatV2Duration(seconds float64) string { + if seconds < 60 { + return formatV2Number(seconds) + "s" + } + + hours := math.Floor(seconds / 3600) + seconds -= hours * 3600 + minutes := math.Floor(seconds / 60) + seconds -= minutes * 60 + parts := make([]string, 0, 3) + if hours > 0 { + parts = append(parts, formatV2Number(hours)+"h") + } + if minutes > 0 { + parts = append(parts, formatV2Number(minutes)+"m") + } + if seconds > 0 { + parts = append(parts, formatV2Number(seconds)+"s") + } + return strings.Join(parts, " ") +} + +func formatV2Number(value float64) string { + formatted := strconv.FormatFloat(value, 'f', 3, 64) + return strings.TrimRight(strings.TrimRight(formatted, "0"), ".") +} diff --git a/haven-notify/v2_test.go b/haven-notify/v2_test.go new file mode 100644 index 0000000..c8e1bfd --- /dev/null +++ b/haven-notify/v2_test.go @@ -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 +}