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:
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
306
haven-notify/v2.go
Normal file
306
haven-notify/v2.go
Normal file
@@ -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"), ".")
|
||||
}
|
||||
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