feat: implement ntfy-discord bridge in Go
- SSE subscription to ntfy with auto-reconnect - Discord webhook integration with embed formatting - Priority to color mapping, tag to emoji conversion - Native HashiCorp Vault support (Kubernetes + token auth) - Hot reload secrets via fsnotify or Vault polling - Prometheus metrics (/metrics endpoint) - Health/ready endpoints for Kubernetes probes - Comprehensive unit tests and fuzz tests - Multi-stage Docker build (~10MB scratch image) - CI/CD pipeline for Gitea Actions
This commit is contained in:
125
internal/discord/fuzz_test.go
Normal file
125
internal/discord/fuzz_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
|
||||
)
|
||||
|
||||
// FuzzBuildEmbed tests that embed building doesn't panic on arbitrary input
|
||||
func FuzzBuildEmbed(f *testing.F) {
|
||||
// Seed with normal inputs
|
||||
f.Add("Test Title", "Test message body", 3, "warning", "test-topic", "https://example.com")
|
||||
f.Add("", "", 0, "", "", "")
|
||||
f.Add("Alert!", "Critical issue detected", 5, "fire", "alerts", "")
|
||||
|
||||
// Edge cases
|
||||
f.Add("x", "y", -1, "unknown", "t", "not-a-url")
|
||||
f.Add("x", "y", 100, "", "", "")
|
||||
f.Add("🔥 Fire Alert", "💀 Something broke", 5, "skull", "test", "")
|
||||
|
||||
// Long strings
|
||||
longStr := ""
|
||||
for i := 0; i < 10000; i++ {
|
||||
longStr += "x"
|
||||
}
|
||||
f.Add(longStr, longStr, 3, "tag", "topic", "https://example.com")
|
||||
|
||||
// Special characters
|
||||
f.Add("Title\x00with\x00nulls", "Message\nwith\nnewlines", 3, "tag", "topic", "")
|
||||
f.Add("<script>alert('xss')</script>", "```code```", 3, "", "", "")
|
||||
f.Add("Title\t\r\n", "Body\t\r\n", 3, "", "", "")
|
||||
|
||||
f.Fuzz(func(t *testing.T, title, message string, priority int, tag, topic, click string) {
|
||||
msg := ntfy.Message{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Priority: priority,
|
||||
Tags: []string{tag},
|
||||
Topic: topic,
|
||||
Click: click,
|
||||
Time: 1706803200,
|
||||
}
|
||||
|
||||
client := NewClient()
|
||||
|
||||
// Should never panic
|
||||
embed := client.buildEmbed(msg)
|
||||
|
||||
// Resulting embed should be valid
|
||||
if embed.Footer == nil {
|
||||
t.Error("Footer should never be nil")
|
||||
}
|
||||
|
||||
// Color should always be set to a valid value
|
||||
if embed.Color == 0 {
|
||||
t.Error("Color should never be 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzExtractEmoji tests emoji extraction doesn't panic
|
||||
func FuzzExtractEmoji(f *testing.F) {
|
||||
// Valid tags
|
||||
f.Add("warning")
|
||||
f.Add("fire")
|
||||
f.Add("check")
|
||||
f.Add("rocket")
|
||||
|
||||
// Edge cases
|
||||
f.Add("")
|
||||
f.Add("unknown_tag")
|
||||
f.Add("WARNING") // uppercase
|
||||
f.Add("WaRnInG") // mixed case
|
||||
f.Add("\x00")
|
||||
f.Add("tag with spaces")
|
||||
f.Add("émoji")
|
||||
f.Add("🔥") // emoji as tag
|
||||
f.Add("a]b[c{d}") // special chars
|
||||
|
||||
f.Fuzz(func(t *testing.T, tag string) {
|
||||
client := NewClient()
|
||||
|
||||
// Should never panic
|
||||
tags := []string{tag}
|
||||
_ = client.extractEmoji(tags)
|
||||
|
||||
// Multiple tags
|
||||
_ = client.extractEmoji([]string{tag, tag, tag})
|
||||
|
||||
// Empty slice
|
||||
_ = client.extractEmoji([]string{})
|
||||
|
||||
// Nil slice
|
||||
_ = client.extractEmoji(nil)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzWebhookPayloadJSON tests JSON marshaling of payloads
|
||||
func FuzzWebhookPayloadJSON(f *testing.F) {
|
||||
f.Add("Title", "Description", 3066993, "Topic", "value", "footer")
|
||||
|
||||
f.Fuzz(func(t *testing.T, title, desc string, color int, fieldName, fieldValue, footer string) {
|
||||
payload := WebhookPayload{
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: title,
|
||||
Description: desc,
|
||||
Color: color,
|
||||
Fields: []Field{
|
||||
{Name: fieldName, Value: fieldValue, Inline: true},
|
||||
},
|
||||
Footer: &Footer{Text: footer},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshaling should not panic
|
||||
_, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// JSON encoding errors are acceptable for invalid UTF-8
|
||||
// but should not panic
|
||||
}
|
||||
})
|
||||
}
|
||||
203
internal/discord/webhook.go
Normal file
203
internal/discord/webhook.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
|
||||
)
|
||||
|
||||
// Priority to Discord embed color mapping
|
||||
var priorityColors = map[int]int{
|
||||
5: 15158332, // Red - Max/Urgent
|
||||
4: 15105570, // Orange - High
|
||||
3: 3066993, // Blue - Default
|
||||
2: 9807270, // Gray - Low
|
||||
1: 12370112, // Light Gray - Min
|
||||
}
|
||||
|
||||
// Tag to emoji mapping
|
||||
var tagEmojis = map[string]string{
|
||||
"white_check_mark": "✅",
|
||||
"heavy_check_mark": "✅",
|
||||
"check": "✅",
|
||||
"x": "❌",
|
||||
"skull": "❌",
|
||||
"warning": "⚠️",
|
||||
"rotating_light": "🚨",
|
||||
"rocket": "🚀",
|
||||
"package": "📦",
|
||||
"tada": "🎉",
|
||||
"fire": "🔥",
|
||||
"bug": "🐛",
|
||||
"wrench": "🔧",
|
||||
"gear": "⚙️",
|
||||
"lock": "🔒",
|
||||
"key": "🔑",
|
||||
"bell": "🔔",
|
||||
"mega": "📢",
|
||||
"eyes": "👀",
|
||||
"sos": "🆘",
|
||||
"no_entry": "⛔",
|
||||
"construction": "🚧",
|
||||
}
|
||||
|
||||
// Embed represents a Discord embed
|
||||
type Embed struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Footer *Footer `json:"footer,omitempty"`
|
||||
}
|
||||
|
||||
// Field represents a Discord embed field
|
||||
type Field struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline,omitempty"`
|
||||
}
|
||||
|
||||
// Footer represents a Discord embed footer
|
||||
type Footer struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WebhookPayload is the Discord webhook request body
|
||||
type WebhookPayload struct {
|
||||
Embeds []Embed `json:"embeds"`
|
||||
}
|
||||
|
||||
// Client sends messages to Discord webhooks
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Discord webhook client
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Send converts an ntfy message to Discord format and sends it
|
||||
func (c *Client) Send(ctx context.Context, webhookURL string, msg ntfy.Message) error {
|
||||
if webhookURL == "" {
|
||||
return fmt.Errorf("no webhook URL configured")
|
||||
}
|
||||
|
||||
embed := c.buildEmbed(msg)
|
||||
payload := WebhookPayload{
|
||||
Embeds: []Embed{embed},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle rate limiting
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter != "" {
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
slog.Warn("Discord rate limited", "retry_after", seconds)
|
||||
time.Sleep(time.Duration(seconds) * time.Second)
|
||||
return c.Send(ctx, webhookURL, msg) // Retry
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("rate limited")
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) buildEmbed(msg ntfy.Message) Embed {
|
||||
// Get color from priority
|
||||
color := priorityColors[3] // Default
|
||||
if msg.Priority >= 1 && msg.Priority <= 5 {
|
||||
color = priorityColors[msg.Priority]
|
||||
}
|
||||
|
||||
// Build title with emoji prefix
|
||||
title := msg.Title
|
||||
if title == "" {
|
||||
title = msg.Topic
|
||||
}
|
||||
emoji := c.extractEmoji(msg.Tags)
|
||||
if emoji != "" {
|
||||
title = emoji + " " + title
|
||||
}
|
||||
|
||||
// Build timestamp
|
||||
timestamp := ""
|
||||
if msg.Time > 0 {
|
||||
timestamp = time.Unix(msg.Time, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
embed := Embed{
|
||||
Title: title,
|
||||
Description: msg.Message,
|
||||
Color: color,
|
||||
Timestamp: timestamp,
|
||||
Footer: &Footer{Text: "ntfy"},
|
||||
}
|
||||
|
||||
// Add topic as field
|
||||
if msg.Topic != "" {
|
||||
embed.Fields = append(embed.Fields, Field{
|
||||
Name: "Topic",
|
||||
Value: msg.Topic,
|
||||
Inline: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Add click URL if present
|
||||
if msg.Click != "" {
|
||||
embed.Fields = append(embed.Fields, Field{
|
||||
Name: "Link",
|
||||
Value: msg.Click,
|
||||
Inline: false,
|
||||
})
|
||||
}
|
||||
|
||||
return embed
|
||||
}
|
||||
|
||||
func (c *Client) extractEmoji(tags []string) string {
|
||||
for _, tag := range tags {
|
||||
tag = strings.ToLower(tag)
|
||||
if emoji, ok := tagEmojis[tag]; ok {
|
||||
return emoji
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
260
internal/discord/webhook_test.go
Normal file
260
internal/discord/webhook_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
|
||||
)
|
||||
|
||||
func TestClient_Send(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg ntfy.Message
|
||||
wantStatus int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful send",
|
||||
msg: ntfy.Message{
|
||||
ID: "test-id",
|
||||
Topic: "alerts",
|
||||
Title: "Test Alert",
|
||||
Message: "This is a test message",
|
||||
Priority: 3,
|
||||
Time: time.Now().Unix(),
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "high priority message",
|
||||
msg: ntfy.Message{
|
||||
ID: "high-priority",
|
||||
Topic: "urgent",
|
||||
Title: "Urgent Alert",
|
||||
Message: "Critical issue detected",
|
||||
Priority: 5,
|
||||
Tags: []string{"warning", "fire"},
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
msg: ntfy.Message{
|
||||
ID: "error-test",
|
||||
Topic: "test",
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var receivedPayload WebhookPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil {
|
||||
t.Errorf("failed to decode payload: %v", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.wantStatus)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient()
|
||||
err := client.Send(context.Background(), server.URL, tt.msg)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Send() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(receivedPayload.Embeds) != 1 {
|
||||
t.Errorf("expected 1 embed, got %d", len(receivedPayload.Embeds))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Send_NoWebhookURL(t *testing.T) {
|
||||
client := NewClient()
|
||||
err := client.Send(context.Background(), "", ntfy.Message{})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for empty webhook URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_buildEmbed(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg ntfy.Message
|
||||
wantColor int
|
||||
wantEmoji string
|
||||
}{
|
||||
{
|
||||
name: "default priority",
|
||||
msg: ntfy.Message{
|
||||
Title: "Test",
|
||||
Message: "Hello",
|
||||
Priority: 3,
|
||||
},
|
||||
wantColor: 3066993, // Blue
|
||||
wantEmoji: "",
|
||||
},
|
||||
{
|
||||
name: "max priority with warning tag",
|
||||
msg: ntfy.Message{
|
||||
Title: "Alert",
|
||||
Message: "Critical",
|
||||
Priority: 5,
|
||||
Tags: []string{"warning"},
|
||||
},
|
||||
wantColor: 15158332, // Red
|
||||
wantEmoji: "⚠️",
|
||||
},
|
||||
{
|
||||
name: "low priority",
|
||||
msg: ntfy.Message{
|
||||
Title: "Info",
|
||||
Message: "Low priority",
|
||||
Priority: 2,
|
||||
},
|
||||
wantColor: 9807270, // Gray
|
||||
wantEmoji: "",
|
||||
},
|
||||
{
|
||||
name: "with check tag",
|
||||
msg: ntfy.Message{
|
||||
Title: "Success",
|
||||
Message: "Completed",
|
||||
Priority: 3,
|
||||
Tags: []string{"check", "success"},
|
||||
},
|
||||
wantColor: 3066993,
|
||||
wantEmoji: "✅",
|
||||
},
|
||||
{
|
||||
name: "no title uses topic",
|
||||
msg: ntfy.Message{
|
||||
Topic: "alerts",
|
||||
Message: "No title",
|
||||
},
|
||||
wantColor: 3066993,
|
||||
wantEmoji: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
embed := client.buildEmbed(tt.msg)
|
||||
|
||||
if embed.Color != tt.wantColor {
|
||||
t.Errorf("Color = %d, want %d", embed.Color, tt.wantColor)
|
||||
}
|
||||
|
||||
if tt.wantEmoji != "" {
|
||||
if len(embed.Title) < 2 || embed.Title[:len(tt.wantEmoji)] != tt.wantEmoji {
|
||||
t.Errorf("Title should start with emoji %s, got %s", tt.wantEmoji, embed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
if embed.Description != tt.msg.Message {
|
||||
t.Errorf("Description = %s, want %s", embed.Description, tt.msg.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_extractEmoji(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tags []string
|
||||
want string
|
||||
}{
|
||||
{"warning tag", []string{"warning"}, "⚠️"},
|
||||
{"check tag", []string{"check"}, "✅"},
|
||||
{"fire tag", []string{"fire"}, "🔥"},
|
||||
{"rocket tag", []string{"rocket"}, "🚀"},
|
||||
{"unknown tag", []string{"unknown"}, ""},
|
||||
{"empty tags", []string{}, ""},
|
||||
{"multiple tags first match", []string{"unknown", "fire", "warning"}, "🔥"},
|
||||
{"case insensitive", []string{"WARNING"}, "⚠️"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := client.extractEmoji(tt.tags)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractEmoji(%v) = %s, want %s", tt.tags, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityColors(t *testing.T) {
|
||||
expected := map[int]int{
|
||||
1: 12370112, // Light Gray
|
||||
2: 9807270, // Gray
|
||||
3: 3066993, // Blue
|
||||
4: 15105570, // Orange
|
||||
5: 15158332, // Red
|
||||
}
|
||||
|
||||
for priority, color := range expected {
|
||||
if priorityColors[priority] != color {
|
||||
t.Errorf("priorityColors[%d] = %d, want %d", priority, priorityColors[priority], color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookPayload_JSON(t *testing.T) {
|
||||
payload := WebhookPayload{
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: "Test",
|
||||
Description: "Hello World",
|
||||
Color: 3066993,
|
||||
Fields: []Field{
|
||||
{Name: "Topic", Value: "alerts", Inline: true},
|
||||
},
|
||||
Footer: &Footer{Text: "ntfy"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded WebhookPayload
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Embeds) != 1 {
|
||||
t.Errorf("expected 1 embed, got %d", len(decoded.Embeds))
|
||||
}
|
||||
|
||||
if decoded.Embeds[0].Title != "Test" {
|
||||
t.Errorf("Title = %s, want Test", decoded.Embeds[0].Title)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user