diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f77c29f1d..f8ec168dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -130,7 +130,7 @@ graph TB | **WebSocket** | gorilla/websocket | Latest | Real-time log streaming | | **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption | | **Metrics** | Prometheus Client | Latest | Application metrics | -| **Notifications** | Notify (Discord-first) | Current | Discord notifications now; additional services in phased rollout | +| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below | | **Docker Client** | Docker SDK | Latest | Container discovery | | **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation | | **Backup Archive Encryption** | filippo.io/age | Latest | Passphrase (scrypt) encryption of backup archives; audited, pure Go, streaming AEAD — avoids buffering whole archives in RAM or hand-rolling chunked AES-GCM | @@ -336,7 +336,8 @@ graph TB - **ProxyService:** CRUD operations for proxy hosts, validation logic - **CertificateService:** ACME certificate provisioning and renewal - **DockerService:** Container discovery and monitoring -- **MailService:** Email notifications for certificate expiry +- **MailService:** SMTP transport and branded HTML templates for certificate-expiry and other system emails +- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all seven provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.2.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed. - **SettingsService:** Application settings management - **BackupService:** Format-v2 archive creation (manifest + SHA-256 checksums), configurable cron scheduling, the safe-restore pipeline (validate → pre-restore safety backup → apply → reconcile), and optional age/scrypt archive encryption — see "Backup & Restore Subsystem" below diff --git a/backend/go.mod b/backend/go.mod index 19a9d28be..dadd570e0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.26.6 require ( filippo.io/age v1.3.1 + github.com/Wikid82/go_notify_yourself v0.2.0 github.com/gin-contrib/gzip v1.2.6 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 @@ -11,7 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/yamux v0.1.2 - github.com/minio/minio-go/v7 v7.2.1 + github.com/minio/minio-go/v7 v7.3.0 github.com/moby/moby/client v0.5.1 github.com/oschwald/geoip2-golang/v2 v2.3.0 github.com/pkg/sftp v1.13.11 diff --git a/backend/go.sum b/backend/go.sum index 7cd563f9a..09cc3b467 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Wikid82/go_notify_yourself v0.2.0 h1:fyzzzfO7ASHibGDhg+IPVnkG4miNJsMeW4OtCk9apvI= +github.com/Wikid82/go_notify_yourself v0.2.0/go.mod h1:Y2CIgAEYdsOwgEQDOXHO1nYYKvEDdGt08R3QD/6fG+w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= @@ -110,8 +112,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= -github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU= +github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= diff --git a/backend/integration/notification_http_wrapper_integration_test.go b/backend/integration/notification_http_wrapper_integration_test.go deleted file mode 100644 index 2b228a0e2..000000000 --- a/backend/integration/notification_http_wrapper_integration_test.go +++ /dev/null @@ -1,124 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/Wikid82/charon/backend/internal/notifications" -) - -func TestNotificationHTTPWrapperIntegration_RetriesOn429AndSucceeds(t *testing.T) { - t.Parallel() - - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - current := atomic.AddInt32(&calls, 1) - if current == 1 { - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - wrapper := notifications.NewNotifyHTTPWrapper() - result, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err != nil { - t.Fatalf("expected retry success, got error: %v", err) - } - if result.Attempts != 2 { - t.Fatalf("expected 2 attempts, got %d", result.Attempts) - } -} - -func TestNotificationHTTPWrapperIntegration_DoesNotRetryOn400(t *testing.T) { - t.Parallel() - - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&calls, 1) - w.WriteHeader(http.StatusBadRequest) - })) - defer server.Close() - - wrapper := notifications.NewNotifyHTTPWrapper() - _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil { - t.Fatalf("expected non-retryable 400 error") - } - if atomic.LoadInt32(&calls) != 1 { - t.Fatalf("expected one request attempt, got %d", calls) - } -} - -func TestNotificationHTTPWrapperIntegration_RejectsTokenizedQueryWithoutEcho(t *testing.T) { - t.Parallel() - - wrapper := notifications.NewNotifyHTTPWrapper() - secret := "pr1-secret-token-value" - _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{ - URL: "http://example.com/hook?token=" + secret, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil { - t.Fatalf("expected tokenized query rejection") - } - if !strings.Contains(err.Error(), "query authentication is not allowed") { - t.Fatalf("expected sanitized query-auth rejection, got: %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatalf("error must not echo secret token") - } -} - -func TestNotificationHTTPWrapperIntegration_HeaderAllowlistSafety(t *testing.T) { - t.Parallel() - - var seenAuthHeader string - var seenCookieHeader string - var seenGotifyKey string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seenAuthHeader = r.Header.Get("Authorization") - seenCookieHeader = r.Header.Get("Cookie") - seenGotifyKey = r.Header.Get("X-Gotify-Key") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - wrapper := notifications.NewNotifyHTTPWrapper() - _, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{ - URL: server.URL, - Headers: map[string]string{ - "Authorization": "Bearer should-not-leak", - "Cookie": "session=should-not-leak", - "X-Gotify-Key": "allowed-token", - }, - Body: []byte(`{"message":"hello"}`), - }) - if err != nil { - t.Fatalf("expected success, got error: %v", err) - } - if seenAuthHeader != "" { - t.Fatalf("authorization header must be stripped") - } - if seenCookieHeader != "" { - t.Fatalf("cookie header must be stripped") - } - if seenGotifyKey != "allowed-token" { - t.Fatalf("expected X-Gotify-Key to pass through") - } -} diff --git a/backend/internal/notifications/engine.go b/backend/internal/notifications/engine.go deleted file mode 100644 index b94f6fd84..000000000 --- a/backend/internal/notifications/engine.go +++ /dev/null @@ -1,22 +0,0 @@ -package notifications - -import "context" - -const ( - EngineNotifyV1 = "notify_v1" -) - -type DispatchRequest struct { - ProviderID string - Type string - URL string - Title string - Message string - Data map[string]any -} - -type DeliveryEngine interface { - Name() string - Send(ctx context.Context, req DispatchRequest) error - Test(ctx context.Context, req DispatchRequest) error -} diff --git a/backend/internal/notifications/http_client_executor.go b/backend/internal/notifications/http_client_executor.go deleted file mode 100644 index 250419511..000000000 --- a/backend/internal/notifications/http_client_executor.go +++ /dev/null @@ -1,7 +0,0 @@ -package notifications - -import "net/http" - -func executeNotifyRequest(client *http.Client, req *http.Request) (*http.Response, error) { - return client.Do(req) -} diff --git a/backend/internal/notifications/http_wrapper.go b/backend/internal/notifications/http_wrapper.go deleted file mode 100644 index e9831e2c2..000000000 --- a/backend/internal/notifications/http_wrapper.go +++ /dev/null @@ -1,540 +0,0 @@ -package notifications - -import ( - "bytes" - "context" - crand "crypto/rand" - "encoding/json" - "errors" - "fmt" - "io" - "math/big" - "net" - "net/http" - neturl "net/url" - "os" - "strconv" - "strings" - "time" - - "github.com/Wikid82/charon/backend/internal/network" - "github.com/Wikid82/charon/backend/internal/security" -) - -const ( - MaxNotifyRequestBodyBytes = 256 * 1024 - MaxNotifyResponseBodyBytes = 1024 * 1024 -) - -type RetryPolicy struct { - MaxAttempts int - BaseDelay time.Duration - MaxDelay time.Duration -} - -type HTTPWrapperRequest struct { - URL string - Headers map[string]string - Body []byte -} - -type HTTPWrapperResult struct { - StatusCode int - ResponseBody []byte - Attempts int -} - -type HTTPWrapper struct { - retryPolicy RetryPolicy - allowHTTP bool - maxRedirects int - httpClientFactory func(allowHTTP bool, maxRedirects int) *http.Client - sleep func(time.Duration) - jitterNanos func(int64) int64 -} - -func NewNotifyHTTPWrapper() *HTTPWrapper { - return &HTTPWrapper{ - retryPolicy: RetryPolicy{ - MaxAttempts: 3, - BaseDelay: 200 * time.Millisecond, - MaxDelay: 2 * time.Second, - }, - allowHTTP: allowNotifyHTTPOverride(), - maxRedirects: notifyMaxRedirects(), - httpClientFactory: func(allowHTTP bool, maxRedirects int) *http.Client { - opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)} - if allowHTTP { - opts = append(opts, network.WithAllowLocalhost()) - } - return network.NewSafeHTTPClient(opts...) - }, - sleep: time.Sleep, - } -} - -func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HTTPWrapperResult, error) { - if len(request.Body) > MaxNotifyRequestBodyBytes { - return nil, fmt.Errorf("request payload exceeds maximum size") - } - - validatedURL, err := w.validateURL(request.URL) - if err != nil { - return nil, err - } - - parsedValidatedURL, err := neturl.Parse(validatedURL) - if err != nil { - return nil, fmt.Errorf("destination URL validation failed") - } - - validationOptions := []security.ValidationOption{} - if w.allowHTTP { - validationOptions = append(validationOptions, security.WithAllowHTTP(), security.WithAllowLocalhost()) - } - - safeURL, safeURLErr := security.ValidateExternalURL(parsedValidatedURL.String(), validationOptions...) - if safeURLErr != nil { - return nil, fmt.Errorf("destination URL validation failed") - } - - safeParsedURL, safeParseErr := neturl.Parse(safeURL) - if safeParseErr != nil { - return nil, fmt.Errorf("destination URL validation failed") - } - - if err := w.guardDestination(safeParsedURL); err != nil { - return nil, err - } - - safeRequestURL, hostHeader, safeRequestErr := w.buildSafeRequestURL(safeParsedURL) - if safeRequestErr != nil { - return nil, safeRequestErr - } - - headers := sanitizeOutboundHeaders(request.Headers) - client := w.httpClientFactory(w.allowHTTP, w.maxRedirects) - w.applyRedirectGuard(client) - - var lastErr error - for attempt := 1; attempt <= w.retryPolicy.MaxAttempts; attempt++ { - httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, safeRequestURL.String(), bytes.NewReader(request.Body)) - if reqErr != nil { - return nil, fmt.Errorf("create outbound request: %w", reqErr) - } - - httpReq.Host = hostHeader - - for key, value := range headers { - httpReq.Header.Set(key, value) - } - - if httpReq.Header.Get("Content-Type") == "" { - httpReq.Header.Set("Content-Type", "application/json") - } - - resp, doErr := executeNotifyRequest(client, httpReq) - if doErr != nil { - lastErr = doErr - if attempt < w.retryPolicy.MaxAttempts && shouldRetry(nil, doErr) { - w.waitBeforeRetry(attempt) - continue - } - return nil, fmt.Errorf("outbound request failed: %s", sanitizeTransportErrorReason(doErr)) - } - - body, bodyErr := readCappedResponseBody(resp.Body) - closeErr := resp.Body.Close() - if bodyErr != nil { - return nil, bodyErr - } - if closeErr != nil { - return nil, fmt.Errorf("close response body: %w", closeErr) - } - - if shouldRetry(resp, nil) && attempt < w.retryPolicy.MaxAttempts { - w.waitBeforeRetry(attempt) - continue - } - - if resp.StatusCode >= http.StatusBadRequest { - if hint := extractProviderErrorHint(body); hint != "" { - return nil, fmt.Errorf("provider returned status %d: %s", resp.StatusCode, hint) - } - return nil, fmt.Errorf("provider returned status %d", resp.StatusCode) - } - - return &HTTPWrapperResult{ - StatusCode: resp.StatusCode, - ResponseBody: body, - Attempts: attempt, - }, nil - } - - if lastErr != nil { - return nil, fmt.Errorf("provider request failed after retries: %s", sanitizeTransportErrorReason(lastErr)) - } - - return nil, fmt.Errorf("provider request failed") -} - -func sanitizeTransportErrorReason(err error) string { - if err == nil { - return "connection failed" - } - - errText := strings.ToLower(strings.TrimSpace(err.Error())) - - switch { - case strings.Contains(errText, "no such host"): - return "dns lookup failed" - case strings.Contains(errText, "connection refused"): - return "connection refused" - case strings.Contains(errText, "no route to host") || strings.Contains(errText, "network is unreachable"): - return "network unreachable" - case strings.Contains(errText, "timeout") || strings.Contains(errText, "deadline exceeded"): - return "request timed out" - case strings.Contains(errText, "tls") || strings.Contains(errText, "certificate") || strings.Contains(errText, "x509"): - return "tls handshake failed" - default: - return "connection failed" - } -} - -func (w *HTTPWrapper) applyRedirectGuard(client *http.Client) { - if client == nil { - return - } - - originalCheckRedirect := client.CheckRedirect - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if originalCheckRedirect != nil { - if err := originalCheckRedirect(req, via); err != nil { - return err - } - } - - return w.guardOutboundRequestURL(req) - } -} - -func (w *HTTPWrapper) validateURL(rawURL string) (string, error) { - parsedURL, err := neturl.Parse(rawURL) - if err != nil { - return "", fmt.Errorf("invalid destination URL") - } - - if hasDisallowedQueryAuthKey(parsedURL.Query()) { - return "", fmt.Errorf("destination URL query authentication is not allowed") - } - - options := []security.ValidationOption{} - if w.allowHTTP { - options = append(options, security.WithAllowHTTP(), security.WithAllowLocalhost()) - } - - validatedURL, err := security.ValidateExternalURL(rawURL, options...) - if err != nil { - return "", fmt.Errorf("destination URL validation failed") - } - - return validatedURL, nil -} - -func hasDisallowedQueryAuthKey(query neturl.Values) bool { - for key := range query { - normalizedKey := strings.ToLower(strings.TrimSpace(key)) - switch normalizedKey { - case "token", "auth", "apikey", "api_key": - return true - } - } - - return false -} - -func (w *HTTPWrapper) guardOutboundRequestURL(httpReq *http.Request) error { - if httpReq == nil || httpReq.URL == nil { - return fmt.Errorf("destination URL validation failed") - } - - reqURL := httpReq.URL.String() - validatedURL, err := w.validateURL(reqURL) - if err != nil { - return err - } - - parsedValidatedURL, err := neturl.Parse(validatedURL) - if err != nil { - return fmt.Errorf("destination URL validation failed") - } - - return w.guardDestination(parsedValidatedURL) -} - -func (w *HTTPWrapper) guardDestination(destinationURL *neturl.URL) error { - if destinationURL == nil { - return fmt.Errorf("destination URL validation failed") - } - - if destinationURL.User != nil || destinationURL.Fragment != "" { - return fmt.Errorf("destination URL validation failed") - } - - hostname := strings.TrimSpace(destinationURL.Hostname()) - if hostname == "" { - return fmt.Errorf("destination URL validation failed") - } - - if parsedIP := net.ParseIP(hostname); parsedIP != nil { - if !w.isAllowedDestinationIP(hostname, parsedIP) { - return fmt.Errorf("destination URL validation failed") - } - return nil - } - - resolvedIPs, err := net.LookupIP(hostname) - if err != nil || len(resolvedIPs) == 0 { - return fmt.Errorf("destination URL validation failed") - } - - for _, resolvedIP := range resolvedIPs { - if !w.isAllowedDestinationIP(hostname, resolvedIP) { - return fmt.Errorf("destination URL validation failed") - } - } - - return nil -} - -func (w *HTTPWrapper) isAllowedDestinationIP(hostname string, ip net.IP) bool { - if ip == nil { - return false - } - - if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - return false - } - - if ip.IsLoopback() { - return w.allowHTTP && isLocalDestinationHost(hostname) - } - - if network.IsPrivateIP(ip) { - return false - } - - return true -} - -func (w *HTTPWrapper) buildSafeRequestURL(destinationURL *neturl.URL) (*neturl.URL, string, error) { - if destinationURL == nil { - return nil, "", fmt.Errorf("destination URL validation failed") - } - - hostname := strings.TrimSpace(destinationURL.Hostname()) - if hostname == "" { - return nil, "", fmt.Errorf("destination URL validation failed") - } - - // Validate destination IPs are allowed (defense-in-depth alongside safeDialer). - _, err := w.resolveAllowedDestinationIP(hostname) - if err != nil { - return nil, "", err - } - - // Preserve the original hostname in the URL so Go's TLS layer derives the - // correct ServerName for SNI and certificate verification. The safeDialer - // resolves DNS, validates IPs against SSRF rules, and connects to a - // validated IP at dial time, so protection is maintained without - // IP-pinning in the URL. - safeRequestURL := &neturl.URL{ - Scheme: destinationURL.Scheme, - Host: destinationURL.Host, - Path: destinationURL.EscapedPath(), - RawQuery: destinationURL.RawQuery, - } - - if safeRequestURL.Path == "" { - safeRequestURL.Path = "/" - } - - return safeRequestURL, destinationURL.Host, nil -} - -func (w *HTTPWrapper) resolveAllowedDestinationIP(hostname string) (net.IP, error) { - if parsedIP := net.ParseIP(hostname); parsedIP != nil { - if !w.isAllowedDestinationIP(hostname, parsedIP) { - return nil, fmt.Errorf("destination URL validation failed") - } - return parsedIP, nil - } - - resolvedIPs, err := net.LookupIP(hostname) - if err != nil || len(resolvedIPs) == 0 { - return nil, fmt.Errorf("destination URL validation failed") - } - - for _, resolvedIP := range resolvedIPs { - if w.isAllowedDestinationIP(hostname, resolvedIP) { - return resolvedIP, nil - } - } - - return nil, fmt.Errorf("destination URL validation failed") -} - -func isLocalDestinationHost(host string) bool { - trimmedHost := strings.TrimSpace(host) - if strings.EqualFold(trimmedHost, "localhost") { - return true - } - - parsedIP := net.ParseIP(trimmedHost) - return parsedIP != nil && parsedIP.IsLoopback() -} - -func shouldRetry(resp *http.Response, err error) bool { - if err != nil { - var netErr net.Error - if isNetErr := strings.Contains(strings.ToLower(err.Error()), "timeout") || strings.Contains(strings.ToLower(err.Error()), "connection"); isNetErr { - return true - } - return errors.As(err, &netErr) - } - - if resp == nil { - return false - } - - if resp.StatusCode == http.StatusTooManyRequests { - return true - } - - return resp.StatusCode >= http.StatusInternalServerError -} - -// extractProviderErrorHint attempts to extract a short, human-readable error description -// from a JSON error response body. Only well-known fields are extracted to avoid -// accidentally surfacing sensitive or overlong content from arbitrary providers. -func extractProviderErrorHint(body []byte) string { - if len(body) == 0 { - return "" - } - var errResp map[string]any - if err := json.Unmarshal(body, &errResp); err != nil { - return "" - } - for _, key := range []string{"description", "message", "error", "error_description"} { - v, ok := errResp[key] - if !ok { - continue - } - s, ok := v.(string) - if !ok || strings.TrimSpace(s) == "" { - continue - } - if len(s) > 100 { - s = s[:100] + "..." - } - return strings.TrimSpace(s) - } - return "" -} - -func readCappedResponseBody(body io.Reader) ([]byte, error) { - limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1) - content, err := io.ReadAll(limited) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - - if len(content) > MaxNotifyResponseBodyBytes { - return nil, fmt.Errorf("response payload exceeds maximum size") - } - - return content, nil -} - -func sanitizeOutboundHeaders(headers map[string]string) map[string]string { - allowed := map[string]struct{}{ - "content-type": {}, - "user-agent": {}, - "x-request-id": {}, - "x-gotify-key": {}, - "authorization": {}, - } - - sanitized := make(map[string]string) - for key, value := range headers { - normalizedKey := strings.ToLower(strings.TrimSpace(key)) - if _, ok := allowed[normalizedKey]; !ok { - continue - } - sanitized[http.CanonicalHeaderKey(normalizedKey)] = strings.TrimSpace(value) - } - - return sanitized -} - -func (w *HTTPWrapper) waitBeforeRetry(attempt int) { - delay := w.retryPolicy.BaseDelay << (attempt - 1) - if delay > w.retryPolicy.MaxDelay { - delay = w.retryPolicy.MaxDelay - } - - jitterFn := w.jitterNanos - if jitterFn == nil { - jitterFn = func(max int64) int64 { - if max <= 0 { - return 0 - } - n, err := crand.Int(crand.Reader, big.NewInt(max)) - if err != nil { - return 0 - } - return n.Int64() - } - } - - jitter := time.Duration(jitterFn(int64(delay) / 2)) - sleepFn := w.sleep - if sleepFn == nil { - sleepFn = time.Sleep - } - sleepFn(delay + jitter) -} - -func allowNotifyHTTPOverride() bool { - if strings.HasSuffix(os.Args[0], ".test") { - return true - } - - allowHTTP := strings.EqualFold(strings.TrimSpace(os.Getenv("CHARON_NOTIFY_ALLOW_HTTP")), "true") - if !allowHTTP { - return false - } - - environment := strings.ToLower(strings.TrimSpace(os.Getenv("CHARON_ENV"))) - return environment == "development" || environment == "test" -} - -func notifyMaxRedirects() int { - raw := strings.TrimSpace(os.Getenv("CHARON_NOTIFY_MAX_REDIRECTS")) - if raw == "" { - return 0 - } - - value, err := strconv.Atoi(raw) - if err != nil { - return 0 - } - - if value < 0 { - return 0 - } - if value > 5 { - return 5 - } - return value -} diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go deleted file mode 100644 index 2097e0917..000000000 --- a/backend/internal/notifications/http_wrapper_test.go +++ /dev/null @@ -1,1001 +0,0 @@ -package notifications - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - neturl "net/url" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestHTTPWrapperRejectsOversizedRequestBody(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - payload := make([]byte, MaxNotifyRequestBodyBytes+1) - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: "http://example.com/hook", - Body: payload, - }) - if err == nil || !strings.Contains(err.Error(), "request payload exceeds") { - t.Fatalf("expected oversized request body error, got: %v", err) - } -} - -func TestHTTPWrapperRejectsTokenizedQueryURL(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: "http://example.com/hook?token=secret", - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") { - t.Fatalf("expected query token rejection, got: %v", err) - } -} - -func TestHTTPWrapperRejectsQueryAuthCaseVariants(t *testing.T) { - testCases := []string{ - "http://example.com/hook?Token=secret", - "http://example.com/hook?AUTH=secret", - "http://example.com/hook?apiKey=secret", - } - - for _, testURL := range testCases { - t.Run(testURL, func(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: testURL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") { - t.Fatalf("expected query auth rejection for %q, got: %v", testURL, err) - } - }) - } -} - -func TestHTTPWrapperSendRejectsRedirectTargetWithDisallowedScheme(t *testing.T) { - var attempts int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&attempts, 1) - http.Redirect(w, r, "ftp://example.com/redirected", http.StatusFound) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.maxRedirects = 3 - wrapper.retryPolicy.MaxAttempts = 1 - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "outbound request failed") { - t.Fatalf("expected outbound failure due to redirect target validation, got: %v", err) - } - if got := atomic.LoadInt32(&attempts); got != 1 { - t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got) - } -} - -func TestHTTPWrapperSendRejectsRedirectTargetWithMixedCaseQueryAuth(t *testing.T) { - var attempts int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&attempts, 1) - http.Redirect(w, r, "https://example.com/redirected?Token=secret", http.StatusFound) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.maxRedirects = 3 - wrapper.retryPolicy.MaxAttempts = 1 - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "outbound request failed") { - t.Fatalf("expected outbound failure due to redirect query auth validation, got: %v", err) - } - if got := atomic.LoadInt32(&attempts); got != 1 { - t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got) - } -} - -func TestHTTPWrapperRetriesOn429ThenSucceeds(t *testing.T) { - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - current := atomic.AddInt32(&calls, 1) - if current == 1 { - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.sleep = func(time.Duration) {} - wrapper.jitterNanos = func(int64) int64 { return 0 } - - result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err != nil { - t.Fatalf("expected success after retry, got error: %v", err) - } - if result.Attempts != 2 { - t.Fatalf("expected 2 attempts, got %d", result.Attempts) - } -} - -func TestHTTPWrapperSendSuccessWithValidatedDestination(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Content-Type"); got != "application/json" { - t.Fatalf("expected default content-type, got %q", got) - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.retryPolicy.MaxAttempts = 1 - wrapper.httpClientFactory = func(bool, int) *http.Client { - return server.Client() - } - - result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err != nil { - t.Fatalf("expected successful send, got error: %v", err) - } - if result.Attempts != 1 { - t.Fatalf("expected 1 attempt, got %d", result.Attempts) - } - if result.StatusCode != http.StatusOK { - t.Fatalf("expected status %d, got %d", http.StatusOK, result.StatusCode) - } -} - -func TestHTTPWrapperSendRejectsUserInfoInDestinationURL(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ //nolint:gosec // test verifies rejection of credentials in URL - URL: "https://user:pass@example.com/hook", - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected destination validation failure, got: %v", err) - } -} - -func TestHTTPWrapperSendRejectsFragmentInDestinationURL(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: "https://example.com/hook#fragment", - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected destination validation failure, got: %v", err) - } -} - -func TestHTTPWrapperDoesNotRetryOn400(t *testing.T) { - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&calls, 1) - w.WriteHeader(http.StatusBadRequest) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.sleep = func(time.Duration) {} - wrapper.jitterNanos = func(int64) int64 { return 0 } - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "status 400") { - t.Fatalf("expected non-retryable 400 error, got: %v", err) - } - if atomic.LoadInt32(&calls) != 1 { - t.Fatalf("expected exactly one request attempt, got %d", calls) - } -} - -func TestHTTPWrapperResponseBodyCap(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, strings.Repeat("x", MaxNotifyResponseBodyBytes+8)) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"message":"hello"}`), - }) - if err == nil || !strings.Contains(err.Error(), "response payload exceeds") { - t.Fatalf("expected capped response body error, got: %v", err) - } -} - -func TestSanitizeOutboundHeadersAllowlist(t *testing.T) { - headers := sanitizeOutboundHeaders(map[string]string{ - "Content-Type": "application/json", - "User-Agent": "Charon", - "X-Request-ID": "abc", - "X-Gotify-Key": "secret", - "Authorization": "Bearer token", - "Cookie": "sid=1", - }) - - if len(headers) != 5 { - t.Fatalf("expected 5 allowed headers, got %d", len(headers)) - } - if _, ok := headers["Authorization"]; !ok { - t.Fatalf("authorization header must be allowed for ntfy Bearer auth") - } - if _, ok := headers["Cookie"]; ok { - t.Fatalf("cookie header must be stripped") - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsNilRequest(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - err := wrapper.guardOutboundRequestURL(nil) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected validation failure for nil request, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsQueryAuth(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "token=secret"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") { - t.Fatalf("expected query auth rejection, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsMixedCaseQueryAuth(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "apiKey=secret"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") { - t.Fatalf("expected query auth rejection, got: %v", err) - } -} - -func TestHTTPWrapperApplyRedirectGuardPreservesOriginalBehavior(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - baseErr := fmt.Errorf("base redirect policy") - client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { - return baseErr - }} - - wrapper.applyRedirectGuard(client) - err := client.CheckRedirect(&http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com"}}, nil) - if !errors.Is(err, baseErr) { - t.Fatalf("expected original redirect policy error, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsUnsafeDestination(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected destination validation failure, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLAllowsValidatedDestination(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err != nil { - t.Fatalf("expected validated destination to pass guard, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsUserInfo(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "127.0.0.1", User: neturl.UserPassword("user", "pass"), Path: "/hook"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected userinfo rejection, got: %v", err) - } -} - -func TestHTTPWrapperGuardOutboundRequestURLRejectsFragment(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook", Fragment: "frag"}} - err := wrapper.guardOutboundRequestURL(httpReq) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected fragment rejection, got: %v", err) - } -} - -func TestSanitizeTransportErrorReason(t *testing.T) { - tests := []struct { - name string - err error - expected string - }{ - {name: "nil error", err: nil, expected: "connection failed"}, - {name: "dns error", err: errors.New("dial tcp: lookup gotify.example: no such host"), expected: "dns lookup failed"}, - {name: "connection refused", err: errors.New("connect: connection refused"), expected: "connection refused"}, - {name: "network unreachable", err: errors.New("connect: no route to host"), expected: "network unreachable"}, - {name: "timeout", err: errors.New("context deadline exceeded"), expected: "request timed out"}, - {name: "tls failure", err: errors.New("tls: handshake failure"), expected: "tls handshake failed"}, - {name: "fallback", err: errors.New("some unexpected transport error"), expected: "connection failed"}, - } - - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - actual := sanitizeTransportErrorReason(testCase.err) - if actual != testCase.expected { - t.Fatalf("expected %q, got %q", testCase.expected, actual) - } - }) - } -} - -func TestBuildSafeRequestURLPreservesHostnameForTLS(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - destinationURL := &neturl.URL{ - Scheme: "https", - Host: "example.com", - Path: "/webhook", - } - - safeURL, hostHeader, err := wrapper.buildSafeRequestURL(destinationURL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if safeURL.Hostname() != "example.com" { - t.Fatalf("expected hostname 'example.com' preserved in URL for TLS SNI, got %q", safeURL.Hostname()) - } - - if hostHeader != "example.com" { - t.Fatalf("expected host header 'example.com', got %q", hostHeader) - } - - if safeURL.Scheme != "https" { - t.Fatalf("expected scheme 'https', got %q", safeURL.Scheme) - } - - if safeURL.Path != "/webhook" { - t.Fatalf("expected path '/webhook', got %q", safeURL.Path) - } -} - -func TestBuildSafeRequestURLDefaultsEmptyPathToSlash(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - destinationURL := &neturl.URL{ - Scheme: "http", - Host: "localhost", - } - - safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if safeURL.Path != "/" { - t.Fatalf("expected default path '/', got %q", safeURL.Path) - } -} - -func TestBuildSafeRequestURLPreservesQueryString(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - destinationURL := &neturl.URL{ - Scheme: "https", - Host: "example.com", - Path: "/hook", - RawQuery: "key=value", - } - - safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if safeURL.RawQuery != "key=value" { - t.Fatalf("expected query 'key=value', got %q", safeURL.RawQuery) - } -} - -func TestBuildSafeRequestURLRejectsNilDestination(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - _, _, err := wrapper.buildSafeRequestURL(nil) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected validation failure for nil URL, got: %v", err) - } -} - -func TestBuildSafeRequestURLRejectsEmptyHostname(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - - destinationURL := &neturl.URL{ - Scheme: "https", - Host: "", - Path: "/hook", - } - - _, _, err := wrapper.buildSafeRequestURL(destinationURL) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected validation failure for empty hostname, got: %v", err) - } -} - -func TestBuildSafeRequestURLWithTLSServer(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - serverURL, _ := neturl.Parse(server.URL) - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - safeURL, hostHeader, err := wrapper.buildSafeRequestURL(serverURL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if safeURL.Host != serverURL.Host { - t.Fatalf("expected host %q preserved for TLS, got %q", serverURL.Host, safeURL.Host) - } - - if hostHeader != serverURL.Host { - t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader) - } -} - -// ===== Additional coverage for uncovered paths ===== - -type errReader struct{} - -func (errReader) Read([]byte) (int, error) { - return 0, errors.New("simulated read error") -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestApplyRedirectGuardNilClient(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.applyRedirectGuard(nil) -} - -func TestGuardDestinationNilURL(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - err := wrapper.guardDestination(nil) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected validation failure for nil URL, got: %v", err) - } -} - -func TestGuardDestinationEmptyHostname(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: ""}) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected validation failure for empty hostname, got: %v", err) - } -} - -func TestGuardDestinationUserInfoRejection(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - u := &neturl.URL{Scheme: "https", Host: "example.com", User: neturl.User("admin")} - err := wrapper.guardDestination(u) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected userinfo rejection, got: %v", err) - } -} - -func TestGuardDestinationFragmentRejection(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - u := &neturl.URL{Scheme: "https", Host: "example.com", Fragment: "section"} - err := wrapper.guardDestination(u) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected fragment rejection, got: %v", err) - } -} - -func TestGuardDestinationPrivateIPRejection(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: "192.168.1.1"}) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected private IP rejection, got: %v", err) - } -} - -func TestIsAllowedDestinationIPEdgeCases(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - - tests := []struct { - name string - hostname string - ip net.IP - expected bool - }{ - {"nil IP", "", nil, false}, - {"unspecified", "0.0.0.0", net.IPv4zero, false}, - {"multicast", "224.0.0.1", net.ParseIP("224.0.0.1"), false}, - {"link-local unicast", "169.254.1.1", net.ParseIP("169.254.1.1"), false}, - {"loopback without allowHTTP", "127.0.0.1", net.ParseIP("127.0.0.1"), false}, - {"private 10.x", "10.0.0.1", net.ParseIP("10.0.0.1"), false}, - {"private 172.16.x", "172.16.0.1", net.ParseIP("172.16.0.1"), false}, - {"private 192.168.x", "192.168.1.1", net.ParseIP("192.168.1.1"), false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := wrapper.isAllowedDestinationIP(tt.hostname, tt.ip) - if result != tt.expected { - t.Fatalf("isAllowedDestinationIP(%q, %v) = %v, want %v", tt.hostname, tt.ip, result, tt.expected) - } - }) - } -} - -func TestIsAllowedDestinationIPLoopbackAllowHTTP(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - - if !wrapper.isAllowedDestinationIP("localhost", net.ParseIP("127.0.0.1")) { - t.Fatal("expected loopback allowed for localhost with allowHTTP") - } - - if wrapper.isAllowedDestinationIP("not-localhost", net.ParseIP("127.0.0.1")) { - t.Fatal("expected loopback rejected for non-localhost hostname") - } -} - -func TestIsLocalDestinationHost(t *testing.T) { - tests := []struct { - host string - expected bool - }{ - {"localhost", true}, - {"LOCALHOST", true}, - {"127.0.0.1", true}, - {"::1", true}, - {"example.com", false}, - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.host, func(t *testing.T) { - if got := isLocalDestinationHost(tt.host); got != tt.expected { - t.Fatalf("isLocalDestinationHost(%q) = %v, want %v", tt.host, got, tt.expected) - } - }) - } -} - -func TestShouldRetryComprehensive(t *testing.T) { - tests := []struct { - name string - resp *http.Response - err error - expected bool - }{ - {"nil resp nil err", nil, nil, false}, - {"timeout error string", nil, errors.New("operation timeout"), true}, - {"connection error string", nil, errors.New("connection reset"), true}, - {"unrelated error", nil, errors.New("json parse error"), false}, - {"500 response", &http.Response{StatusCode: 500}, nil, true}, - {"502 response", &http.Response{StatusCode: 502}, nil, true}, - {"503 response", &http.Response{StatusCode: 503}, nil, true}, - {"429 response", &http.Response{StatusCode: 429}, nil, true}, - {"200 response", &http.Response{StatusCode: 200}, nil, false}, - {"400 response", &http.Response{StatusCode: 400}, nil, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := shouldRetry(tt.resp, tt.err); got != tt.expected { - t.Fatalf("shouldRetry = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestShouldRetryNetError(t *testing.T) { - netErr := &net.DNSError{Err: "no such host", Name: "example.invalid"} - if !shouldRetry(nil, netErr) { - t.Fatal("expected net.Error to trigger retry via errors.As fallback") - } -} - -func TestReadCappedResponseBodyReadError(t *testing.T) { - _, err := readCappedResponseBody(errReader{}) - if err == nil || !strings.Contains(err.Error(), "read response body") { - t.Fatalf("expected read body error, got: %v", err) - } -} - -func TestReadCappedResponseBodyOversize(t *testing.T) { - oversized := strings.NewReader(strings.Repeat("x", MaxNotifyResponseBodyBytes+10)) - _, err := readCappedResponseBody(oversized) - if err == nil || !strings.Contains(err.Error(), "response payload exceeds") { - t.Fatalf("expected oversize error, got: %v", err) - } -} - -func TestReadCappedResponseBodySuccess(t *testing.T) { - content, err := readCappedResponseBody(strings.NewReader("hello")) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(content) != "hello" { - t.Fatalf("expected 'hello', got %q", string(content)) - } -} - -func TestHasDisallowedQueryAuthKeyAllVariants(t *testing.T) { - tests := []struct { - name string - key string - expected bool - }{ - {"token", "token", true}, - {"auth", "auth", true}, - {"apikey", "apikey", true}, - {"api_key", "api_key", true}, - {"TOKEN uppercase", "TOKEN", true}, - {"Api_Key mixed", "Api_Key", true}, - {"safe key", "callback", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - query := neturl.Values{} - query.Set(tt.key, "secret") - if got := hasDisallowedQueryAuthKey(query); got != tt.expected { - t.Fatalf("hasDisallowedQueryAuthKey with key %q = %v, want %v", tt.key, got, tt.expected) - } - }) - } -} - -func TestHasDisallowedQueryAuthKeyEmptyQuery(t *testing.T) { - if hasDisallowedQueryAuthKey(neturl.Values{}) { - t.Fatal("expected empty query to be safe") - } -} - -func TestNotifyMaxRedirects(t *testing.T) { - tests := []struct { - name string - envValue string - expected int - }{ - {"empty", "", 0}, - {"valid 3", "3", 3}, - {"zero", "0", 0}, - {"negative", "-1", 0}, - {"above max", "10", 5}, - {"exactly 5", "5", 5}, - {"invalid", "abc", 0}, - {"whitespace", " 2 ", 2}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", tt.envValue) - if got := notifyMaxRedirects(); got != tt.expected { - t.Fatalf("notifyMaxRedirects() = %d, want %d", got, tt.expected) - } - }) - } -} - -func TestResolveAllowedDestinationIPRejectsPrivateIP(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - _, err := wrapper.resolveAllowedDestinationIP("192.168.1.1") - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected private IP rejection, got: %v", err) - } -} - -func TestResolveAllowedDestinationIPRejectsLoopback(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - _, err := wrapper.resolveAllowedDestinationIP("127.0.0.1") - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected loopback rejection, got: %v", err) - } -} - -func TestResolveAllowedDestinationIPAllowsPublic(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - ip, err := wrapper.resolveAllowedDestinationIP("1.1.1.1") - if err != nil { - t.Fatalf("expected public IP to be allowed, got: %v", err) - } - if !ip.Equal(net.ParseIP("1.1.1.1")) { - t.Fatalf("expected 1.1.1.1, got %v", ip) - } -} - -func TestBuildSafeRequestURLRejectsPrivateHostname(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = false - u := &neturl.URL{Scheme: "https", Host: "192.168.1.1", Path: "/hook"} - _, _, err := wrapper.buildSafeRequestURL(u) - if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { - t.Fatalf("expected private host rejection, got: %v", err) - } -} - -func TestWaitBeforeRetryBasic(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - var sleptDuration time.Duration - wrapper.sleep = func(d time.Duration) { sleptDuration = d } - wrapper.jitterNanos = func(int64) int64 { return 0 } - wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond - wrapper.retryPolicy.MaxDelay = 1 * time.Second - - wrapper.waitBeforeRetry(1) - if sleptDuration != 100*time.Millisecond { - t.Fatalf("expected 100ms delay for attempt 1, got %v", sleptDuration) - } - - wrapper.waitBeforeRetry(2) - if sleptDuration != 200*time.Millisecond { - t.Fatalf("expected 200ms delay for attempt 2, got %v", sleptDuration) - } -} - -func TestWaitBeforeRetryClampedToMax(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - var sleptDuration time.Duration - wrapper.sleep = func(d time.Duration) { sleptDuration = d } - wrapper.jitterNanos = func(int64) int64 { return 0 } - wrapper.retryPolicy.BaseDelay = 1 * time.Second - wrapper.retryPolicy.MaxDelay = 2 * time.Second - - wrapper.waitBeforeRetry(5) - if sleptDuration != 2*time.Second { - t.Fatalf("expected clamped delay of 2s, got %v", sleptDuration) - } -} - -func TestWaitBeforeRetryDefaultJitter(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.jitterNanos = nil - wrapper.sleep = func(time.Duration) {} - wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond - wrapper.retryPolicy.MaxDelay = 1 * time.Second - wrapper.waitBeforeRetry(1) -} - -func TestHTTPWrapperSendExhaustsRetriesOnTransportError(t *testing.T) { - var calls int32 - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.sleep = func(time.Duration) {} - wrapper.jitterNanos = func(int64) int64 { return 0 } - wrapper.httpClientFactory = func(bool, int) *http.Client { - return &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return nil, errors.New("connection timeout failure") - }), - } - } - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: "http://localhost:19999/hook", - Body: []byte(`{"msg":"test"}`), - }) - if err == nil { - t.Fatal("expected error after transport failures") - } - if !strings.Contains(err.Error(), "outbound request failed") { - t.Fatalf("expected outbound request failed message, got: %v", err) - } - if got := atomic.LoadInt32(&calls); got != 3 { - t.Fatalf("expected 3 attempts, got %d", got) - } -} - -func TestHTTPWrapperSendExhaustsRetriesOn500(t *testing.T) { - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&calls, 1) - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.sleep = func(time.Duration) {} - wrapper.jitterNanos = func(int64) int64 { return 0 } - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: server.URL, - Body: []byte(`{"msg":"test"}`), - }) - if err == nil || !strings.Contains(err.Error(), "status 500") { - t.Fatalf("expected 500 status error, got: %v", err) - } - if got := atomic.LoadInt32(&calls); got != 3 { - t.Fatalf("expected 3 attempts for 500 retries, got %d", got) - } -} - -func TestHTTPWrapperSendTransportErrorNoRetry(t *testing.T) { - wrapper := NewNotifyHTTPWrapper() - wrapper.allowHTTP = true - wrapper.retryPolicy.MaxAttempts = 1 - wrapper.httpClientFactory = func(bool, int) *http.Client { - return &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, errors.New("some unretryable error") - }), - } - } - - _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ - URL: "http://localhost:19999/hook", - Body: []byte(`{"msg":"test"}`), - }) - if err == nil || !strings.Contains(err.Error(), "outbound request failed") { - t.Fatalf("expected outbound request failed, got: %v", err) - } -} - -func TestSanitizeTransportErrorReasonNetworkUnreachable(t *testing.T) { - result := sanitizeTransportErrorReason(errors.New("connect: network is unreachable")) - if result != "network unreachable" { - t.Fatalf("expected 'network unreachable', got %q", result) - } -} - -func TestSanitizeTransportErrorReasonCertificate(t *testing.T) { - result := sanitizeTransportErrorReason(errors.New("x509: certificate signed by unknown authority")) - if result != "tls handshake failed" { - t.Fatalf("expected 'tls handshake failed', got %q", result) - } -} - -func TestAllowNotifyHTTPOverride(t *testing.T) { - result := allowNotifyHTTPOverride() - if !result { - t.Fatal("expected allowHTTP to be true in test binary") - } -} - -func TestExtractProviderErrorHint(t *testing.T) { - tests := []struct { - name string - body []byte - expected string - }{ - { - name: "description field", - body: []byte(`{"description":"Not Found: chat not found"}`), - expected: "Not Found: chat not found", - }, - { - name: "message field", - body: []byte(`{"message":"Unauthorized"}`), - expected: "Unauthorized", - }, - { - name: "error field", - body: []byte(`{"error":"rate limited"}`), - expected: "rate limited", - }, - { - name: "error_description field", - body: []byte(`{"error_description":"invalid token"}`), - expected: "invalid token", - }, - { - name: "empty body", - body: []byte{}, - expected: "", - }, - { - name: "non-JSON body", - body: []byte(`Server Error`), - expected: "", - }, - { - name: "string over 100 chars truncated", - body: []byte(`{"description":"` + strings.Repeat("x", 120) + `"}`), - expected: strings.Repeat("x", 100) + "...", - }, - { - name: "empty string value ignored", - body: []byte(`{"description":"","message":"fallback hint"}`), - expected: "fallback hint", - }, - { - name: "whitespace-only value ignored", - body: []byte(`{"description":" ","message":"real hint"}`), - expected: "real hint", - }, - { - name: "non-string value ignored", - body: []byte(`{"description":42,"message":"string hint"}`), - expected: "string hint", - }, - { - name: "priority order: description before message", - body: []byte(`{"message":"second","description":"first"}`), - expected: "first", - }, - { - name: "no recognized fields", - body: []byte(`{"status":"error","code":500}`), - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractProviderErrorHint(tt.body) - if result != tt.expected { - t.Errorf("extractProviderErrorHint(%q) = %q, want %q", string(tt.body), result, tt.expected) - } - }) - } -} diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go deleted file mode 100644 index 5aa780765..000000000 --- a/backend/internal/notifications/router.go +++ /dev/null @@ -1,37 +0,0 @@ -package notifications - -import "strings" - -// NOTE: used only in tests -type Router struct{} - -func NewRouter() *Router { - return &Router{} -} - -func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) bool { - if !flags[FlagNotifyEngineEnabled] { - return false - } - - switch strings.ToLower(providerType) { - case "discord": - return flags[FlagDiscordServiceEnabled] - case "email": - return flags[FlagEmailServiceEnabled] - case "gotify": - return flags[FlagGotifyServiceEnabled] - case "webhook": - return flags[FlagWebhookServiceEnabled] - case "telegram": - return flags[FlagTelegramServiceEnabled] - case "slack": - return flags[FlagSlackServiceEnabled] - case "pushover": - return flags[FlagPushoverServiceEnabled] - case "ntfy": - return flags[FlagNtfyServiceEnabled] - default: - return false - } -} diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go deleted file mode 100644 index 25395dba1..000000000 --- a/backend/internal/notifications/router_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package notifications - -import "testing" - -func TestRouter_ShouldUseNotify(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagDiscordServiceEnabled: true, - } - - if !router.ShouldUseNotify("discord", flags) { - t.Fatalf("expected notify routing for discord when enabled") - } - - if router.ShouldUseNotify("telegram", flags) { - t.Fatalf("expected unsupported service to remain legacy") - } -} - -// TestRouter_ShouldUseNotify_EngineDisabled covers lines 13-14 -func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: false, - FlagDiscordServiceEnabled: true, - } - - if router.ShouldUseNotify("discord", flags) { - t.Fatalf("expected notify routing disabled when FlagNotifyEngineEnabled is false") - } -} - -// TestRouter_ShouldUseNotify_DiscordServiceFlag covers lines 23-24 -func TestRouter_ShouldUseNotify_DiscordServiceFlag(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagDiscordServiceEnabled: false, - } - - if router.ShouldUseNotify("discord", flags) { - t.Fatalf("expected notify routing disabled for discord when FlagDiscordServiceEnabled is false") - } -} - -// TestRouter_ShouldUseNotify_GotifyServiceFlag covers lines 23-24 (gotify case) -func TestRouter_ShouldUseNotify_GotifyServiceFlag(t *testing.T) { - router := NewRouter() - - // Test with gotify enabled - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagGotifyServiceEnabled: true, - } - - if !router.ShouldUseNotify("gotify", flags) { - t.Fatalf("expected notify routing enabled for gotify when FlagGotifyServiceEnabled is true") - } - - // Test with gotify disabled - flags[FlagGotifyServiceEnabled] = false - - if router.ShouldUseNotify("gotify", flags) { - t.Fatalf("expected notify routing disabled for gotify when FlagGotifyServiceEnabled is false") - } -} - -func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagWebhookServiceEnabled: true, - } - - if !router.ShouldUseNotify("webhook", flags) { - t.Fatalf("expected notify routing enabled for webhook when FlagWebhookServiceEnabled is true") - } - - flags[FlagWebhookServiceEnabled] = false - if router.ShouldUseNotify("webhook", flags) { - t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false") - } -} - -func TestRouter_ShouldUseNotify_SlackServiceFlag(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagSlackServiceEnabled: true, - } - - if !router.ShouldUseNotify("slack", flags) { - t.Fatalf("expected notify routing enabled for slack when FlagSlackServiceEnabled is true") - } - - flags[FlagSlackServiceEnabled] = false - if router.ShouldUseNotify("slack", flags) { - t.Fatalf("expected notify routing disabled for slack when FlagSlackServiceEnabled is false") - } -} - -func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagPushoverServiceEnabled: true, - } - - if !router.ShouldUseNotify("pushover", flags) { - t.Fatalf("expected notify routing enabled for pushover when FlagPushoverServiceEnabled is true") - } - - flags[FlagPushoverServiceEnabled] = false - if router.ShouldUseNotify("pushover", flags) { - t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false") - } -} - -func TestRouter_ShouldUseNotify_NtfyServiceFlag(t *testing.T) { - router := NewRouter() - - flags := map[string]bool{ - FlagNotifyEngineEnabled: true, - FlagNtfyServiceEnabled: true, - } - - if !router.ShouldUseNotify("ntfy", flags) { - t.Fatalf("expected notify routing enabled for ntfy when FlagNtfyServiceEnabled is true") - } - - flags[FlagNtfyServiceEnabled] = false - if router.ShouldUseNotify("ntfy", flags) { - t.Fatalf("expected notify routing disabled for ntfy when FlagNtfyServiceEnabled is false") - } -} diff --git a/backend/internal/services/coverage_boost_test.go b/backend/internal/services/coverage_boost_test.go index cb4e0029b..9e2e7f15d 100644 --- a/backend/internal/services/coverage_boost_test.go +++ b/backend/internal/services/coverage_boost_test.go @@ -2,7 +2,6 @@ package services import ( "context" - "net" "testing" "github.com/Wikid82/charon/backend/internal/models" @@ -301,18 +300,6 @@ func TestCoverageBoost_HelperFunctions(t *testing.T) { headers := map[string][]string{} assert.False(t, hasHeader(headers, "Any-Header")) }) - - t.Run("isPrivateIP_PrivateRanges", func(t *testing.T) { - assert.True(t, isPrivateIP(net.ParseIP("192.168.1.1"))) - assert.True(t, isPrivateIP(net.ParseIP("10.0.0.1"))) - assert.True(t, isPrivateIP(net.ParseIP("172.16.0.1"))) - assert.True(t, isPrivateIP(net.ParseIP("127.0.0.1"))) - }) - - t.Run("isPrivateIP_PublicIP", func(t *testing.T) { - assert.False(t, isPrivateIP(net.ParseIP("8.8.8.8"))) - assert.False(t, isPrivateIP(net.ParseIP("1.1.1.1"))) - }) } // TestCoverageBoost_ProxyHostService_DB tests DB accessor diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/services/notification_feature_flags.go similarity index 63% rename from backend/internal/notifications/feature_flags.go rename to backend/internal/services/notification_feature_flags.go index 846a78cb2..c1b0db08f 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/services/notification_feature_flags.go @@ -1,5 +1,12 @@ -package notifications +package services +// Notification feature-flag keys (models.Setting table). These gate +// per-provider dispatch via NotificationService.isDispatchEnabled / +// getFeatureFlagValue. Moved here from the now-removed internal/notifications +// package (docs/plans/notifications_extraction_spec.md §3.6 step 2) — this is +// Charon policy (which provider types are enabled), not delivery-engine +// logic, so it stays in Charon rather than moving to the extracted +// go_notify_yourself module. const ( FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled" FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled" diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index c48ed6d6f..5f480c4ef 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -5,20 +5,19 @@ import ( "context" "encoding/json" "fmt" - "html" "net" - "net/http" neturl "net/url" "regexp" "strings" "text/template" "time" + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/email" + "github.com/Wikid82/go_notify_yourself/providers/webhook" + "github.com/Wikid82/go_notify_yourself/transport" + "github.com/Wikid82/charon/backend/internal/logger" - "github.com/Wikid82/charon/backend/internal/network" - "github.com/Wikid82/charon/backend/internal/notifications" - "github.com/Wikid82/charon/backend/internal/security" - "github.com/Wikid82/charon/backend/internal/trace" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/util" @@ -27,7 +26,7 @@ import ( type NotificationService struct { DB *gorm.DB - httpWrapper *notifications.HTTPWrapper + notifyWrapper *transport.Wrapper mailService MailServiceInterface telegramAPIBaseURL string pushoverAPIBaseURL string @@ -45,10 +44,22 @@ func WithSlackURLValidator(fn func(string) error) NotificationServiceOption { } } +// WithNotifyTransportWrapper overrides the *transport.Wrapper used to +// dispatch notifications through the extracted notify module's provider +// packages (buildNotifySender, notify_provider_adapter.go). Intended for +// tests that need to intercept outbound requests (e.g. a fake +// http.RoundTripper) without hitting a real network destination — +// production code always uses the wrapper built by NewNotifyTransportWrapper. +func WithNotifyTransportWrapper(w *transport.Wrapper) NotificationServiceOption { + return func(s *NotificationService) { + s.notifyWrapper = w + } +} + func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts ...NotificationServiceOption) *NotificationService { s := &NotificationService{ DB: db, - httpWrapper: notifications.NewNotifyHTTPWrapper(), + notifyWrapper: NewNotifyTransportWrapper(), mailService: mailService, telegramAPIBaseURL: "https://api.telegram.org", pushoverAPIBaseURL: "https://api.pushover.net", @@ -60,8 +71,6 @@ func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts return s } -var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`) - var allowedDiscordWebhookHosts = map[string]struct{}{ "discord.com": {}, "canary.discord.com": {}, @@ -76,18 +85,6 @@ func validateSlackWebhookURL(rawURL string) error { return nil } -func normalizeURL(serviceType, rawURL string) string { - if serviceType == "discord" { - matches := discordWebhookRegex.FindStringSubmatch(rawURL) - if len(matches) == 3 { - id := matches[1] - token := matches[2] - return fmt.Sprintf("discord://%s@%s", token, id) - } - } - return rawURL -} - func validateDiscordWebhookURL(rawURL string) error { parsedURL, err := neturl.Parse(rawURL) if err != nil { @@ -150,19 +147,19 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool { case "discord": return true case "email": - return s.getFeatureFlagValue(notifications.FlagEmailServiceEnabled, false) + return s.getFeatureFlagValue(FlagEmailServiceEnabled, false) case "gotify": - return s.getFeatureFlagValue(notifications.FlagGotifyServiceEnabled, true) + return s.getFeatureFlagValue(FlagGotifyServiceEnabled, true) case "webhook": - return s.getFeatureFlagValue(notifications.FlagWebhookServiceEnabled, true) + return s.getFeatureFlagValue(FlagWebhookServiceEnabled, true) case "telegram": - return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true) + return s.getFeatureFlagValue(FlagTelegramServiceEnabled, true) case "slack": - return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true) + return s.getFeatureFlagValue(FlagSlackServiceEnabled, true) case "pushover": - return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true) + return s.getFeatureFlagValue(FlagPushoverServiceEnabled, true) case "ntfy": - return s.getFeatureFlagValue(notifications.FlagNtfyServiceEnabled, true) + return s.getFeatureFlagValue(FlagNtfyServiceEnabled, true) default: return false } @@ -267,7 +264,7 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title continue } if strings.ToLower(strings.TrimSpace(provider.Type)) == "email" { - go s.dispatchEmail(ctx, provider, eventType, title, message) + go s.dispatchEmailViaNotify(ctx, provider, eventType, title, message) continue } go func(p models.NotificationProvider) { @@ -275,88 +272,111 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime") return } - - if err := s.sendJSONPayload(ctx, p, data); err != nil { - logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send JSON notification") - } + s.dispatchViaNotify(ctx, p, eventType, title, message, data) }(provider) } } -// sanitizeForEmail strips ASCII control characters (0x00–0x1F and 0x7F DEL) -// and trims leading/trailing whitespace from untrusted strings before they -// enter the email pipeline. The result is a normalized, single-line string. -// This provides defense-in-depth alongside rejectCRLF() validation in -// SendEmail/buildEmail. -func sanitizeForEmail(s string) string { - stripped := strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7F { - return -1 - } - return r - }, s) - return strings.TrimSpace(stripped) +// notifyMessageDataFromLegacyFlatMap extracts the host/service extras that +// legacyDetailedTemplate (notify_provider_adapter.go) reads via +// {{index .Data "HostName"}}/{{index .Data "HostIP"}}/ +// {{index .Data "ServiceCount"}}/{{index .Data "Services"}} from the flat +// data map SendExternal callers (e.g. uptime_service.go's +// sendHostDownNotification) pass in, so a provider configured with the old +// "detailed" template keeps rendering the same host/IP/service-count/ +// services values after cutover to the extracted notify module. Reading a +// missing key from a nil or incomplete map yields nil (renders as JSON +// null), matching the old flat-map template's behavior for callers that +// don't supply these optional fields (e.g. proxy_host/domain/cert/ +// remote_server events). +func notifyMessageDataFromLegacyFlatMap(data map[string]any) map[string]any { + return map[string]any{ + "HostName": data["HostName"], + "HostIP": data["HostIP"], + "ServiceCount": data["ServiceCount"], + "Services": data["Services"], + } +} + +// dispatchViaNotify sends a notification through the extracted notify +// module (buildNotifySender, notify_provider_adapter.go). It builds +// a notify.Message from the caller-supplied source data +// (title/message/eventType plus the HostName/HostIP/ServiceCount/Services +// extras a caller may have supplied), then dispatches it through the +// provider-specific Sender, which routes through the shared +// *transport.Wrapper (s.notifyWrapper) — gaining that wrapper's +// retry/backoff behavior for every dispatch that goes through this path. +func (s *NotificationService) dispatchViaNotify(ctx context.Context, p models.NotificationProvider, eventType, title, message string, data map[string]any) { + sender, err := buildNotifySender(p, s.notifyWrapper) + if err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to build notify sender") + return + } + + msg := notify.Message{ + Title: title, + Body: message, + EventType: eventType, + Data: notifyMessageDataFromLegacyFlatMap(data), + } + + if err := sender.Send(ctx, msg); err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send notification via notify module") + } } -// dispatchEmail sends an email notification for the given provider. +// dispatchEmailViaNotify sends an email notification through the extracted +// notify module's email package (NewNotifyEmailConfig, notify_email_adapter.go). // It runs in a goroutine; all errors are logged rather than returned. -func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, eventType, title, message string) { +// +// Behavior note: a template-rendering failure still +// results in the notification being sent, using a manually built plain +// HTML body — see mailServiceTemplateRendererAdapter.Render's doc comment +// (notify_email_adapter.go) for where that fallback now lives. Only a real +// Mailer/SMTP transport failure (mailServiceMailerAdapter.Send) causes this +// function's error branch to fire. +func (s *NotificationService) dispatchEmailViaNotify(ctx context.Context, p models.NotificationProvider, eventType, title, message string) { if s.mailService == nil || !s.mailService.IsConfigured() { logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider is not configured, skipping dispatch") return } - rawRecipients := strings.Split(p.URL, ",") - recipients := make([]string, 0, len(rawRecipients)) - for _, r := range rawRecipients { - if trimmed := strings.TrimSpace(r); trimmed != "" { - recipients = append(recipients, trimmed) - } - } - + recipients := parseEmailRecipients(p.URL) if len(recipients) == 0 { logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider has no recipients configured") return } - safeTitle := sanitizeForEmail(title) - safeMessage := sanitizeForEmail(message) - subject := fmt.Sprintf("[Charon Alert] %s", safeTitle) + client := email.New(NewNotifyEmailConfig(s.mailService, recipients)) - templateName := emailTemplateForEventType(eventType) - data := EmailTemplateData{ + msg := notify.Message{ + Title: title, + Body: message, EventType: eventType, - Title: safeTitle, - Message: safeMessage, - Timestamp: time.Now().Format(time.RFC3339), - } - - htmlBody, renderErr := s.mailService.RenderNotificationEmail(templateName, data) - if renderErr != nil { - logger.Log().WithError(renderErr).WithField("template", templateName).Warn("Email template rendering failed, using fallback") - var bodyBuilder strings.Builder - if safeTitle != "" { - bodyBuilder.WriteString("") - bodyBuilder.WriteString(html.EscapeString(safeTitle)) - bodyBuilder.WriteString("") - } - if safeMessage != "" { - if bodyBuilder.Len() > 0 { - bodyBuilder.WriteString("
") - } - bodyBuilder.WriteString(html.EscapeString(safeMessage)) - } - htmlBody = bodyBuilder.String() } timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, htmlBody); err != nil { + if err := client.Send(timeoutCtx, msg); err != nil { logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification") } } +// parseEmailRecipients splits a NotificationProvider's comma-separated URL +// field into a trimmed, non-empty recipient list. Shared by +// dispatchEmailViaNotify and TestEmailProvider's notify-path counterpart. +func parseEmailRecipients(rawURL string) []string { + rawRecipients := strings.Split(rawURL, ",") + recipients := make([]string, 0, len(rawRecipients)) + for _, r := range rawRecipients { + if trimmed := strings.TrimSpace(r); trimmed != "" { + recipients = append(recipients, trimmed) + } + } + return recipients +} + func emailTemplateForEventType(eventType string) string { switch strings.ToLower(strings.TrimSpace(eventType)) { case "security_waf", "security_acl", "security_rate_limit", "security_crowdsec": @@ -370,332 +390,10 @@ func emailTemplateForEventType(eventType string) string { } } -// webhookDoRequestFunc is a test hook for outbound JSON webhook requests. -// In production it defaults to (*http.Client).Do. -var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return client.Do(req) -} - // validateDiscordProviderURLFunc is a test hook for Discord webhook URL validation. // In tests, you can override this to bypass strict hostname checks for localhost testing. var validateDiscordProviderURLFunc = validateDiscordProviderURL -func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.NotificationProvider, data map[string]any) error { - // Built-in templates - const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}` - const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}` - - // Select template based on provider.Template; if 'custom' use Config; else builtin. - tmplStr := p.Config - switch strings.ToLower(strings.TrimSpace(p.Template)) { - case "detailed": - tmplStr = detailedTemplate - case "minimal": - tmplStr = minimalTemplate - case "custom": - if tmplStr == "" { - tmplStr = minimalTemplate - } - default: - if tmplStr == "" { - tmplStr = minimalTemplate - } - } - - // Template size limit validation (10KB max) - const maxTemplateSize = 10 * 1024 - if len(tmplStr) > maxTemplateSize { - return fmt.Errorf("template size exceeds maximum limit of %d bytes", maxTemplateSize) - } - - providerType := strings.ToLower(strings.TrimSpace(p.Type)) - if providerType == "discord" { - if err := validateDiscordProviderURLFunc(p.Type, p.URL); err != nil { - return err - } - - if !isValidRedirectURL(p.URL) { - return fmt.Errorf("invalid webhook url") - } - } - - // Parse template and add helper funcs - tmpl, err := template.New("webhook").Funcs(template.FuncMap{ - "toJSON": func(v any) string { - b, _ := json.Marshal(v) - return string(b) - }, - }).Parse(tmplStr) - if err != nil { - return fmt.Errorf("failed to parse webhook template: %w", err) - } - - // Template execution with timeout (5 seconds) - var body bytes.Buffer - execDone := make(chan error, 1) - go func() { - execDone <- tmpl.Execute(&body, data) - }() - - select { - case execErr := <-execDone: - if execErr != nil { - return fmt.Errorf("failed to execute webhook template: %w", execErr) - } - case <-time.After(5 * time.Second): - return fmt.Errorf("template execution timeout after 5 seconds") - } - - // Service-specific JSON validation - var jsonPayload map[string]any - if unmarshalErr := json.Unmarshal(body.Bytes(), &jsonPayload); unmarshalErr != nil { - return fmt.Errorf("invalid JSON payload: %w", unmarshalErr) - } - - // Validate service-specific requirements - switch strings.ToLower(p.Type) { - case "discord": - // Discord requires either 'content' or 'embeds' - if _, hasContent := jsonPayload["content"]; !hasContent { - if _, hasEmbeds := jsonPayload["embeds"]; !hasEmbeds { - if messageValue, hasMessage := jsonPayload["message"]; hasMessage { - jsonPayload["content"] = messageValue - normalizedBody, marshalErr := json.Marshal(jsonPayload) - if marshalErr != nil { - return fmt.Errorf("failed to normalize discord payload: %w", marshalErr) - } - body.Reset() - if _, writeErr := body.Write(normalizedBody); writeErr != nil { - return fmt.Errorf("failed to write normalized discord payload: %w", writeErr) - } - } else { - return fmt.Errorf("discord payload requires 'content' or 'embeds' field") - } - } - } - case "slack": - if _, hasText := jsonPayload["text"]; !hasText { - if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks { - if messageValue, hasMessage := jsonPayload["message"]; hasMessage { - jsonPayload["text"] = messageValue - normalizedBody, marshalErr := json.Marshal(jsonPayload) - if marshalErr != nil { - return fmt.Errorf("failed to normalize slack payload: %w", marshalErr) - } - body.Reset() - if _, writeErr := body.Write(normalizedBody); writeErr != nil { - return fmt.Errorf("failed to write normalized slack payload: %w", writeErr) - } - } else { - return fmt.Errorf("slack payload requires 'text' or 'blocks' field") - } - } - } - case "gotify": - // Gotify requires 'message' field - if _, hasMessage := jsonPayload["message"]; !hasMessage { - return fmt.Errorf("gotify payload requires 'message' field") - } - case "telegram": - // Telegram requires 'text' field for the message body - if _, hasText := jsonPayload["text"]; !hasText { - if messageValue, hasMessage := jsonPayload["message"]; hasMessage { - jsonPayload["text"] = messageValue - normalizedBody, marshalErr := json.Marshal(jsonPayload) - if marshalErr != nil { - return fmt.Errorf("failed to normalize telegram payload: %w", marshalErr) - } - body.Reset() - if _, writeErr := body.Write(normalizedBody); writeErr != nil { - return fmt.Errorf("failed to write normalized telegram payload: %w", writeErr) - } - } else { - return fmt.Errorf("telegram payload requires 'text' field") - } - } - case "pushover": - if _, hasMessage := jsonPayload["message"]; !hasMessage { - return fmt.Errorf("pushover payload requires 'message' field") - } - if priority, ok := jsonPayload["priority"]; ok { - if p, isFloat := priority.(float64); isFloat && p == 2 { - return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported") - } - } - case "ntfy": - if _, hasMessage := jsonPayload["message"]; !hasMessage { - return fmt.Errorf("ntfy payload must include a 'message' field") - } - } - - if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" { - headers := map[string]string{ - "Content-Type": "application/json", - "User-Agent": "Charon-Notify/1.0", - } - if rid := ctx.Value(trace.RequestIDKey); rid != nil { - if ridStr, ok := rid.(string); ok { - headers["X-Request-ID"] = ridStr - } - } - - dispatchURL := p.URL - - if providerType == "gotify" { - if strings.TrimSpace(p.Token) != "" { - headers["X-Gotify-Key"] = strings.TrimSpace(p.Token) - } - } - - if providerType == "telegram" { - decryptedToken := p.Token - telegramBase := s.telegramAPIBaseURL - if telegramBase == "" { - telegramBase = "https://api.telegram.org" - } - dispatchURL = telegramBase + "/bot" + decryptedToken + "/sendMessage" - - parsedURL, parseErr := neturl.Parse(dispatchURL) - expectedHost := "api.telegram.org" - if parsedURL != nil && parsedURL.Hostname() != "" && telegramBase != "https://api.telegram.org" { - // In test overrides, skip the hostname pin check. - expectedHost = parsedURL.Hostname() - } - if parseErr != nil || parsedURL.Hostname() != expectedHost { - return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname") - } - - jsonPayload["chat_id"] = p.URL - updatedBody, marshalErr := json.Marshal(jsonPayload) - if marshalErr != nil { - return fmt.Errorf("failed to marshal telegram payload with chat_id: %w", marshalErr) - } - body.Reset() - body.Write(updatedBody) - } - - if providerType == "slack" { - decryptedWebhookURL := p.Token - if strings.TrimSpace(decryptedWebhookURL) == "" { - return fmt.Errorf("slack webhook URL is not configured") - } - if validateErr := s.validateSlackURL(decryptedWebhookURL); validateErr != nil { - return validateErr - } - dispatchURL = decryptedWebhookURL - } - - if providerType == "ntfy" { - if strings.TrimSpace(p.Token) != "" { - headers["Authorization"] = "Bearer " + strings.TrimSpace(p.Token) - } - } - - if providerType == "pushover" { - decryptedToken := p.Token - if strings.TrimSpace(decryptedToken) == "" { - return fmt.Errorf("pushover API token is not configured") - } - if strings.TrimSpace(p.URL) == "" { - return fmt.Errorf("pushover user key is not configured") - } - - pushoverBase := s.pushoverAPIBaseURL - if pushoverBase == "" { - pushoverBase = "https://api.pushover.net" - } - dispatchURL = pushoverBase + "/1/messages.json" - - parsedURL, parseErr := neturl.Parse(dispatchURL) - expectedHost := "api.pushover.net" - if parsedURL != nil && parsedURL.Hostname() != "" && pushoverBase != "https://api.pushover.net" { - expectedHost = parsedURL.Hostname() - } - if parseErr != nil || parsedURL.Hostname() != expectedHost { - return fmt.Errorf("pushover dispatch URL validation failed: invalid hostname") - } - - jsonPayload["token"] = decryptedToken - jsonPayload["user"] = p.URL - - updatedBody, marshalErr := json.Marshal(jsonPayload) - if marshalErr != nil { - return fmt.Errorf("failed to marshal pushover payload: %w", marshalErr) - } - body.Reset() - body.Write(updatedBody) - } - - if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{ - URL: dispatchURL, - Headers: headers, - Body: body.Bytes(), - }); sendErr != nil { - return fmt.Errorf("failed to send webhook: %w", sendErr) - } - return nil - } - - validatedURLStr, err := security.ValidateExternalURL(p.URL, - security.WithAllowHTTP(), - security.WithAllowLocalhost(), - ) - if err != nil { - return fmt.Errorf("invalid webhook url: %w", err) - } - - client := network.NewSafeHTTPClient( - network.WithTimeout(10*time.Second), - network.WithAllowLocalhost(), - ) - - req, err := http.NewRequestWithContext(ctx, "POST", validatedURLStr, &body) - if err != nil { - return fmt.Errorf("failed to create webhook request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - if rid := ctx.Value(trace.RequestIDKey); rid != nil { - if ridStr, ok := rid.(string); ok { - req.Header.Set("X-Request-ID", ridStr) - } - } - - resp, err := webhookDoRequestFunc(client, req) - if err != nil { - return fmt.Errorf("failed to send webhook: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - logger.Log().WithError(err).Warn("failed to close webhook response body") - } - }() - - if resp.StatusCode >= 400 { - return fmt.Errorf("webhook returned status: %d", resp.StatusCode) - } - return nil -} - -// isPrivateIP returns true for RFC1918, loopback and link-local addresses. -// This wraps network.IsPrivateIP for backward compatibility and local use. -func isPrivateIP(ip net.IP) bool { - return network.IsPrivateIP(ip) -} - -func isValidRedirectURL(rawURL string) bool { - u, err := neturl.Parse(rawURL) - if err != nil { - return false - } - if u.Scheme != "http" && u.Scheme != "https" { - return false - } - if u.Hostname() == "" { - return false - } - return true -} - func (s *NotificationService) TestProvider(provider models.NotificationProvider) error { providerType := strings.ToLower(strings.TrimSpace(provider.Type)) if !isSupportedNotificationProviderType(providerType) { @@ -710,46 +408,73 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return fmt.Errorf("provider type %q does not support JSON templates", providerType) } - data := map[string]any{ - "Title": "Test Notification", - "Message": "This is a test notification from Charon", - "Status": "TEST", - "Name": "Test Monitor", - "Latency": 123, - "Time": time.Now().Format(time.RFC3339), + return s.testProviderViaNotify(provider) +} + +// testProviderViaNotify sends a test notification through the extracted +// notify module (buildNotifySender, notify_provider_adapter.go). +func (s *NotificationService) testProviderViaNotify(provider models.NotificationProvider) error { + sender, err := buildNotifySender(provider, s.notifyWrapper) + if err != nil { + return fmt.Errorf("build notify sender: %w", err) } - return s.sendJSONPayload(context.Background(), provider, data) + + msg := notify.Message{ + Title: "Test Notification", + Body: "This is a test notification from Charon", + EventType: "test", + } + return sender.Send(context.Background(), msg) } -// TestEmailProvider sends a test email to the recipients configured in provider.URL. -// It bypasses the JSON-template path used by TestProvider and uses the SMTP mail service directly. +// TestEmailProvider sends a test email to the recipients configured in +// provider.URL, dispatched through the extracted notify module's email +// package (providers/email) the same way TestEmailProvider's real-dispatch +// counterpart (dispatchEmailViaNotify) is. It bypasses the JSON-template +// path used by TestProvider. +// +// This uses its own inline email.Config, rather than NewNotifyEmailConfig +// (notify_email_adapter.go), because the test-send subject prefix +// ("[Charon Test] ") and forced "email_system_event.html" template differ +// from NewNotifyEmailConfig's production values ("[Charon Alert] " and +// emailTemplateForEventType's event-type-based mapping) — matching the old +// TestEmailProvider's hardcoded subject/template exactly. +// +// Behavior note: see dispatchEmailViaNotify's comment — like that path, +// this still falls back to a manually built plain HTML body (via +// mailServiceTemplateRendererAdapter.Render) when template rendering fails, +// and still sends/succeeds. Only a real Mailer/SMTP transport failure +// causes this function to return an error. func (s *NotificationService) TestEmailProvider(provider models.NotificationProvider) error { if s.mailService == nil || !s.mailService.IsConfigured() { return fmt.Errorf("email service is not configured; configure SMTP settings before testing email providers") } - rawRecipients := strings.Split(provider.URL, ",") - recipients := make([]string, 0, len(rawRecipients)) - for _, r := range rawRecipients { - if trimmed := strings.TrimSpace(r); trimmed != "" { - recipients = append(recipients, trimmed) - } - } + + recipients := parseEmailRecipients(provider.URL) if len(recipients) == 0 { return fmt.Errorf("no recipients configured; add at least one recipient email address") } - data := EmailTemplateData{ - EventType: "test", - Title: "Test Notification", - Message: "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.", - Timestamp: time.Now().Format(time.RFC3339), + + cfg := email.Config{ + Recipients: recipients, + SubjectPrefix: "[Charon Test] ", + TemplateName: func(notify.Message) string { + return "email_system_event.html" + }, + Renderer: &mailServiceTemplateRendererAdapter{mailService: s.mailService}, + Mailer: &mailServiceMailerAdapter{mailService: s.mailService}, } - htmlBody, renderErr := s.mailService.RenderNotificationEmail("email_system_event.html", data) - if renderErr != nil { - htmlBody = "Test Notification
This is a test notification from Charon. If you received this email, your email notification provider is configured correctly." + client := email.New(cfg) + + msg := notify.Message{ + Title: "Test Notification", + Body: "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.", + EventType: "test", } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return s.mailService.SendEmail(ctx, recipients, "[Charon Test] Test Notification", htmlBody) + return client.Send(ctx, msg) } // ListTemplates returns all external notification templates stored in the database. @@ -863,11 +588,15 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid provider.Token = "" } - // Validate custom template before creating + // Validate custom template before creating. Uses providers/webhook.RenderPreview + // (the extracted notify module's template-preview function) rather than the old + // RenderTemplate, so preview validation exercises the same TemplateData shape + // (Title/Message/Time/EventType/Data) that dispatchViaNotify's actual dispatch + // uses — a custom template referencing {{index .Data "..."}} now validates + // correctly instead of failing preview with a flat map that had no Data field. if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" { - // Provide a minimal preview payload - payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} - if _, _, err := s.RenderTemplate(*provider, payload); err != nil { + previewMsg := notify.Message{Title: "Preview", Body: "Preview", EventType: "preview"} + if _, _, err := webhook.RenderPreview(provider.Config, previewMsg); err != nil { return fmt.Errorf("invalid custom template: %w", err) } } @@ -909,10 +638,11 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid } } - // Validate custom template before saving + // Validate custom template before saving — see the matching comment in + // CreateProvider for why this uses providers/webhook.RenderPreview. if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" { - payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} - if _, _, err := s.RenderTemplate(*provider, payload); err != nil { + previewMsg := notify.Message{Title: "Preview", Body: "Preview", EventType: "preview"} + if _, _, err := webhook.RenderPreview(provider.Config, previewMsg); err != nil { return fmt.Errorf("invalid custom template: %w", err) } } diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go index 8ca4b9ff0..be6b9e561 100644 --- a/backend/internal/services/notification_service_discord_only_test.go +++ b/backend/internal/services/notification_service_discord_only_test.go @@ -181,6 +181,12 @@ func TestDiscordOnly_UpdateProviderAllowsWebhookUpdates(t *testing.T) { // TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag tests that webhook TestProvider // works without explicit feature flag (bypasses dispatch gate). func TestDiscordOnly_TestProviderAllowsWebhookWithoutFeatureFlag(t *testing.T) { + // Webhook is cut over to the extracted notify module, whose transport + // wrapper gates plain-HTTP/localhost dispatch on CHARON_ENV=test + // explicitly (resolveNotifyAllowHTTP in notify_client_adapter.go) + // rather than the old implicit os.Args[0]-".test"-suffix detection. + t.Setenv("CHARON_ENV", "test") + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Setting{})) diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 3403b5595..8f44924d6 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -3,11 +3,6 @@ package services import ( "context" "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" "testing" "time" @@ -43,32 +38,6 @@ func TestSupportsJSONTemplates(t *testing.T) { } } -func TestSendJSONPayload_DiscordIPHostRejected(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://203.0.113.10/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - "Time": time.Now().Format(time.RFC3339), - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid Discord webhook URL") - assert.Contains(t, err.Error(), "IP address hosts are not allowed") -} - func TestValidateDiscordWebhookURL_AcceptsDiscordHostname(t *testing.T) { err := validateDiscordWebhookURL("https://discord.com/api/webhooks/123456/token_abc?wait=true") assert.NoError(t, err) @@ -84,371 +53,24 @@ func TestValidateDiscordProviderURL_NonDiscordUnchanged(t *testing.T) { assert.NoError(t, err) } -func TestSendJSONPayload_UsesStoredHostnameURLWithoutHostMutation(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - // Mock Discord validation to allow test server URLs - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - var observedURLHost string - var observedRequestHost string - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - observedURLHost = req.URL.Host - observedRequestHost = req.Host - return client.Do(req) //nolint:gosec // G704: test uses a controlled mock server URL - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - parsedServerURL, err := url.Parse(server.URL) - require.NoError(t, err) - parsedServerURL.Host = "localhost:" + parsedServerURL.Port() - - provider := models.NotificationProvider{ - Type: "discord", - URL: parsedServerURL.String(), - Template: "minimal", - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - "Time": time.Now().Format(time.RFC3339), - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - - assert.Equal(t, "localhost:"+parsedServerURL.Port(), observedURLHost) - assert.Equal(t, observedURLHost, observedRequestHost) -} - -func TestSendJSONPayload_Discord(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - - // Discord webhook should have 'content' or 'embeds' - assert.True(t, payload["content"] != nil || payload["embeds"] != nil, "Discord payload should have content or embeds") - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Mock Discord validation to allow test server URL - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "discord", - URL: server.URL, - Template: "custom", - Config: `{"content": {{toJSON .Message}}, "username": "Charon"}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - "Time": time.Now().Format(time.RFC3339), - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) -} - -func TestSendJSONPayload_Slack(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - - // Slack webhook should have 'text' or 'blocks' - assert.True(t, payload["text"] != nil || payload["blocks"] != nil, "Slack payload should have text or blocks") - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - - provider := models.NotificationProvider{ - Type: "slack", - URL: "#test", - Token: server.URL, - Template: "custom", - Config: `{"text": {{toJSON .Message}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) -} - -func TestSendJSONPayload_Gotify(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - - // Gotify webhook should have 'message' - assert.NotNil(t, payload["message"], "Gotify payload should have message field") - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "gotify", - URL: server.URL, - Token: "test-token", - Template: "custom", - Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) -} - -func TestSendJSONPayload_TemplateTimeout(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - // Mock Discord validation to allow private IP check to run - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - // Create a template that would take too long to execute - // This is simulated by having a large number of iterations - // Use a private IP (10.x) which is blocked by SSRF protection to trigger an error - provider := models.NotificationProvider{ - Type: "discord", - URL: "http://10.0.0.1:9999", - Template: "custom", - Config: `{"content": {{toJSON .Message}}, "data": {{toJSON .}}}`, - } - - // Create data that will be processed - data := map[string]any{ - "Message": "Test", - } - - // This should complete quickly, but test the timeout mechanism exists - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - err = svc.sendJSONPayload(ctx, provider, data) - // The private IP is blocked by SSRF protection - // We're mainly testing that the validation and timeout mechanisms are in place - assert.Error(t, err) - assert.Contains(t, err.Error(), "private ip addresses is blocked") -} - -func TestSendJSONPayload_TemplateSizeLimit(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - // Create a template larger than 10KB - largeTemplate := strings.Repeat("x", 11*1024) - - provider := models.NotificationProvider{ - Type: "discord", - URL: "http://localhost:9999", - Template: "custom", - Config: largeTemplate, - } - - data := map[string]any{ - "Message": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "template size exceeds maximum limit") -} - -func TestSendJSONPayload_DiscordValidation(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://203.0.113.10/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"username": "Charon", "message": {{toJSON .Message}}}`, - } - - data := map[string]any{ - "Message": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid Discord webhook URL") - assert.Contains(t, err.Error(), "IP address hosts are not allowed") -} - -func TestSendJSONPayload_DiscordValidation_MissingMessage(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"username": "Charon"}`, - } - - data := map[string]any{} - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "discord payload requires 'content' or 'embeds'") -} - -func TestSendJSONPayload_SlackValidation(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - // Slack payload without text or blocks should fail - provider := models.NotificationProvider{ - Type: "slack", - URL: "http://localhost:9999", - Template: "custom", - Config: `{"username": "Charon"}`, - } - - data := map[string]any{ - "Message": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks'") -} - -func TestSendJSONPayload_GotifyValidation(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - // Gotify payload without message should fail - provider := models.NotificationProvider{ - Type: "gotify", - URL: "http://localhost:9999", - Template: "custom", - Config: `{"title": "Test"}`, - } - - data := map[string]any{ - "Message": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "gotify payload requires 'message'") -} - -func TestSendJSONPayload_InvalidJSON(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "discord", - URL: "http://localhost:9999", - Template: "custom", - Config: `{invalid json}`, - } - - data := map[string]any{ - "Message": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) -} - -func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) { - got := normalizeURL("discord", "https://discord.com/api/webhooks/123/abcDEF_123") - assert.Equal(t, "discord://abcDEF_123@123", got) - - got2 := normalizeURL("discord", "https://discordapp.com/api/webhooks/456/xyz") - assert.Equal(t, "discord://xyz@456", got2) -} - +// TestSendExternal_UsesJSONForSupportedServices exercises Discord dispatch +// after its cutover to the extracted notify module (buildNotifySender). +// Discord's own webhook validation (providers/discord.ValidateWebhookURL) +// only accepts discord.com/canary.discord.com hosts, so an httptest.Server +// URL (as used before cutover) can no longer stand in for a Discord +// webhook — the test instead injects a capturing fake RoundTripper via +// WithNotifyTransportWrapper, matching the pattern +// notify_provider_adapter_test.go uses to test buildNotifySender directly. func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - var called atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called.Store(true) - var payload map[string]any - _ = json.NewDecoder(r.Body).Decode(&payload) - assert.NotNil(t, payload["content"]) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Mock Discord validation to allow test server URL - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + wrapper, rt := newCapturingWrapper() provider := models.NotificationProvider{ Type: "discord", - URL: server.URL, + URL: "https://discord.com/api/webhooks/123456789/notify-json-token", Template: "custom", Config: `{"content": {{toJSON .Message}}}`, Enabled: true, @@ -456,301 +78,45 @@ func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { } db.Create(&provider) - svc := NewNotificationService(db, nil) + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) - // Give goroutine time to execute - time.Sleep(100 * time.Millisecond) - assert.True(t, called.Load(), "notification should have been sent via JSON") + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "notification should have been sent via JSON") + + _, body := rt.last() + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotNil(t, payload["content"]) } +// TestTestProvider_UsesJSONForSupportedServices is the TestProvider +// (test-send) counterpart of TestSendExternal_UsesJSONForSupportedServices +// — see its comment for why a capturing fake RoundTripper replaces the old +// httptest.Server + validateDiscordProviderURLFunc override. func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - assert.NotNil(t, payload["content"]) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Mock Discord validation to allow test server URL - origValidateDiscordFunc := validateDiscordProviderURLFunc - origWebhookDoReq := webhookDoRequestFunc - defer func() { - validateDiscordProviderURLFunc = origValidateDiscordFunc - webhookDoRequestFunc = origWebhookDoReq - }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return client.Do(req) //nolint:gosec // G704: test-controlled httptest server, not user input - } + wrapper, rt := newCapturingWrapper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db, nil) + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Type: "discord", - URL: server.URL, + URL: "https://discord.com/api/webhooks/123456789/notify-json-test-token", Template: "custom", Config: `{"content": {{toJSON .Message}}}`, } err = svc.TestProvider(provider) assert.NoError(t, err) -} - -func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) { - var capturedPayload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := json.NewDecoder(r.Body).Decode(&capturedPayload) - require.NoError(t, err) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - svc.telegramAPIBaseURL = server.URL - - provider := models.NotificationProvider{ //nolint:gosec // G101: test credential - Type: "telegram", - URL: "123456789", - Token: "bot-test-token", - Template: "custom", - Config: `{"text": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - } - - sendErr := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, sendErr) - assert.NotNil(t, capturedPayload["text"], "Telegram payload should have text field") - assert.NotNil(t, capturedPayload["chat_id"], "Telegram payload should have chat_id field") -} - -func TestSendJSONPayload_Telegram_AutoMapMessageToText(t *testing.T) { - var capturedPayload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewDecoder(r.Body).Decode(&capturedPayload) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - svc.telegramAPIBaseURL = server.URL - - provider := models.NotificationProvider{ //nolint:gosec // G101: test credential - Type: "telegram", - URL: "123456789", - Token: "bot-test-token", - Template: "custom", - Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - } - - sendErr := svc.sendJSONPayload(context.Background(), provider, data) - // 'message' must be auto-mapped to 'text' — dispatch must succeed. - require.NoError(t, sendErr) - assert.Equal(t, "Test notification", capturedPayload["text"], "'message' should be auto-mapped to 'text'") -} - -func TestSendJSONPayload_Telegram_MissingTextAndMessage(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ //nolint:gosec // G101: test credential - Type: "telegram", - URL: "123456789", - Token: "bot-test-token", - Template: "custom", - Config: `{"title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Title": "Test", - } - - sendErr := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, sendErr) - assert.Contains(t, sendErr.Error(), "telegram payload requires 'text' field") -} - -func TestSendJSONPayload_Telegram_SSRFValidation(t *testing.T) { - var capturedPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - svc.telegramAPIBaseURL = server.URL - - // Path traversal in token: Go's net/http transport cleans the URL path, - // so "/../../../evil.com/x" does not escape the server host. - provider := models.NotificationProvider{ //nolint:gosec // G101: test credential - Type: "telegram", - URL: "123456789", - Token: "test-token/../../../evil.com/x", - Template: "custom", - Config: `{"text": {{toJSON .Message}}}`, - } - - data := map[string]any{ - "Message": "Test", - } - - sendErr := svc.sendJSONPayload(context.Background(), provider, data) - // Dispatch must succeed (no validation error) — the path traversal in the - // token cannot redirect the request to a different host. The request was - // received by our local server, not by evil.com. - require.NoError(t, sendErr) - // capturedPath is non-empty only if our server handled the request. - assert.NotEmpty(t, capturedPath, "request must have been served by the local test server, not redirected to evil.com") -} - -func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) { - // Use a webhook provider with a mock server returning 401 to verify - // that the dispatch path surfaces "provider returned status 401" in the error. - // Telegram cannot be tested this way because its SSRF check requires api.telegram.org. - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "webhook", - URL: server.URL, - Template: "custom", - Config: `{"message": {{toJSON .Message}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - } - - sendErr := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, sendErr) - assert.Contains(t, sendErr.Error(), "provider returned status 401") -} - -func TestSendJSONPayload_Ntfy_Valid(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - assert.Empty(t, r.Header.Get("Authorization"), "no auth header when token is empty") - - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - assert.NotNil(t, payload["message"], "ntfy payload should have message field") - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "ntfy", - URL: server.URL, - Template: "custom", - Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) -} - -func TestSendJSONPayload_Ntfy_WithToken(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer tk_test123", r.Header.Get("Authorization")) - - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - assert.NotNil(t, payload["message"]) - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "ntfy", - URL: server.URL, - Token: "tk_test123", - Template: "custom", - Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - - data := map[string]any{ - "Message": "Test notification", - "Title": "Test", - } - - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) -} - -func TestSendJSONPayload_Ntfy_MissingMessage(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - - svc := NewNotificationService(db, nil) - - provider := models.NotificationProvider{ - Type: "ntfy", - URL: "http://localhost:9999", - Template: "custom", - Config: `{"title": "Test"}`, - } - - data := map[string]any{ - "Message": "Test", - } - err = svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ntfy payload must include a 'message' field") + _, body := rt.last() + require.NotNil(t, body) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotNil(t, payload["content"]) } diff --git a/backend/internal/services/notification_service_registry_consistency_test.go b/backend/internal/services/notification_service_registry_consistency_test.go new file mode 100644 index 000000000..3ba76d8fd --- /dev/null +++ b/backend/internal/services/notification_service_registry_consistency_test.go @@ -0,0 +1,47 @@ +package services + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" +) + +// TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes is a +// build-configuration drift guard, not a runtime assertion (per +// docs/plans/notify_provider_registry_spec.md §3.6.2/§3.10): it asserts +// that every provider type isSupportedNotificationProviderType claims to +// support is actually registered in the go_notify_yourself registry given +// Charon's hand-picked blank imports (notify_providers_import.go). +// +// This deliberately does NOT run in the other direction — the registry may +// (and, per notify_providers_import.go's doc comment, currently does not) +// contain types Charon's allowlist doesn't yet expose; that's the expected, +// intentional state per §3.6.2 Option A (Charon curates its own supported +// surface independently of what's merely constructible). If this test ever +// fails, it means Charon's allowlist claims to support a provider type +// whose package was never blank-imported for registration — a drift bug to +// fix by adding the missing import, not by touching +// isSupportedNotificationProviderType itself. +func TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes(t *testing.T) { + registered := make(map[string]struct{}, len(notify.RegisteredTypes())) + for _, name := range notify.RegisteredTypes() { + registered[name] = struct{}{} + } + + // Mirrors isSupportedNotificationProviderType's exact case list + // (notification_service.go) — kept as a literal list here rather than + // derived from the function itself, since that switch has no + // enumerable form to introspect. + supportedTypes := []string{"discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy"} + + for _, providerType := range supportedTypes { + if !isSupportedNotificationProviderType(providerType) { + t.Fatalf("test bug: %q is not actually in isSupportedNotificationProviderType's allowlist", providerType) + } + if _, ok := registered[providerType]; !ok { + t.Errorf("provider type %q is in isSupportedNotificationProviderType's allowlist but is not registered "+ + "in the notify registry (registered types: %v) — a package that registers it under notify.Register "+ + "is missing a blank import in notify_providers_import.go", providerType, notify.RegisteredTypes()) + } + } +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 89a085e7e..6ce15513e 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -5,21 +5,19 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "net/http/httptest" "os" "path/filepath" - "strings" "sync" "sync/atomic" "testing" "time" + "github.com/Wikid82/go_notify_yourself/transport" + "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/notifications" "github.com/Wikid82/charon/backend/internal/security" - "github.com/Wikid82/charon/backend/internal/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -128,65 +126,50 @@ func TestNotificationService_Providers(t *testing.T) { assert.Len(t, list, 0) } +// TestNotificationService_TestProvider_Webhook (despite its name, this +// exercises a Discord provider) verifies TestProvider dispatch after +// Discord's cutover to the extracted notify module (buildNotifySender). +// Discord's own webhook validation only accepts discord.com/ +// canary.discord.com hosts, so it can no longer be pointed at an +// httptest.Server the way pre-cutover tests could — a capturing fake +// RoundTripper (via WithNotifyTransportWrapper) stands in instead, mirroring +// notify_provider_adapter_test.go's pattern for testing buildNotifySender. func TestNotificationService_TestProvider_Webhook(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // Mock validation and webhook request for testing - origValidateDiscordFunc := validateDiscordProviderURLFunc - origWebhookDoReq := webhookDoRequestFunc - defer func() { - validateDiscordProviderURLFunc = origValidateDiscordFunc - webhookDoRequestFunc = origWebhookDoReq - }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil - } - - // Start a test server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - // Minimal template uses lowercase keys: title, message - assert.Equal(t, "Test Notification", body["title"]) - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "Test Discord", Type: "discord", - URL: ts.URL, + URL: "https://discord.com/api/webhooks/123456789/webhook-test-token", Template: "minimal", - Config: `{"Header": "{{.Title}}"}`, } err := svc.TestProvider(provider) require.NoError(t, err) + + _, body := rt.last() + require.NotNil(t, body) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + // Minimal template uses lowercase keys: title, message + assert.Equal(t, "Test Notification", payload["title"]) } +// TestNotificationService_SendExternal exercises SendExternal's async +// Discord dispatch after cutover — see +// TestNotificationService_TestProvider_Webhook's comment for why a +// capturing fake RoundTripper replaces the old httptest.Server. func TestNotificationService_SendExternal(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - received := make(chan struct{}) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(received) - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - // Mock discord webhook validation to allow test server URLs - // Do NOT mock webhookDoRequestFunc - we want real HTTP call to test server - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "Test Discord", Type: "discord", - URL: ts.URL, + URL: "https://discord.com/api/webhooks/123456789/send-external-token", Enabled: true, NotifyProxyHosts: true, Template: "minimal", @@ -195,94 +178,81 @@ func TestNotificationService_SendExternal(t *testing.T) { svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) - select { - case <-received: - // Success - case <-time.After(1 * time.Second): - t.Fatal("Timed out waiting for webhook") - } + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "Timed out waiting for webhook") } +// TestNotificationService_SendExternal_MinimalVsDetailedTemplates verifies +// both built-in templates render correctly for a cut-over Discord provider. +// Each phase uses its own capturing wrapper/service instance, and the +// minimal-template provider is deleted before the detailed phase runs, so +// SendExternal's per-provider fan-out never dispatches both providers to +// the same capturing wrapper at once (which would race the "last request" +// assertions below). func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // Mock validation only - allow real HTTP calls to test servers - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - // Minimal template - rcvMinimal := make(chan map[string]any, 1) - tsMin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - rcvMinimal <- body - w.WriteHeader(http.StatusOK) - })) - defer tsMin.Close() + // Minimal template phase + wrapperMin, rtMin := newCapturingWrapper() + svcMin := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapperMin)) providerMin := models.NotificationProvider{ Name: "Minimal Discord", Type: "discord", - URL: tsMin.URL, + URL: "https://discord.com/api/webhooks/1/minimal-token", Enabled: true, NotifyUptime: true, Template: "minimal", } - _ = svc.CreateProvider(&providerMin) + require.NoError(t, svcMin.CreateProvider(&providerMin)) data := map[string]any{"Title": "Min Title", "Message": "Min Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime"} - svc.SendExternal(context.Background(), "uptime", "Min Title", "Min Message", data) + svcMin.SendExternal(context.Background(), "uptime", "Min Title", "Min Message", data) - select { - case body := <-rcvMinimal: - // minimal template should contain 'title' and 'message' keys - if title, ok := body["title"].(string); ok { - assert.Equal(t, "Min Title", title) - } else { - t.Fatalf("expected title in minimal body") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("Timeout waiting for minimal webhook") - } + require.Eventually(t, func() bool { + _, body := rtMin.last() + return body != nil + }, 500*time.Millisecond, 10*time.Millisecond, "Timeout waiting for minimal webhook") - // Detailed template - rcvDetailed := make(chan map[string]any, 1) - tsDet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - rcvDetailed <- body - w.WriteHeader(http.StatusOK) - })) - defer tsDet.Close() + _, minBody := rtMin.last() + var minPayload map[string]any + require.NoError(t, json.Unmarshal(minBody, &minPayload)) + // minimal template should contain 'title' and 'message' keys + assert.Equal(t, "Min Title", minPayload["title"]) + + require.NoError(t, svcMin.DeleteProvider(providerMin.ID)) + + // Detailed template phase + wrapperDet, rtDet := newCapturingWrapper() + svcDet := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapperDet)) providerDet := models.NotificationProvider{ Name: "Detailed Discord", Type: "discord", - URL: tsDet.URL, + URL: "https://discord.com/api/webhooks/2/detailed-token", Enabled: true, NotifyUptime: true, Template: "detailed", } - _ = svc.CreateProvider(&providerDet) + require.NoError(t, svcDet.CreateProvider(&providerDet)) dataDet := map[string]any{"Title": "Det Title", "Message": "Det Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime", "HostName": "example-host", "HostIP": "1.2.3.4", "ServiceCount": 1, "Services": []map[string]any{{"Name": "svc1"}}} - svc.SendExternal(context.Background(), "uptime", "Det Title", "Det Message", dataDet) + svcDet.SendExternal(context.Background(), "uptime", "Det Title", "Det Message", dataDet) - select { - case body := <-rcvDetailed: - // detailed template should contain 'host' and 'services' - if host, ok := body["host"].(string); ok { - assert.Equal(t, "example-host", host) - } else { - t.Fatalf("expected host in detailed body") - } - if _, ok := body["services"]; !ok { - t.Fatalf("expected services in detailed body") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("Timeout waiting for detailed webhook") + require.Eventually(t, func() bool { + _, body := rtDet.last() + return body != nil + }, 500*time.Millisecond, 10*time.Millisecond, "Timeout waiting for detailed webhook") + + _, detBody := rtDet.last() + var detPayload map[string]any + require.NoError(t, json.Unmarshal(detBody, &detPayload)) + // detailed template should contain 'host' and 'services' + assert.Equal(t, "example-host", detPayload["host"]) + if _, ok := detPayload["services"]; !ok { + t.Fatalf("expected services in detailed body") } } @@ -318,181 +288,6 @@ func TestNotificationService_SendExternal_Filtered(t *testing.T) { } } -func TestNormalizeURL(t *testing.T) { - tests := []struct { - name string - serviceType string - rawURL string - expected string - }{ - { - name: "Discord HTTPS", - serviceType: "discord", - rawURL: "https://discord.com/api/webhooks/123456789/abcdefg", - expected: "discord://abcdefg@123456789", - }, - { - name: "Discord HTTPS with app", - serviceType: "discord", - rawURL: "https://discordapp.com/api/webhooks/123456789/abcdefg", - expected: "discord://abcdefg@123456789", - }, - { - name: "Discord Generic", - serviceType: "discord", - rawURL: "discord://token@id", - expected: "discord://token@id", - }, - { - name: "Other Service", - serviceType: "slack", - rawURL: "https://hooks.slack.com/services/...", - expected: "https://hooks.slack.com/services/...", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeURL(tt.serviceType, tt.rawURL) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - t.Run("invalid URL", func(t *testing.T) { - provider := models.NotificationProvider{ - Type: "webhook", - URL: "://invalid-url", - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - }) - - t.Run("unreachable host", func(t *testing.T) { - provider := models.NotificationProvider{ - Type: "webhook", - URL: "http://192.0.2.1:9999", // TEST-NET-1, unreachable - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - // Set short timeout for client if possible, but here we just expect error - // Note: http.Client default timeout is 0 (no timeout), but OS might timeout - // We can't easily change client timeout here without modifying service - // So we might skip this or just check if it returns error eventually - // But for unit test speed, we should probably mock or use a closed port on localhost - // Using a closed port on localhost is faster - provider.URL = "http://127.0.0.1:54321" // Assuming this port is closed - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - }) - - t.Run("server returns error", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer ts.Close() - - provider := models.NotificationProvider{ - Type: "webhook", - URL: ts.URL, - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "500") - }) - - t.Run("valid custom payload template", func(t *testing.T) { - receivedBody := "" - received := make(chan struct{}) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - if custom, ok := body["custom"]; ok { - receivedBody = custom.(string) - } - w.WriteHeader(http.StatusOK) - close(received) - })) - defer ts.Close() - - provider := models.NotificationProvider{ - Type: "webhook", - URL: ts.URL, - Config: `{"custom": "Test: {{.Title}}"}`, - } - data := map[string]any{"Title": "My Title", "Message": "Test Message"} - _ = svc.sendJSONPayload(context.Background(), provider, data) - - select { - case <-received: - assert.Equal(t, "Test: My Title", receivedBody) - case <-time.After(500 * time.Millisecond): - t.Fatal("Timeout waiting for webhook") - } - }) - - t.Run("default payload without template", func(t *testing.T) { - receivedContent := "" - received := make(chan struct{}) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - if title, ok := body["title"]; ok { - receivedContent = title.(string) - } - w.WriteHeader(http.StatusOK) - close(received) - })) - defer ts.Close() - - provider := models.NotificationProvider{ - Type: "webhook", - URL: ts.URL, - // Config is empty, so default template is used: minimal - } - data := map[string]any{"Title": "Default Title", "Message": "Test Message"} - _ = svc.sendJSONPayload(context.Background(), provider, data) - - select { - case <-received: - assert.Equal(t, "Default Title", receivedContent) - case <-time.After(500 * time.Millisecond): - t.Fatal("Timeout waiting for webhook") - } - }) -} - -func TestNotificationService_SendCustomWebhook_PropagatesRequestID(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - received := make(chan string, 1) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - received <- r.Header.Get("X-Request-ID") - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - provider := models.NotificationProvider{Type: "webhook", URL: ts.URL} - data := map[string]any{"Title": "Test", "Message": "Test"} - // Build context with requestID value - ctx := context.WithValue(context.Background(), trace.RequestIDKey, "my-rid") - err := svc.sendJSONPayload(ctx, provider, data) - require.NoError(t, err) - - select { - case rid := <-received: - assert.Equal(t, "my-rid", rid) - case <-time.After(500 * time.Millisecond): - t.Fatal("Timed out waiting for webhook request") - } -} - func TestNotificationService_TestProvider_Errors(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) @@ -529,27 +324,17 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { }) t.Run("webhook success", func(t *testing.T) { - // Mock validation and webhook request for testing - origValidateDiscordFunc := validateDiscordProviderURLFunc - origWebhookDoReq := webhookDoRequestFunc - defer func() { - validateDiscordProviderURLFunc = origValidateDiscordFunc - webhookDoRequestFunc = origWebhookDoReq - }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() + // Discord's own webhook validation only accepts discord.com/ + // canary.discord.com hosts (see TestNotificationService_SendExternal's + // comment), so a capturing fake RoundTripper stands in for the old + // httptest.Server here. + wrapper, _ := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Type: "discord", - URL: ts.URL, - Template: "minimal", // Use JSON template path which supports HTTP/HTTPS + URL: "https://discord.com/api/webhooks/1/webhook-success-token", + Template: "minimal", } err := svc.TestProvider(provider) assert.NoError(t, err) @@ -619,48 +404,6 @@ func TestSSRF_URLValidation_ComprehensiveBlocking(t *testing.T) { } } -func TestSSRF_WebhookIntegration(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - t.Run("blocks private IP webhook", func(t *testing.T) { - provider := models.NotificationProvider{ - Type: "webhook", - URL: "http://10.0.0.1/webhook", - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "destination URL validation failed") - }) - - t.Run("blocks cloud metadata endpoint", func(t *testing.T) { - provider := models.NotificationProvider{ - Type: "webhook", - URL: "http://169.254.169.254/latest/meta-data/", - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.Error(t, err) - assert.Contains(t, err.Error(), "destination URL validation failed") - }) - - t.Run("allows localhost for testing", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - provider := models.NotificationProvider{ - Type: "webhook", - URL: ts.URL, - } - data := map[string]any{"Title": "Test", "Message": "Test Message"} - err := svc.sendJSONPayload(context.Background(), provider, data) - assert.NoError(t, err) - }) -} - func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("no enabled providers", func(t *testing.T) { db := setupNotificationTestDB(t) @@ -724,45 +467,52 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { time.Sleep(50 * time.Millisecond) }) + // TestNotificationService_SendExternal_EdgeCases/custom_data_passed_to_webhook + // covers a cut-over Discord provider configured with the "detailed" + // template, verifying that SendExternal's HostName extra (passed in the + // `data` map, same as before cutover) still reaches the rendered + // payload via legacyDetailedTemplate's backward-compat translation + // (notify_provider_adapter.go). Note a scope change from the + // pre-cutover version of this test: sendJSONPayload's old flat data map + // exposed ANY caller-supplied key (e.g. an arbitrary "CustomField") to + // a *custom* template at its top level. The extracted notify module's + // render.TemplateData only exposes Title/Message/Time/EventType/Data, + // and dispatchViaNotify (notification_service.go) only populates Data + // with the four documented keys — a "custom" template referencing + // {{index .Data "HostName"}} would additionally fail CreateProvider's + // preview-validation step until the webhook commit (§6 commit 9) + // updates RenderTemplate's call sites to the new preview payload shape + // — so this test exercises the documented Data contract via the + // "detailed" template instead, which bypasses that preview validation. t.Run("custom data passed to webhook", func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // Mock validation only - allow real HTTP calls to test server - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - var receivedCustom atomic.Value - receivedCustom.Store("") - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - if custom, ok := body["custom"]; ok { - receivedCustom.Store(custom.(string)) - } - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "Custom Data Discord", Type: "discord", - URL: ts.URL, + URL: "https://discord.com/api/webhooks/1/custom-data-token", Enabled: true, NotifyProxyHosts: true, - Config: `{"content": {{toJSON .Message}}, "custom": "{{.CustomField}}"}`, - Template: "custom", // Use custom template to enable Config + Template: "detailed", } - _ = svc.CreateProvider(&provider) + require.NoError(t, svc.CreateProvider(&provider)) customData := map[string]any{ - "CustomField": "test-value", + "HostName": "test-value", } svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", customData) - time.Sleep(100 * time.Millisecond) - assert.Equal(t, "test-value", receivedCustom.Load().(string)) + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "expected webhook to be sent") + + _, body := rt.last() + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "test-value", payload["host"]) }) } @@ -828,40 +578,6 @@ func TestNotificationService_CreateProvider_Validation(t *testing.T) { }) } -func TestNotificationService_IsPrivateIP(t *testing.T) { - tests := []struct { - name string - ipStr string - isPrivate bool - }{ - {"loopback ipv4", "127.0.0.1", true}, - {"loopback ipv6", "::1", true}, - {"private 10.x", "10.0.0.1", true}, - {"private 10.x high", "10.255.255.254", true}, - {"private 172.16-31", "172.16.0.1", true}, - {"private 172.31", "172.31.255.254", true}, - {"private 192.168", "192.168.1.1", true}, - {"public 172.32", "172.32.0.1", false}, - {"public 172.15", "172.15.0.1", false}, - {"public ip", "8.8.8.8", false}, - {"public ipv6", "2001:4860:4860::8888", false}, - {"link local ipv4", "169.254.1.1", true}, - {"link local ipv6", "fe80::1", true}, - {"unique local ipv6 fc", "fc00::1", true}, - {"unique local ipv6 fc high", "fc12:3456::1", true}, - {"unique local ipv6 fd", "fd00::1", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip := net.ParseIP(tt.ipStr) - require.NotNil(t, ip, "failed to parse IP: %s", tt.ipStr) - got := isPrivateIP(ip) - assert.Equal(t, tt.isPrivate, got, "IP %s private check mismatch", tt.ipStr) - }) - } -} - func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) @@ -968,174 +684,6 @@ func TestRenderTemplate_InvalidJSONOutput(t *testing.T) { assert.Nil(t, parsed) } -func TestSendCustomWebhook_HTTPStatusCodeErrors(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - errorCodes := []int{400, 404, 500, 502, 503} - - for _, statusCode := range errorCodes { - t.Run(fmt.Sprintf("status_%d", statusCode), func(t *testing.T) { - // Mock webhook HTTP client to return error status - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: statusCode, - Body: http.NoBody, - Header: make(http.Header), - }, nil - } - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/test_token", - Template: "minimal", - } - - data := map[string]any{ - "Title": "Test", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("%d", statusCode)) - }) - } -} - -func TestSendCustomWebhook_TemplateSelection(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - tests := []struct { - name string - template string - config string - expectedKeys []string - unexpectedKeys []string - }{ - { - name: "minimal template", - template: "minimal", - expectedKeys: []string{"title", "message", "time", "event"}, - }, - { - name: "detailed template", - template: "detailed", - expectedKeys: []string{"title", "message", "time", "event", "host", "host_ip", "service_count", "services"}, - }, - { - name: "custom template", - template: "custom", - config: `{"custom_key": "custom_value", "content": {{toJSON .Title}}}`, - expectedKeys: []string{"custom_key", "content"}, - }, - { - name: "empty template defaults to minimal", - template: "", - expectedKeys: []string{"title", "message", "time", "event"}, - }, - { - name: "unknown template defaults to minimal", - template: "unknown", - expectedKeys: []string{"title", "message", "time", "event"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var receivedBody map[string]any - - // Mock webhook HTTP client to capture request - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - body, _ := io.ReadAll(req.Body) - _ = json.Unmarshal(body, &receivedBody) - return &http.Response{ - StatusCode: http.StatusOK, - Body: http.NoBody, - Header: make(http.Header), - }, nil - } - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/test_token", - Template: tt.template, - Config: tt.config, - } - - data := map[string]any{ - "Title": "Test Title", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - "HostName": "testhost", - "HostIP": "192.168.1.1", - "ServiceCount": 3, - "Services": []string{"svc1", "svc2"}, - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - - for _, key := range tt.expectedKeys { - assert.Contains(t, receivedBody, key, "Expected key %s in response", key) - } - - for _, key := range tt.unexpectedKeys { - assert.NotContains(t, receivedBody, key, "Unexpected key %s in response", key) - } - }) - } -} - -func TestSendCustomWebhook_EmptyCustomTemplateDefaultsToMinimal(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - var receivedBody map[string]any - - // Mock webhook HTTP client - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - body, _ := io.ReadAll(req.Body) - _ = json.Unmarshal(body, &receivedBody) - return &http.Response{ - StatusCode: http.StatusOK, - Body: http.NoBody, - Header: make(http.Header), - }, nil - } - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/test_token", - Template: "custom", - Config: "", // Empty config should default to minimal - } - - data := map[string]any{ - "Title": "Test", - "Message": "Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - - // Should use minimal template - assert.Equal(t, "Test", receivedBody["title"]) - assert.Equal(t, "Message", receivedBody["message"]) -} - func TestCreateProvider_EmptyCustomTemplateAllowed(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) @@ -1173,76 +721,9 @@ func TestUpdateProvider_NonCustomTemplateSkipsValidation(t *testing.T) { require.NoError(t, err) // Should succeed because detailed template doesn't use Config } -func TestIsPrivateIP_EdgeCases(t *testing.T) { - tests := []struct { - name string - ip string - isPrivate bool - }{ - // Boundary testing for 172.16-31 range - {"172.15.255.255 (just before private)", "172.15.255.255", false}, - {"172.16.0.0 (start of private)", "172.16.0.0", true}, - {"172.31.255.255 (end of private)", "172.31.255.255", true}, - {"172.32.0.0 (just after private)", "172.32.0.0", false}, - - // IPv6 unique local address boundaries - {"fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before ULA)", "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false}, - {"fc00::0 (start of ULA)", "fc00::0", true}, - {"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of ULA)", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true}, - {"fe00::0 (after ULA)", "fe00::0", false}, - - // IPv6 link-local boundaries - {"fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff (before link-local)", "fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false}, - {"fe80::0 (start of link-local)", "fe80::0", true}, - {"febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff (end of link-local)", "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true}, - {"fec0::0 (after link-local)", "fec0::0", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip := net.ParseIP(tt.ip) - require.NotNil(t, ip, "Failed to parse IP: %s", tt.ip) - result := isPrivateIP(ip) - assert.Equal(t, tt.isPrivate, result, "IP %s: expected private=%v, got=%v", tt.ip, tt.isPrivate, result) - }) - } -} - -func TestSendCustomWebhook_ContextCancellation(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // Create a server that delays response - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(500 * time.Millisecond) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - provider := models.NotificationProvider{ - Type: "discord", - URL: server.URL, - Template: "minimal", - } - - data := map[string]any{ - "Title": "Test", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - // Create context with immediate cancellation - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := svc.sendJSONPayload(ctx, provider, data) - require.Error(t, err) -} - -func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) +func TestSendExternal_UnknownEventTypeSendsToAll(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1371,212 +852,6 @@ func TestRenderTemplate_MinimalAndDetailedTemplates(t *testing.T) { // Phase 3: Service-Specific Validation Tests // ============================================ -func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - t.Run("discord_message_is_normalized_to_content", func(t *testing.T) { - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - var payload map[string]any - err := json.NewDecoder(req.Body).Decode(&payload) - require.NoError(t, err) - assert.Equal(t, "Test Message", payload["content"]) - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - - // Discord payload with message should be normalized to content - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"message": {{toJSON .Message}}}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - - t.Run("discord_with_content_succeeds", func(t *testing.T) { - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"content": {{toJSON .Message}}}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - - t.Run("discord_with_embeds_succeeds", func(t *testing.T) { - originalDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = originalDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456/token_abc", - Template: "custom", - Config: `{"embeds": [{"title": {{toJSON .Title}}}]}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - - t.Run("slack_requires_text_or_blocks", func(t *testing.T) { - subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - - provider := models.NotificationProvider{ //nolint:gosec // G101: test credential - Type: "slack", - URL: "#test", - Token: "https://hooks.slack.com/services/T00/B00/xxx", - Template: "custom", - Config: `{"username": "Charon"}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := subSvc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field") - }) - - t.Run("slack_with_text_succeeds", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - - provider := models.NotificationProvider{ - Type: "slack", - URL: "#test", - Token: server.URL, - Template: "custom", - Config: `{"text": {{toJSON .Message}}}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := subSvc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - - t.Run("slack_with_blocks_succeeds", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - - provider := models.NotificationProvider{ - Type: "slack", - URL: "#test", - Token: server.URL, - Template: "custom", - Config: `{"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": {{toJSON .Message}}}}]}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := subSvc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - - t.Run("gotify_requires_message", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Gotify without message should fail - provider := models.NotificationProvider{ - Type: "gotify", - URL: server.URL, - Template: "custom", - Config: `{"title": {{toJSON .Title}}}`, // Missing message - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "gotify payload requires 'message' field") - }) - - t.Run("gotify_with_message_succeeds", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - provider := models.NotificationProvider{ - Type: "gotify", - URL: server.URL, - Template: "custom", - Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, - } - data := map[string]any{ - "Title": "Test", - "Message": "Test Message", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) -} - -// ============================================ -// Phase 3: SendExternal Event Type Coverage -// ============================================ - func TestSendExternal_AllEventTypes(t *testing.T) { eventTypes := []struct { eventType string @@ -1594,24 +869,13 @@ func TestSendExternal_AllEventTypes(t *testing.T) { for _, et := range eventTypes { t.Run(et.eventType, func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // Mock Discord validation to allow test server URL - origValidateDiscordFunc := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidateDiscordFunc }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - var callCount atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "event-test", Type: "discord", - URL: server.URL, + URL: "https://discord.com/api/webhooks/1/event-test-token", Enabled: true, Template: "minimal", NotifyProxyHosts: et.eventType == "proxy_host", @@ -1632,50 +896,23 @@ func TestSendExternal_AllEventTypes(t *testing.T) { }).Error) svc.SendExternal(context.Background(), et.eventType, "Title", "Message", nil) - time.Sleep(100 * time.Millisecond) // test always sends; unknown defaults to false (security-first); others only when their flag is true switch et.eventType { - case "test": - assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification", et.eventType) case "unknown": - assert.Equal(t, int32(0), callCount.Load(), "Unknown event type should not trigger notification (security-first)") + time.Sleep(100 * time.Millisecond) + _, body := rt.last() + assert.Nil(t, body, "Unknown event type should not trigger notification (security-first)") default: - assert.Greater(t, callCount.Load(), int32(0), "Event type %s should trigger notification when flag is set", et.eventType) + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "Event type %s should trigger notification", et.eventType) } }) } } -// ============================================ -// Phase 3: isValidRedirectURL Coverage -// ============================================ - -func TestIsValidRedirectURL(t *testing.T) { - tests := []struct { - name string - url string - expected bool - }{ - {"valid http", "https://discord.com/api/webhooks/123/abc/webhook", true}, - {"valid https", "https://example.com/webhook", true}, - {"invalid scheme ftp", "ftp://example.com", false}, - {"invalid scheme file", "file:///etc/passwd", false}, - {"no scheme", "example.com/webhook", false}, - {"empty hostname", "http:///webhook", false}, - {"invalid url", "://invalid", false}, - {"javascript scheme", "javascript:alert(1)", false}, - {"data scheme", "data:text/html,

test

", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isValidRedirectURL(tt.url) - assert.Equal(t, tt.expected, result, "isValidRedirectURL(%q) = %v, want %v", tt.url, result, tt.expected) - }) - } -} - func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { eventCases := []struct { name string @@ -1715,23 +952,13 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { for _, tc := range eventCases { t.Run(tc.name, func(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - origValidate := validateDiscordProviderURLFunc - defer func() { validateDiscordProviderURLFunc = origValidate }() - validateDiscordProviderURLFunc = func(providerType, rawURL string) error { return nil } - - received := make(chan struct{}, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - received <- struct{}{} - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "discord-security", Type: "discord", - URL: server.URL, + URL: "https://discord.com/api/webhooks/1/security-token", Enabled: true, Template: "minimal", } @@ -1740,11 +967,10 @@ func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { svc.SendExternal(context.Background(), tc.eventType, "Security Title", "Security Message", nil) - select { - case <-received: - case <-time.After(1 * time.Second): - t.Fatalf("expected dispatch for event type %s", tc.eventType) - } + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "expected dispatch for event type %s", tc.eventType) }) } } @@ -1847,61 +1073,63 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { } } -func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { +// TestTestProviderViaNotify_BuildSenderError covers testProviderViaNotify's +// defense-in-depth error branch when buildNotifySender rejects a provider +// type it doesn't recognize. TestProvider's public entry point can never +// reach this in practice (isSupportedNotificationProviderType and +// supportsJSONTemplates both gate to exactly the types buildNotifySender +// supports), so this calls the unexported method directly to exercise the +// branch. +func TestTestProviderViaNotify_BuildSenderError(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) - serverCalled := atomic.Bool{} - originalDo := webhookDoRequestFunc - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - serverCalled.Store(true) - // Verify it's using JSON payload (not legacy fallback) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - defer func() { webhookDoRequestFunc = originalDo }() + err := svc.testProviderViaNotify(models.NotificationProvider{Type: "not-a-real-provider-type"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "build notify sender") +} - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456789/token_abc", - Template: "minimal", - } +// TestDispatchViaNotify_BuildSenderError is dispatchViaNotify's counterpart +// to TestTestProviderViaNotify_BuildSenderError — see that test's comment +// for why this must call the unexported method directly rather than going +// through SendExternal. +func TestDispatchViaNotify_BuildSenderError(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) - err := svc.TestProvider(provider) - require.NoError(t, err) - assert.True(t, serverCalled.Load(), "discord provider should use JSON webhook path") + // Must not panic; buildNotifySender's error is logged and dispatchViaNotify returns. + svc.dispatchViaNotify(context.Background(), models.NotificationProvider{Type: "not-a-real-provider-type"}, "test", "Title", "Message", nil) } func TestTestProvider_HTTPURLValidation(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - t.Run("blocks private IP", func(t *testing.T) { + t.Run("blocks failed dispatch", func(t *testing.T) { + rt := &capturingRoundTripper{statusCode: http.StatusInternalServerError} + wrapper := transport.NewWrapper( + transport.WithClientFactory(func(bool, int) *http.Client { + return &http.Client{Transport: rt} + }), + transport.WithURLValidator(func(rawURL string, _ bool) (string, error) { + return rawURL, nil + }), + transport.WithRetryPolicy(transport.RetryPolicy{MaxAttempts: 1}), + ) + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + provider := models.NotificationProvider{ Type: "discord", URL: "https://discord.com/api/webhooks/999/invalidtoken", - Template: "", - } - - // Mock the webhook request to fail on IP validation - originalDo := webhookDoRequestFunc - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("private IP blocked") + Template: "minimal", } - defer func() { webhookDoRequestFunc = originalDo }() err := svc.TestProvider(provider) require.Error(t, err) }) t.Run("allows valid discord webhook", func(t *testing.T) { - serverCalled := atomic.Bool{} - originalDo := webhookDoRequestFunc - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - serverCalled.Store(true) - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - defer func() { webhookDoRequestFunc = originalDo }() + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Type: "discord", @@ -1911,7 +1139,9 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) { err := svc.TestProvider(provider) require.NoError(t, err) - assert.True(t, serverCalled.Load()) + + _, body := rt.last() + require.NotNil(t, body) }) } @@ -1919,90 +1149,6 @@ func TestTestProvider_HTTPURLValidation(t *testing.T) { // Phase 4: Additional Edge Case Coverage // ============================================ -func TestSendJSONPayload_TemplateExecutionError(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Template that calls a method on nil should cause execution error - provider := models.NotificationProvider{ - Type: "discord", - URL: server.URL, - Template: "custom", - Config: `{"result": {{call .NonExistentFunc}}}`, // This will fail during execution - } - - data := map[string]any{ - "Title": "Test", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - // The error could be a parse error or execution error depending on Go version -} - -func TestSendJSONPayload_InvalidJSONFromTemplate(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - // Template that produces invalid JSON - provider := models.NotificationProvider{ - Type: "webhook", - URL: server.URL, - Template: "custom", - Config: `{"title": {{.Title}}}`, // Missing toJSON, will produce unquoted string - } - - data := map[string]any{ - "Title": "Test Value", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid JSON payload") -} - -func TestSendJSONPayload_RequestCreationError(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - // This test verifies request creation doesn't panic on edge cases - provider := models.NotificationProvider{ - Type: "discord", - URL: "http://localhost:8080/webhook", - Template: "minimal", - } - - // Use canceled context to trigger early error - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - data := map[string]any{ - "Title": "Test", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(ctx, provider, data) - require.Error(t, err) -} - func TestRenderTemplate_CustomTemplateWithWhitespace(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) @@ -2092,48 +1238,10 @@ func TestSendExternal_JSONPayloadError(t *testing.T) { time.Sleep(100 * time.Millisecond) } -func TestSendJSONPayload_HTTPScheme(t *testing.T) { +func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) - - // Test both HTTP and HTTPS schemes - schemes := []string{"http", "https"} - - for _, scheme := range schemes { - t.Run(scheme, func(t *testing.T) { - // Create server (note: httptest.Server uses http by default) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - provider := models.NotificationProvider{ - Type: "webhook", - URL: server.URL, // httptest always uses http - Template: "minimal", - } - - data := map[string]any{ - "Title": "Test", - "Message": "Test", - "Time": time.Now().Format(time.RFC3339), - "EventType": "test", - } - - err := svc.sendJSONPayload(context.Background(), provider, data) - require.NoError(t, err) - }) - } -} - -// ============================================ -// Migration Completeness Tests -// ============================================ - -func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - ctx := context.Background() + ctx := context.Background() // Create test providers: discord (supported) and others (deprecated in discord-only rollout) providers := []models.NotificationProvider{ @@ -2328,7 +1436,7 @@ func TestIsDispatchEnabled_WebhookDefaultTrue(t *testing.T) { } func TestFlagEmailServiceEnabled_ConstantValue(t *testing.T) { - assert.Equal(t, "feature.notifications.service.email.enabled", notifications.FlagEmailServiceEnabled) + assert.Equal(t, "feature.notifications.service.email.enabled", FlagEmailServiceEnabled) } func TestIsSupportedNotificationProviderType_Email(t *testing.T) { @@ -2345,7 +1453,7 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) { // Explicitly set flag to true — should now return true require.NoError(t, db.Create(&models.Setting{ - Key: notifications.FlagEmailServiceEnabled, + Key: FlagEmailServiceEnabled, Value: "true", }).Error) assert.True(t, svc.isDispatchEnabled("email")) @@ -2354,7 +1462,8 @@ func TestIsDispatchEnabled_EmailDefaultFalse(t *testing.T) { // TestSendExternal_EmailProvider_NilMailService_DoesNotPanic verifies that when an // email provider is enabled but the mail service is nil, SendExternal dispatches // the goroutine which early-returns without panicking. The type == "email" branch -// calls dispatchEmail and continues — it never reaches supportsJSONTemplates. +// calls dispatchEmailViaNotify directly and continues — it never reaches +// supportsJSONTemplates, which only gates the non-email dispatch goroutine. func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) @@ -2362,7 +1471,7 @@ func TestSendExternal_EmailProvider_NilMailService_DoesNotPanic(t *testing.T) { // Enable the email feature flag so isDispatchEnabled("email") returns true. require.NoError(t, db.Create(&models.Setting{ - Key: notifications.FlagEmailServiceEnabled, + Key: FlagEmailServiceEnabled, Value: "true", }).Error) @@ -2399,6 +1508,12 @@ func TestTestProvider_EmailRejectsJSONTemplateStep(t *testing.T) { } func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) { + // Gotify is cut over to the extracted notify module, whose transport + // wrapper gates plain-HTTP/localhost dispatch on CHARON_ENV=test + // explicitly (resolveNotifyAllowHTTP in notify_client_adapter.go) + // rather than the old implicit os.Args[0]-".test"-suffix detection. + t.Setenv("CHARON_ENV", "test") + db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) svc := NewNotificationService(db, nil) @@ -2419,6 +1534,10 @@ func TestTestProvider_GotifyWorksWithoutFeatureFlag(t *testing.T) { } func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) { + // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment: webhook + // is also cut over to the extracted notify module. + t.Setenv("CHARON_ENV", "test") + db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) svc := NewNotificationService(db, nil) @@ -2439,6 +1558,9 @@ func TestTestProvider_WebhookWorksWithoutFeatureFlag(t *testing.T) { } func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) { + // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment. + t.Setenv("CHARON_ENV", "test") + db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) svc := NewNotificationService(db, nil) @@ -2463,6 +1585,10 @@ func TestTestProvider_GotifyWorksWhenFlagExplicitlyFalse(t *testing.T) { } func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) { + // See TestTestProvider_GotifyWorksWithoutFeatureFlag's comment: webhook + // is also cut over to the extracted notify module. + t.Setenv("CHARON_ENV", "test") + db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) svc := NewNotificationService(db, nil) @@ -2562,7 +1688,7 @@ func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { } } -// --- mockMailService for dispatchEmail tests --- +// --- mockMailService for email dispatch/test-provider tests --- type mockMailService struct { mu sync.Mutex @@ -2607,69 +1733,15 @@ func (m *mockMailService) firstCall() mockSendEmailCall { return m.calls[0] } -func TestDispatchEmail_NilMailService(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - - p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} - // Must not panic - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") -} - -func TestDispatchEmail_SMTPNotConfigured(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: false} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - assert.Empty(t, mock.calls) -} - -func TestDispatchEmail_EmptyRecipients(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: " , , ", Type: "email"} - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - assert.Empty(t, mock.calls) -} - -func TestDispatchEmail_ValidSend(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: "a@b.com, c@d.com", Type: "email"} - svc.dispatchEmail(context.Background(), p, "alert", "My Title", "My Message") - - require.Len(t, mock.calls, 1) - assert.Equal(t, []string{"a@b.com", "c@d.com"}, mock.calls[0].to) - assert.Equal(t, "[Charon Alert] My Title", mock.calls[0].subject) - assert.Contains(t, mock.calls[0].body, "My Title") - assert.Contains(t, mock.calls[0].body, "My Message") -} - -func TestDispatchEmail_SendError_Logged(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true, sendEmailErr: fmt.Errorf("smtp failure")} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} - // Must not panic even when SendEmail returns error - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - assert.Len(t, mock.calls, 1) -} - func TestSendExternal_EmailProvider_Dispatches(t *testing.T) { db := setupNotificationTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) - mock := &mockMailService{isConfigured: true} + // renderResult must be set so the notify-module email path's render + // step succeeds and reaches Mailer.Send — see TestEmailProvider's doc + // comment for why a render failure now aborts dispatch instead of + // falling back to a generic body. + mock := &mockMailService{isConfigured: true, renderResult: "

rendered

"} svc := NewNotificationService(db, mock) provider := models.NotificationProvider{ @@ -2680,7 +1752,7 @@ func TestSendExternal_EmailProvider_Dispatches(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"}) + db.Create(&models.Setting{Key: FlagEmailServiceEnabled, Value: "true"}) svc.SendExternal(context.Background(), "test", "Title", "Body", nil) @@ -2704,7 +1776,7 @@ func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) { } require.NoError(t, db.Create(&provider).Error) - db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "false"}) + db.Create(&models.Setting{Key: FlagEmailServiceEnabled, Value: "false"}) svc.SendExternal(context.Background(), "test", "Title", "Body", nil) @@ -2712,184 +1784,6 @@ func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) { assert.Zero(t, mock.callCount()) } -func TestDispatchEmail_InvalidRecipient(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: "not-an-email", Type: "email"} - // dispatchEmail will call SendEmail; the mock returns ErrInvalidRecipient — must not panic - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - // SendEmail was called once (validation happens inside real SendEmail, mock just returns the error) - assert.Len(t, mock.calls, 1) -} - -func TestDispatchEmail_TooManyRecipients(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true, sendEmailErr: ErrTooManyRecipients} - svc := NewNotificationService(db, mock) - - recipients := make([]string, 21) - for i := range recipients { - recipients[i] = fmt.Sprintf("user%d@example.com", i) - } - p := models.NotificationProvider{Name: "test-email", URL: strings.Join(recipients, ","), Type: "email"} - // dispatchEmail passes all recipients to SendEmail; mock returns ErrTooManyRecipients — must not panic - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - assert.Len(t, mock.calls, 1) -} - -func TestDispatchEmail_HeaderInjectionRecipient(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true, sendEmailErr: ErrInvalidRecipient} - svc := NewNotificationService(db, mock) - - p := models.NotificationProvider{Name: "test-email", URL: "bad\r\naddr@test.com", Type: "email"} - // The recipient contains CR/LF; dispatchEmail trims + splits but passes to SendEmail which rejects — must not panic - svc.dispatchEmail(context.Background(), p, "alert", "Title", "Message") - - assert.Len(t, mock.calls, 1) -} - -func TestSendExternal_EmailProviderDoesNotCallSendJSONPayload(t *testing.T) { - db := setupNotificationTestDB(t) - require.NoError(t, db.AutoMigrate(&models.Setting{})) - - mock := &mockMailService{isConfigured: true} - svc := NewNotificationService(db, mock) - - // Track any JSON payload call via the webhook hook - jsonPayloadCalled := false - origDo := webhookDoRequestFunc - defer func() { webhookDoRequestFunc = origDo }() - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - jsonPayloadCalled = true - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil - } - - provider := models.NotificationProvider{ - Name: "email-no-http", - Type: "email", - URL: "notify@example.com", - Enabled: true, - } - require.NoError(t, db.Create(&provider).Error) - db.Create(&models.Setting{Key: notifications.FlagEmailServiceEnabled, Value: "true"}) - - svc.SendExternal(context.Background(), "test", "Title", "Body", nil) - require.Eventually(t, func() bool { return mock.callCount() > 0 }, 2*time.Second, 10*time.Millisecond) - - assert.False(t, jsonPayloadCalled, "email provider must not trigger HTTP JSON payload path") -} - -func TestDispatchEmail_XSSPayload_BodySanitized(t *testing.T) { - db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true} - svc := NewNotificationService(db, mock) - - xssTitle := `` - xssMessage := `` - - p := models.NotificationProvider{Name: "test-email", URL: "a@b.com", Type: "email"} - svc.dispatchEmail(context.Background(), p, "alert", xssTitle, xssMessage) - - require.Len(t, mock.calls, 1) - body := mock.calls[0].body - // Raw script tags must not appear — they must be escaped. - assert.NotContains(t, body, "`, ``) + if got != "<script>alert(1)</script>
<img src=x onerror=evil()>" { + t.Fatalf("unexpected escaped fallback body: %q", got) + } +} + +// TestFallbackEmailBodyEmptyFields confirms fallbackEmailBody degrades +// gracefully (no stray "
") when title or body is empty. +func TestFallbackEmailBodyEmptyFields(t *testing.T) { + if got := fallbackEmailBody("", ""); got != "" { + t.Fatalf("expected empty fallback body, got %q", got) + } + if got := fallbackEmailBody("Only Title", ""); got != "Only Title" { + t.Fatalf("unexpected fallback body: %q", got) + } + if got := fallbackEmailBody("", "Only body"); got != "Only body" { + t.Fatalf("unexpected fallback body: %q", got) + } +} + +func TestMailServiceTemplateRendererAdapterNilMailServiceErrors(t *testing.T) { + adapter := &mailServiceTemplateRendererAdapter{mailService: nil} + + if _, err := adapter.Render("t.html", notify.Message{}); err == nil { + t.Fatal("expected an error when mail service is not configured") + } +} + +func TestNewNotifyEmailConfigPreservesSubjectPrefixAndTemplateSelection(t *testing.T) { + fake := &fakeMailServiceForEmailAdapter{isConfigured: true} + cfg := NewNotifyEmailConfig(fake, []string{"ops@example.com"}) + + if cfg.SubjectPrefix != "[Charon Alert] " { + t.Fatalf("SubjectPrefix = %q, want %q", cfg.SubjectPrefix, "[Charon Alert] ") + } + if len(cfg.Recipients) != 1 || cfg.Recipients[0] != "ops@example.com" { + t.Fatalf("unexpected recipients: %v", cfg.Recipients) + } + if cfg.Mailer == nil { + t.Fatal("expected a non-nil Mailer") + } + if cfg.Renderer == nil { + t.Fatal("expected a non-nil Renderer") + } + if cfg.TemplateName == nil { + t.Fatal("expected a non-nil TemplateName selector") + } + + tests := []struct { + eventType string + want string + }{ + {"security_waf", "email_security_alert.html"}, + {"security_acl", "email_security_alert.html"}, + {"security_rate_limit", "email_security_alert.html"}, + {"security_crowdsec", "email_security_alert.html"}, + {"cert", "email_ssl_event.html"}, + {"uptime", "email_uptime_event.html"}, + {"proxy_host", "email_system_event.html"}, + {"unknown-event", "email_system_event.html"}, + } + for _, tt := range tests { + t.Run(tt.eventType, func(t *testing.T) { + got := cfg.TemplateName(notify.Message{EventType: tt.eventType}) + if got != tt.want { + t.Fatalf("TemplateName(%q) = %q, want %q", tt.eventType, got, tt.want) + } + if got != emailTemplateForEventType(tt.eventType) { + t.Fatalf("TemplateName(%q) diverges from emailTemplateForEventType", tt.eventType) + } + }) + } +} + +func TestNewNotifyEmailConfigEndToEndSend(t *testing.T) { + fake := &fakeMailServiceForEmailAdapter{isConfigured: true, renderResult: "

hi

"} + cfg := NewNotifyEmailConfig(fake, []string{"ops@example.com"}) + + client := email.New(cfg) + + err := client.Send(context.Background(), notify.Message{Title: "Uptime issue", Body: "host down", EventType: "uptime"}) + if err != nil { + t.Fatalf("Send returned error: %v", err) + } + + if len(fake.renderCalls) != 1 || fake.renderCalls[0].templateName != "email_uptime_event.html" { + t.Fatalf("unexpected render calls: %+v", fake.renderCalls) + } + if len(fake.sendCalls) != 1 { + t.Fatalf("expected 1 SendEmail call, got %d", len(fake.sendCalls)) + } + if fake.sendCalls[0].subject != "[Charon Alert] Uptime issue" { + t.Fatalf("subject = %q, want %q", fake.sendCalls[0].subject, "[Charon Alert] Uptime issue") + } +} diff --git a/backend/internal/services/notify_provider_adapter.go b/backend/internal/services/notify_provider_adapter.go new file mode 100644 index 000000000..5dcfbe82e --- /dev/null +++ b/backend/internal/services/notify_provider_adapter.go @@ -0,0 +1,178 @@ +package services + +import ( + "fmt" + "strings" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// legacyDetailedTemplate reproduces, verbatim in JSON key structure, the old +// Charon `detailedTemplate` const that lived in notification_service.go's +// sendJSONPayload (and its identical copy in the old RenderTemplate): +// +// {"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, +// "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, +// "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}} +// +// This is a deliberate backward-compatibility decision (extraction spec +// §3.6 step 5 / §7 risk 1b, "err toward the safer option"): the extracted +// module's own built-in "detailed" template (providers/internal/render's +// DetailedTemplate) nests all host-specific extras under a single "data" +// object via notify.Message.Data instead of exposing them as flat top-level +// JSON fields. Falling through to that new built-in template for an +// already-configured "detailed" provider would silently change the JSON +// payload shape delivered to any existing consumer that parses the old flat +// keys (Discord embed parsers, custom webhook receivers, etc.). +// buildNotifySender/resolveTemplateFields below translate a stored +// `provider.Template == "detailed"` into `Template: "custom"`, +// `CustomTemplate: legacyDetailedTemplate`, so already-configured +// "detailed" providers see zero payload-shape change once cutover (a later +// phase — Commits 3-9) wires this adapter into production dispatch. +// +// Field-access note: the module's shared render engine +// (providers/internal/render.TemplateData) exposes host-specific extras +// under a single `.Data` map (notify.Message.Data), not as top-level +// template fields the way Charon's old flat `data map[string]any` did — so +// `.HostName` becomes `(index .Data "HostName")` here. Go's text/template +// `index` returns the map's zero value (nil, renders as JSON `null`) for a +// missing key, matching the old template's behavior when a caller's data +// map lacked one of these optional fields. +// +// One narrow, intentionally-documented difference from the original: the +// old template's final `"data": {{toJSON .}}` serialized the ENTIRE input +// map — which included Title/Message/Time/EventType as well as the +// extras — under "data", since `.` was the whole flat map passed into +// sendJSONPayload. Here, `"data": {{toJSON .Data}}` serializes only +// notify.Message.Data (the caller-supplied extras), because Title/Message/ +// Time/EventType are no longer part of that map — they became top-level +// notify.Message fields. What ends up inside `msg.Data` at cutover time is +// decided by Commits 3-9 (SendExternal's new notify.Message construction), +// not by this file; this comment flags the difference explicitly so that +// decision is made consciously rather than by accident, per the extraction +// spec's instruction not to let payload-shape decisions happen implicitly. +const legacyDetailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON (index .Data "HostName")}}, "host_ip": {{toJSON (index .Data "HostIP")}}, "service_count": {{toJSON (index .Data "ServiceCount")}}, "services": {{toJSON (index .Data "Services")}}, "data": {{toJSON .Data}}}` + +// resolveTemplateFields translates a GORM NotificationProvider row's +// Template/Config columns into the (template, customTemplate) pair every +// extracted provider package's Config expects, applying the +// "detailed" -> flat-shape CustomTemplate backward-compat translation +// documented on legacyDetailedTemplate above. "minimal" and "custom" (and +// any other/empty selector, which the module's own render.SelectTemplate +// treats as "custom") pass through unchanged. +func resolveTemplateFields(provider models.NotificationProvider) (template string, customTemplate string) { + if strings.EqualFold(strings.TrimSpace(provider.Template), "detailed") { + return "custom", legacyDetailedTemplate + } + return provider.Template, provider.Config +} + +// providerConfigMap maps a GORM models.NotificationProvider row's +// type-specific fields onto the map[string]any key convention every +// extracted-module HTTP-based provider's Factory expects (per +// docs/plans/notify_provider_registry_spec.md §3.4: "transport" for the +// shared *transport.Wrapper, provider-specific fields under the lowercase +// snake_case name of their typed Config struct field). w is the single +// shared *transport.Wrapper built by NewNotifyTransportWrapper +// (notify_client_adapter.go), injected into every HTTP-based provider. +// +// Field mappings below were read directly out of the old +// notification_service.go sendJSONPayload's provider-specific branches (not +// guessed from the spec's design summary) and are unchanged from the +// pre-registry switch that used to live in buildNotifySender: +// - discord: webhook_url <- provider.URL +// - slack: webhook_url <- provider.Token — Slack's decrypted webhook URL +// is stored in the Token column (provider.URL is an unused placeholder +// for Slack, matching the old code's `decryptedWebhookURL := p.Token`) +// - gotify: url <- provider.URL, token <- provider.Token (sent as the +// X-Gotify-Key header when non-empty) +// - pushover: user_key <- provider.URL, api_token <- provider.Token +// (matching the old code's `jsonPayload["user"] = p.URL` / +// `decryptedToken := p.Token`); base_url left unset, so +// providers/pushover defaults to the production API +// - ntfy: url <- provider.URL, token <- provider.Token (sent as an +// "Authorization: Bearer " header when non-empty) +// - telegram: bot_token <- provider.Token, chat_id <- provider.URL +// (matching the old code's `decryptedToken := p.Token` / +// `jsonPayload["chat_id"] = p.URL`); base_url left unset, so +// providers/telegram defaults to the production Bot API +// - webhook / generic: url <- provider.URL, generic JSON passthrough, no +// provider-specific payload shape or host allowlist +// +// This per-type field mapping is a Charon persistence-schema fact (which +// GORM column means what for which provider type), not something the +// registry can know — collapsing buildNotifySender's dispatch onto +// notify.New (below) removes the "which Go constructor do I call" branch, +// not this one (spec §3.6.1). +func providerConfigMap(provider models.NotificationProvider, w *transport.Wrapper, template, customTemplate string) map[string]any { + config := map[string]any{ + "transport": w, + "template": template, + "custom_template": customTemplate, + } + + switch strings.ToLower(strings.TrimSpace(provider.Type)) { + case "discord": + config["webhook_url"] = provider.URL + case "slack": + config["webhook_url"] = provider.Token + case "gotify": + config["url"] = provider.URL + config["token"] = provider.Token + case "pushover": + config["user_key"] = provider.URL + config["api_token"] = provider.Token + case "ntfy": + config["url"] = provider.URL + config["token"] = provider.Token + case "telegram": + config["bot_token"] = provider.Token + config["chat_id"] = provider.URL + case "webhook", "generic": + config["url"] = provider.URL + } + + return config +} + +// registryTypeForProvider resolves a Charon provider.Type discriminator to +// the name it is registered under in the go_notify_yourself registry. +// "generic" is Charon's own alias for the module's "webhook" provider (the +// pre-registry switch handled both cases identically via webhook.New); the +// registry itself only knows the canonical "webhook" name, so the alias +// must be resolved here before calling notify.New. +func registryTypeForProvider(providerType string) string { + t := strings.ToLower(strings.TrimSpace(providerType)) + if t == "generic" { + return "webhook" + } + return t +} + +// buildNotifySender maps a GORM models.NotificationProvider row into the +// map[string]any config the extracted module's provider registry expects +// (providerConfigMap, above) and constructs the corresponding notify.Sender +// via notify.New — replacing the per-type switch/constructor-call dispatch +// that used to live here (docs/plans/notify_provider_registry_spec.md +// §3.6.1). notify.New itself returns a descriptive error, never panics, for +// an unregistered provider type or a missing/invalid required config key +// (e.g. "transport" absent or nil) — buildNotifySender wraps that error +// rather than reinterpreting it. +// +// Email is handled separately (notify_email_adapter.go / providers/email), +// not by this function — the module's email package has a different shape +// (Mailer/TemplateRenderer, not a Wrapper-backed Sender) and Charon's +// dispatch code never routes an "email" provider.Type through here. +func buildNotifySender(provider models.NotificationProvider, w *transport.Wrapper) (notify.Sender, error) { + tmpl, customTemplate := resolveTemplateFields(provider) + config := providerConfigMap(provider, w, tmpl, customTemplate) + + sender, err := notify.New(registryTypeForProvider(provider.Type), config) + if err != nil { + return nil, fmt.Errorf("notify provider adapter: %w", err) + } + return sender, nil +} diff --git a/backend/internal/services/notify_provider_adapter_test.go b/backend/internal/services/notify_provider_adapter_test.go new file mode 100644 index 000000000..7e38edd26 --- /dev/null +++ b/backend/internal/services/notify_provider_adapter_test.go @@ -0,0 +1,468 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/discord" + "github.com/Wikid82/go_notify_yourself/providers/gotify" + "github.com/Wikid82/go_notify_yourself/providers/ntfy" + "github.com/Wikid82/go_notify_yourself/providers/pushover" + "github.com/Wikid82/go_notify_yourself/providers/slack" + "github.com/Wikid82/go_notify_yourself/providers/telegram" + "github.com/Wikid82/go_notify_yourself/providers/webhook" + "github.com/Wikid82/go_notify_yourself/transport" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// capturingRoundTripper is a fake http.RoundTripper that records every +// outbound request (method, URL, headers, body) and returns a canned +// response (200 OK by default, or statusCode when set) without hitting any +// real network destination — including providers like pushover/telegram +// whose dispatch URL is hardcoded to a production API host. +type capturingRoundTripper struct { + mu sync.Mutex + requests []*http.Request + bodies [][]byte + statusCode int +} + +func (c *capturingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + + var body []byte + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + _ = req.Body.Close() + } + c.requests = append(c.requests, req) + c.bodies = append(c.bodies, body) + + status := c.statusCode + if status == 0 { + status = http.StatusOK + } + + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: make(http.Header), + }, nil +} + +func (c *capturingRoundTripper) last() (*http.Request, []byte) { + c.mu.Lock() + defer c.mu.Unlock() + n := len(c.requests) + if n == 0 { + return nil, nil + } + return c.requests[n-1], c.bodies[n-1] +} + +// newCapturingWrapper builds a *transport.Wrapper whose ClientFactory routes +// every outbound request through a capturingRoundTripper (so tests can +// inspect exactly what was dispatched) and whose URLValidator is a +// pass-through (SSRF policy is exercised by notify_client_adapter's own +// tests, not here). +func newCapturingWrapper() (*transport.Wrapper, *capturingRoundTripper) { + rt := &capturingRoundTripper{} + w := transport.NewWrapper( + transport.WithClientFactory(func(bool, int) *http.Client { + return &http.Client{Transport: rt} + }), + transport.WithURLValidator(func(rawURL string, _ bool) (string, error) { + return rawURL, nil + }), + ) + return w, rt +} + +func TestBuildNotifySenderDiscord(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "discord", + URL: "https://discord.com/api/webhooks/123456/abcdef", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*discord.Client); !ok { + t.Fatalf("expected *discord.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, body := rt.last() + if req == nil { + t.Fatal("expected a request to be captured") + } + if req.URL.String() != provider.URL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("invalid JSON payload: %v", err) + } + if payload["content"] != "hello" { + t.Fatalf("expected content fallback from message, got %v", payload) + } +} + +func TestBuildNotifySenderSlackUsesTokenAsWebhookURL(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "slack", + URL: "unused-placeholder", + Token: "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*slack.Client); !ok { + t.Fatalf("expected *slack.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, body := rt.last() + if req.URL.String() != provider.Token { + t.Fatalf("dispatch URL = %q, want provider.Token %q", req.URL.String(), provider.Token) + } + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if payload["text"] != "hello" { + t.Fatalf("expected text fallback from message, got %v", payload) + } +} + +func TestBuildNotifySenderGotifySetsAuthHeader(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "gotify", + URL: "https://gotify.example.com/message", + Token: "app-token-123", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*gotify.Client); !ok { + t.Fatalf("expected *gotify.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, _ := rt.last() + if req.URL.String() != provider.URL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL) + } + if got := req.Header.Get("X-Gotify-Key"); got != provider.Token { + t.Fatalf("X-Gotify-Key header = %q, want %q", got, provider.Token) + } +} + +func TestBuildNotifySenderPushoverBuildsProductionURLAndInjectsCredentials(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "pushover", + URL: "user-key-abc", + Token: "api-token-xyz", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*pushover.Client); !ok { + t.Fatalf("expected *pushover.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, body := rt.last() + wantURL := "https://api.pushover.net/1/messages.json" + if req.URL.String() != wantURL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), wantURL) + } + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if payload["token"] != provider.Token { + t.Fatalf("payload token = %v, want %q", payload["token"], provider.Token) + } + if payload["user"] != provider.URL { + t.Fatalf("payload user = %v, want %q", payload["user"], provider.URL) + } +} + +func TestBuildNotifySenderNtfySetsBearerHeader(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "ntfy", + URL: "https://ntfy.sh/my-topic", + Token: "ntfy-token", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*ntfy.Client); !ok { + t.Fatalf("expected *ntfy.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, _ := rt.last() + if req.URL.String() != provider.URL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL) + } + if got := req.Header.Get("Authorization"); got != "Bearer "+provider.Token { + t.Fatalf("Authorization header = %q, want %q", got, "Bearer "+provider.Token) + } +} + +func TestBuildNotifySenderTelegramBuildsProductionURLAndChatID(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: "telegram", + URL: "chat-id-456", + Token: "bot-token-789", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*telegram.Client); !ok { + t.Fatalf("expected *telegram.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, body := rt.last() + wantURL := "https://api.telegram.org/bot" + provider.Token + "/sendMessage" + if req.URL.String() != wantURL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), wantURL) + } + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if payload["chat_id"] != provider.URL { + t.Fatalf("payload chat_id = %v, want %q", payload["chat_id"], provider.URL) + } + if payload["text"] != "hello" { + t.Fatalf("expected text fallback from message, got %v", payload) + } +} + +func TestBuildNotifySenderWebhookGeneric(t *testing.T) { + for _, providerType := range []string{"webhook", "generic"} { + t.Run(providerType, func(t *testing.T) { + w, rt := newCapturingWrapper() + provider := models.NotificationProvider{ + Type: providerType, + URL: "https://example.com/hook", + Template: "minimal", + } + + sender, err := buildNotifySender(provider, w) + if err != nil { + t.Fatalf("buildNotifySender returned error: %v", err) + } + if _, ok := sender.(*webhook.Client); !ok { + t.Fatalf("expected *webhook.Client, got %T", sender) + } + + if err := sender.Send(context.Background(), notify.Message{Title: "t", Body: "hello", EventType: "test"}); err != nil { + t.Fatalf("Send failed: %v", err) + } + + req, _ := rt.last() + if req.URL.String() != provider.URL { + t.Fatalf("dispatch URL = %q, want %q", req.URL.String(), provider.URL) + } + }) + } +} + +func TestBuildNotifySenderUnsupportedTypeErrors(t *testing.T) { + w, _ := newCapturingWrapper() + provider := models.NotificationProvider{Type: "carrier-pigeon"} + + _, err := buildNotifySender(provider, w) + if err == nil { + t.Fatal("expected an error for an unsupported/unregistered provider type") + } + if !strings.Contains(err.Error(), "no provider registered") { + t.Fatalf("expected a registry not-found error, got: %v", err) + } +} + +// TestBuildNotifySenderMissingTransportErrors asserts that a nil +// *transport.Wrapper (which providerConfigMap stores under the +// config["transport"] key notify.New's per-provider factories require) +// produces a descriptive error, not a panic — per +// docs/plans/notify_provider_registry_spec.md §3.10's "missing/wrong-typed +// required config key" error-handling convention. +func TestBuildNotifySenderMissingTransportErrors(t *testing.T) { + provider := models.NotificationProvider{Type: "discord", URL: "https://discord.com/api/webhooks/123/abc"} + + _, err := buildNotifySender(provider, nil) + if err == nil { + t.Fatal("expected an error for a nil transport wrapper") + } + if !strings.Contains(err.Error(), "transport") { + t.Fatalf("expected error to mention the missing transport, got: %v", err) + } +} + +// TestBuildNotifySenderInvalidTransportInConfigMapErrors exercises +// providerConfigMap/notify.New's config["transport"] validation directly: +// a config map whose "transport" key holds something other than a +// *transport.Wrapper (or is absent) must produce an error from notify.New, +// never a panic. +func TestBuildNotifySenderInvalidTransportInConfigMapErrors(t *testing.T) { + config := map[string]any{"webhook_url": "https://discord.com/api/webhooks/123/abc"} + + _, err := notify.New("discord", config) + if err == nil { + t.Fatal("expected an error for a config map missing a valid transport") + } + if !strings.Contains(err.Error(), "transport") { + t.Fatalf("expected error to mention the missing transport, got: %v", err) + } +} + +// TestRegistryTypeForProviderResolvesGenericAlias asserts the "generic" -> +// "webhook" alias translation (registryTypeForProvider) matches the +// pre-registry switch's `case "webhook", "generic":` behavior exactly. +func TestRegistryTypeForProviderResolvesGenericAlias(t *testing.T) { + if got := registryTypeForProvider("generic"); got != "webhook" { + t.Fatalf("registryTypeForProvider(%q) = %q, want %q", "generic", got, "webhook") + } + if got := registryTypeForProvider("Discord"); got != "discord" { + t.Fatalf("registryTypeForProvider(%q) = %q, want %q", "Discord", got, "discord") + } +} + +func TestResolveTemplateFieldsPassesThroughMinimalAndCustom(t *testing.T) { + minimal := models.NotificationProvider{Template: "minimal", Config: ""} + if tmpl, custom := resolveTemplateFields(minimal); tmpl != "minimal" || custom != "" { + t.Fatalf("minimal: got (%q, %q)", tmpl, custom) + } + + custom := models.NotificationProvider{Template: "custom", Config: `{"foo": {{toJSON .Title}}}`} + if tmpl, cfg := resolveTemplateFields(custom); tmpl != "custom" || cfg != custom.Config { + t.Fatalf("custom: got (%q, %q)", tmpl, cfg) + } +} + +func TestResolveTemplateFieldsTranslatesDetailedToLegacyFlatShape(t *testing.T) { + provider := models.NotificationProvider{Template: "detailed", Config: ""} + + tmpl, custom := resolveTemplateFields(provider) + if tmpl != "custom" { + t.Fatalf("expected template to become %q, got %q", "custom", tmpl) + } + if custom != legacyDetailedTemplate { + t.Fatalf("expected custom template to be legacyDetailedTemplate, got %q", custom) + } +} + +// TestDetailedTemplateBackwardCompatMatchesOldFlatJSONShape renders the +// backward-compat legacyDetailedTemplate via providers/webhook.RenderPreview +// (the module's public template-preview function, replacing the old +// RenderTemplate) and asserts the resulting JSON keys/values exactly match +// what the OLD Charon detailedTemplate const in notification_service.go +// would have produced for the same inputs — proving the "detailed" -> +// flat-shape backward-compat translation (extraction spec §3.6 step 5) is +// implemented faithfully. +func TestDetailedTemplateBackwardCompatMatchesOldFlatJSONShape(t *testing.T) { + fixedTime := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) + msg := notify.Message{ + Title: "Certificate Renewed", + Body: "Certificate renewed for example.com", + EventType: "cert", + Timestamp: fixedTime, + Data: map[string]any{ + "HostName": "example.com", + "HostIP": "1.2.3.4", + "ServiceCount": float64(3), + "Services": []any{"web", "api", "admin"}, + }, + } + + _, customTemplate := resolveTemplateFields(models.NotificationProvider{Template: "detailed"}) + + rendered, parsed, err := webhook.RenderPreview(customTemplate, msg) + if err != nil { + t.Fatalf("RenderPreview failed: %v (rendered=%s)", err, rendered) + } + + parsedMap, ok := parsed.(map[string]any) + if !ok { + t.Fatalf("expected parsed output to be a JSON object, got %T", parsed) + } + + want := map[string]any{ + "title": "Certificate Renewed", + "message": "Certificate renewed for example.com", + "time": fixedTime.Format(time.RFC3339), + "event": "cert", + "host": "example.com", + "host_ip": "1.2.3.4", + "service_count": float64(3), + } + for key, wantVal := range want { + if got := parsedMap[key]; got != wantVal { + t.Fatalf("key %q = %v (%T), want %v (%T)", key, got, got, wantVal, wantVal) + } + } + + services, ok := parsedMap["services"].([]any) + if !ok || len(services) != 3 { + t.Fatalf("expected services to be a 3-element array, got %v", parsedMap["services"]) + } + + dataField, ok := parsedMap["data"].(map[string]any) + if !ok { + t.Fatalf("expected data field to be a JSON object, got %v", parsedMap["data"]) + } + if dataField["HostName"] != "example.com" { + t.Fatalf("expected data.HostName to be preserved, got %v", dataField["HostName"]) + } +} diff --git a/backend/internal/services/notify_providers_import.go b/backend/internal/services/notify_providers_import.go new file mode 100644 index 000000000..2eafbfb05 --- /dev/null +++ b/backend/internal/services/notify_providers_import.go @@ -0,0 +1,32 @@ +package services + +// This file exists solely for its side effects: each blank import below +// runs that provider package's init(), which self-registers a +// notify.Factory into the go_notify_yourself registry (notify.Register), +// making the provider constructible via notify.New(provider.Type, config) +// from buildNotifySender (notify_provider_adapter.go). +// +// Deliberately NOT importing providers/all here. Charon hand-picks exactly +// the provider types it currently supports (mirrored by +// isSupportedNotificationProviderType in notification_service.go) rather +// than linking every provider the module ships, per the registry design +// spec (docs/plans/notify_provider_registry_spec.md §3.6.3): Charon's own +// allowlist — not what happens to be linked into the binary — remains the +// gate on what's exposed through its API/UI. Importing providers/all would +// add binary size and transitive dependency surface for providers Charon's +// allowlist would reject anyway. +// +// Keep this list in sync with isSupportedNotificationProviderType's +// allowlist by hand; notification_service_allowlist_registry_test.go +// (or equivalent) asserts that allowlist is a subset of +// notify.RegisteredTypes(), catching drift between the two. +import ( + _ "github.com/Wikid82/go_notify_yourself/providers/discord" + _ "github.com/Wikid82/go_notify_yourself/providers/email" + _ "github.com/Wikid82/go_notify_yourself/providers/gotify" + _ "github.com/Wikid82/go_notify_yourself/providers/ntfy" + _ "github.com/Wikid82/go_notify_yourself/providers/pushover" + _ "github.com/Wikid82/go_notify_yourself/providers/slack" + _ "github.com/Wikid82/go_notify_yourself/providers/telegram" + _ "github.com/Wikid82/go_notify_yourself/providers/webhook" +) diff --git a/docs/plans/notifications_extraction_spec.md b/docs/plans/notifications_extraction_spec.md new file mode 100644 index 000000000..dd96e2161 --- /dev/null +++ b/docs/plans/notifications_extraction_spec.md @@ -0,0 +1,1146 @@ +# Notifications Engine Extraction — Scoping Spec + +Status: Scoping/design only. No extraction, no new repo, no code changes performed under this +spec. This document is the literal move-list and design brief for a **future session** that will +create the new repository and perform the file-move/refactor/import work. + +Owner for this document: **planning** agent. +Owner for execution (future session): TBD — likely a fresh `management`-orchestrated pipeline once +the new repo exists, since it touches both a new external repo and Charon itself. + +**Revision note (rev 2):** this draft originally recommended a Phase-1-only extraction (the +SSRF-safe HTTP wrapper alone) and left the provider-payload/template logic and email dispatch for a +deferred v0.2. The user has since decided on **full provider-layer scope**: the +Discord/Slack/Gotify/Pushover/Ntfy/webhook payload builders and email dispatch are genericized and +moved into the new module in this same extraction. §3.1, §3.3, §3.5, §3.6, §4, §5, §6, and §7 are +revised accordingly. §2 (research findings) and the HTTP wrapper DI seams in §3.2 are unchanged from +the prior draft. + +**Two follow-up inputs folded into this same revision:** + +1. **The new repo already exists.** The user has created it at `/projects/go_notify_yourself` + (sibling to `/projects/Charon`, remote `github.com/Wikid82/go_notify_yourself`), currently just + `LICENSE` + a placeholder `README.md` — no `go.mod` yet. Every placeholder module path in this + spec (`github.com/Wikid82/notifyhttp` in the original draft, briefly `github.com/Wikid82/notify` + earlier in this revision pass) is now replaced with the real path, + **`github.com/Wikid82/go_notify_yourself`**. The extraction session's Phase 1 (§4) scaffolds + *into* this existing directory/repo, not a newly-`git init`'d one. The root Go package name is + kept as `notify` (not `go_notify_yourself`) since Go package names conventionally avoid + underscores — this is a normal, unproblematic mismatch (import path ≠ package identifier; the + compiler resolves the identifier from each file's `package notify` declaration, so consuming code + still just writes `notify.Message` after `import "github.com/Wikid82/go_notify_yourself"`). +2. **Long-term direction vs. near-term scope.** The user's eventual goal is for this module to + become a Go equivalent of [Apprise](https://github.com/caronc/apprise) — the Python library that + unifies notification dispatch across a large number of services via a common interface/URL-scheme + convention. **Right now, while both Charon and the user's other small family project are still + under active development**, they explicitly do not want this extraction to add any provider + Charon doesn't already have (no Twilio/PagerDuty/Matrix/etc.). The move-list in §3.1 stays exactly + Charon's existing seven HTTP providers + email — no more (six at the time this note was first + drafted; Telegram was folded in as the seventh per §7 risk 1e/§3.6 step 6, since Charon already + supports it). The API design in §3.3.3, however, is shaped so that adding providers later is + additive, not a breaking change — see the new §8 for the explicit tradeoff and what was (and + wasn't) built now to support that. + +--- + +## 1. Introduction + +### 1.1 Objective + +Charon's owner now maintains multiple projects and wants a **standalone, reusable Go module** for +notification delivery (SSRF-safe outbound HTTP dispatch, retries, provider payload templating) so +future projects — and Charon itself — can `go get` it instead of re-implementing notification +delivery from scratch each time. + +### 1.2 Goals + +- Produce an exact inventory of what moves to the new module vs. what stays in Charon, with a + one-line reason for each file. +- Identify every point where the current engine reaches into Charon-internal code, and define a + dependency-injection seam that removes that coupling. +- Define the new module's public Go API, generic enough for an unrelated project to adopt. +- Define the new repo's structure, versioning/release strategy, and CI shape. +- Define the Charon-side migration plan for consuming the new module once it exists. +- Surface open questions/risks that the extraction session must resolve or confirm with the user + before touching code. + +### 1.3 Non-goals + +- No new repository is created here. +- No files are moved, no import paths are rewritten, no code is written under this spec. +- No decision is made on the new repo's exact GitHub org/visibility — that's the user's call when + they set up the workspace. + +--- + +## 2. Research Findings + +### 2.1 Existing architecture summary + +Charon's notification surface spans four layers, and they are **not** equally coupled: + +| Layer | Location | Coupling to Charon | +|---|---|---| +| Delivery primitive | `backend/internal/notifications/` | Imports `internal/network` + `internal/security` (SSRF guards) directly. Otherwise pure Go, no GORM, no DB, no Charon config. | +| Orchestration/business logic | `backend/internal/services/notification_service.go`, `security_notification_service.go`, `enhanced_security_notification_service.go` | Heavily coupled: `*gorm.DB`, `models.NotificationProvider`/`NotificationConfig`, Charon's `Setting` table for feature flags, Charon's `logger`/`util`/`trace` packages, `MailServiceInterface` (Charon SMTP), Charon-branded strings ("[Charon Alert]"), Charon domain concepts (`HostName`, `ServiceCount`, `proxy_host`/`remote_server`/`domain`/`cert`/`uptime`/`security_*` event types). | +| Persistence | `backend/internal/models/notification*.go` | GORM models with `BeforeCreate` hooks, `gorm:` tags — pure Charon persistence. | +| Presentation | `frontend/src/{api,pages,hooks,components}/*notification*` | React/TanStack Query UI wired to Charon's REST API and design system. | + +This four-layer split is the central finding of this spec: **the reusable "engine" the user asked +for is materially smaller than the full notification feature.** Section 3.1 lays out the exact +line, now revised for full provider-layer scope (see the revision note above). + +### 2.2 `backend/internal/notifications/` package (the current "engine") + +| File | LOC | Purpose | +|---|---|---| +| `engine.go` | 23 | `DeliveryEngine` interface + `DispatchRequest` struct. **Dead code** — grep confirms nothing outside this file implements or references `DeliveryEngine`; `EngineNotifyV1` const is unused elsewhere. | +| `feature_flags.go` | 15 | String constants naming Charon `Setting`-table keys (e.g. `feature.notifications.service.discord.enabled`). These are Charon policy labels, not engine behavior — the lookup logic lives in `notification_service.go` (`getFeatureFlagValue`), not in this package. | +| `http_client_executor.go` | 8 | Thin `client.Do` wrapper, exists purely as a test seam. | +| `router.go` | 38 | `Router.ShouldUseNotify()`. Comment in the file itself says `// NOTE: used only in tests`. Grep confirms: zero production call sites outside `router_test.go`. **Dead code.** | +| `http_wrapper.go` | 541 | The real engine: `HTTPWrapper.Send()` — SSRF-hardened outbound POST with retry/backoff (`RetryPolicy`), redirect guarding, response size caps (256 KiB request / 1 MiB response), header allowlisting, provider error-hint extraction, transport error sanitization. This is genuinely reusable and provider-agnostic. | +| `http_wrapper_test.go`, `router_test.go` | — | Unit tests, ~31 KB combined coverage of the above. | + +**Coupling point**: `http_wrapper.go` imports `internal/network` (for `network.NewSafeHTTPClient`, +`network.Option`, `network.IsPrivateIP`) and `internal/security` (for `security.ValidateExternalURL`, +`security.ValidationOption`). Confirmed via `grep -rl` that both packages are **shared Charon +infrastructure** used well beyond notifications — Caddy client, CrowdSec integration, uptime +monitoring, auth, config, remote-storage SSRF guards. They must **not** move into the new module; +they need a DI seam instead (see §3.2). + +Both `internal/network` and `internal/security` were read in full: neither imports GORM, Charon +models, or Charon config beyond `os.Getenv` for two env var overrides +(`CHARON_NOTIFY_ALLOW_HTTP`, `CHARON_NOTIFY_MAX_REDIRECTS`, both read inside +`internal/notifications/http_wrapper.go` itself, not in `network`/`security`). This means the seam +is narrow: two small interfaces, not a deep dependency tree. + +### 2.3 `backend/internal/services/notification_service.go` (35.7 KB — the real feature logic) + +Confirmed via read of `SendExternal`, `sendJSONPayload`, `dispatchEmail`, +`emailTemplateForEventType`, `RenderTemplate`, and the CRUD methods: + +- `sendJSONPayload` builds provider JSON payloads from Go `text/template`, with two built-in + templates (`minimal`, `detailed`) referencing Charon-specific fields: `HostName`, `HostIP`, + `ServiceCount`, `Services`. Operates directly on `models.NotificationProvider` (GORM struct), not + a generic config type. +- `dispatchEmail` hardcodes the subject prefix `"[Charon Alert] %s"` and delegates to + `MailServiceInterface` (Charon's SMTP service) for template rendering (`email_security_alert.html`, + `email_ssl_event.html`, etc.) — those HTML templates live in Charon's mail service, not in + `internal/notifications`. +- `SendExternal` filters providers by Charon domain event types (`proxy_host`, `remote_server`, + `domain`, `cert`, `uptime`, `security_waf`, `security_acl`, `security_rate_limit`, + `security_crowdsec`, `test`) matched against per-provider boolean columns on the GORM model. +- Feature-flag gating (`isDispatchEnabled` → `getFeatureFlagValue`) reads `models.Setting` rows via + `s.DB` directly — this is Charon's own settings/feature-flag system, not something the new module + should own. +- `httpWrapper *notifications.HTTPWrapper` is the **only** call from this file into the + `internal/notifications` package for actual delivery — confirms `HTTPWrapper.Send` is the true + reusable primitive and everything else in this file is Charon-specific orchestration built on top + of it. + +**Conclusion**: this file is not "the engine with some Charon glue" — it's Charon's *product +feature* built on top of a much smaller generic engine. Fully genericizing it (replacing +`models.NotificationProvider` with a generic config struct, replacing `HostName`/`ServiceCount` +with a generic `Data map[string]any`, extracting the Discord/Slack/Gotify/Pushover/Ntfy/webhook +payload-building into provider packages, defining a `Mailer` interface for email) is real design +and implementation work, not a mechanical move. **This is now in scope for the same extraction — +see the revised §3.1 for the function-level split.** + +### 2.4 `security_notification_service.go` / `enhanced_security_notification_service.go` + +Both operate on `models.SecurityEvent` / `models.NotificationConfig` (GORM), and encode +Charon-specific security taxonomy (WAF blocks, ACL denies, rate-limit hits, CrowdSec decisions — +i.e. Charon's own proxy/WAF feature surface). `enhanced_security_notification_service.go` additionally +implements a legacy-config migration path (`MigrateFromLegacyConfig`, `computeConfigChecksum`) +that is pure Charon schema-evolution logic. Neither belongs in a generic notifications module — +they are consumers of it, not part of it. + +### 2.5 `backend/internal/models/notification*.go` + +All four files (`notification.go`, `notification_config.go`, `notification_provider.go`, +`notification_template.go`) are GORM models with `gorm:` struct tags and `BeforeCreate` UUID +hooks. Pure persistence — stay in Charon by definition. A generic module must not depend on GORM +at all (a future adopter may use Postgres, a different ORM, or no DB). + +### 2.6 `backend/integration/notification_http_wrapper_integration_test.go` + +Build-tagged `integration` test exercising `notifications.NewNotifyHTTPWrapper()` directly against +an `httptest.Server` (retry-on-429, no-retry-on-400, tokenized-query rejection). This test only +exercises the `HTTPWrapper` — it has zero dependency on Charon models/DB. It is a strong candidate +to move with the engine (it's effectively already an engine-level integration test), with a thin +Charon-side replacement or deletion once the import path changes. + +### 2.7 `docs/features/notifications.md` + +Documents the product feature end-to-end (provider setup, JSON template variables including +Charon-specific ones like `{{.HostName}}`, migration guide, "Charon Test" wording, links to +`github.com/Wikid82/charon`). This is Charon user documentation, not module documentation — stays +in Charon. The new module will need its own README/docs describing its generic API, written fresh +during the extraction session (not migrated from this file). + +### 2.8 Frontend + +Confirmed by line count and a read of the API client shape: `frontend/src/api/notifications.ts` +(271 LOC), `pages/Notifications.tsx` (758 LOC), `hooks/useNotifications.ts` (53 LOC), +`components/NotificationCenter.tsx` (157 LOC), plus their tests. All talk to Charon's REST API +(`/api/notifications`, `/api/notification-providers`, etc.) and render Charon's design system. This +is UI for Charon's product feature, not the engine. **Confirms the assumption in the task context +explicitly**: none of this moves. A Go module has no frontend; a *different* consuming project +would build its own UI (or none) against its own backend, not reuse Charon's React components. + +### 2.9 External dependencies / prior art + +- The org already publishes and releases via GoReleaser (`.goreleaser.yaml` at repo root, driven by + `.github/workflows/release-goreleaser.yml`) and has an `auto-versioning` workflow tied to + Conventional Commits. The new module should reuse this exact pattern rather than invent a new one + — same maintainer, same tooling, lower cognitive overhead. +- No `.codecov.yml` exists at the repo root currently (checked); Charon's coverage gate is enforced + via `scripts/go-test-coverage.sh` instead. The new module should carry its own equivalent + lightweight script rather than depend on Charon's. +- Go module path convention: Charon's backend module is + `github.com/Wikid82/charon/backend` (go 1.26.6). The new module should follow the same GitHub org + (`Wikid82`) unless the user decides otherwise when creating the repo. + +--- + +## 3. Technical Specifications + +### 3.1 Exact inventory — move list vs. stay list + +**Decision (resolved by the user): full provider-layer scope, in this same extraction.** The +Discord/Slack/Gotify/Pushover/Ntfy/Telegram/webhook payload builders and email dispatch are +genericized and moved into the new module now — not deferred to a v0.2. This supersedes the Phase-1-only +recommendation this section previously carried; risk #1 in §7 (previously "scope question, flag to +user") is now resolved and reframed as a behavior-parity risk instead. + +This re-read `notification_service.go` (1029 lines) in full, plus `mail_service.go` and the +`templates/*.html` files, to pin exact function boundaries rather than guessing. The seam (per the +revision brief) is: Charon's service layer maps its GORM `NotificationProvider` row + Charon event +data into the new module's generic `Message` type and a provider-specific `Config`, calls the +module's `Sender`/email `Mailer` to dispatch, and logs/persists the result. The module itself ends +up with **zero** imports of GORM, `models`, or any `github.com/Wikid82/charon/*` package. + +#### 3.1.1 Moves to the new module — delivery primitive (unchanged from prior draft) + +| File | Reason | +|---|---| +| `http_wrapper.go` | SSRF-hardened dispatch, retries, header sanitization. Zero Charon-domain knowledge — only needs the two DI seams in §3.2. | +| `http_wrapper_test.go` | Moves with its subject. | +| `http_client_executor.go` | Test seam used by `http_wrapper.go`. | +| `backend/integration/notification_http_wrapper_integration_test.go` | Exercises only `HTTPWrapper` (§2.6). | + +#### 3.1.2 Moves to the new module — provider payload/dispatch logic, function-level (NEW scope) + +All of the following are read directly out of `notification_service.go`'s `sendJSONPayload` (lines +383–677), `RenderTemplate` (788–832), and `dispatchEmail`/`sanitizeForEmail` (286–358), and mapped to +their destination package. None of these are simple moves — each is genericized per §3.3. + +| Current location (`notification_service.go`) | Logic | Destination | +|---|---|---| +| `minimalTemplate`/`detailedTemplate` consts (385–386, duplicated 792–793) | Built-in JSON templates | `providers/webhook`, genericized: `.HostName`/`.HostIP`/`.ServiceCount`/`.Services` top-level fields replaced with a single `{{toJSON .Data}}` (see "payload shape change" risk in §7). | +| Template parse/exec core (388–447, 811–825) — `text/template` + `toJSON` funcmap, 10 KB size cap, 5 s exec timeout | Shared rendering engine | `providers/internal/render` (unexported, shared by all seven provider packages — avoids 7x duplication of the same template plumbing). | +| `discordWebhookRegex`, `allowedDiscordWebhookHosts`, `normalizeURL`, `validateDiscordWebhookURL`, `validateDiscordProviderURL` (63–127) | Discord webhook URL shape/host validation | `providers/discord` | +| Discord payload normalization (`content`/`embeds` fallback, 458–475) | Discord-specific JSON shape | `providers/discord` | +| `slackWebhookRegex`, `validateSlackWebhookURL` (70–77) | Slack webhook URL shape validation | `providers/slack` | +| Slack payload normalization (`text`/`blocks` fallback, 476–493) + webhook-token substitution (577–586) | Slack-specific JSON shape + dispatch URL resolution | `providers/slack` | +| Gotify `message`-field validation (494–498) + `X-Gotify-Key` header (544–548) | Gotify-specific JSON shape + auth header | `providers/gotify` | +| Pushover `message`-field/priority validation (516–524) + URL build, token/user injection, hostname pin (594–627) | Pushover-specific JSON shape + dispatch URL/auth | `providers/pushover` | +| Ntfy `message`-field validation (525–528) + `Authorization: Bearer` header (588–592) | Ntfy-specific JSON shape + auth header | `providers/ntfy` | +| Telegram `text`-field validation with `message`-field fallback (499–515) + dispatch URL build from `telegramAPIBaseURL + "/bot" + token + "/sendMessage"` with hostname-pin check, `chat_id` injection from `p.URL` (550–575) | Telegram-specific JSON shape + dispatch URL/auth (bot token embedded in URL path, not a header; `p.URL` repurposed as chat ID) | `providers/telegram` | +| Generic/custom webhook dispatch (the plain `webhook`/`generic` case) | Passthrough JSON dispatch, no provider-specific shape | `providers/webhook` | +| `isValidRedirectURL` (685–697) | Generic URL sanity check used before Discord dispatch | Moves with Discord validation into `providers/discord` (only call site). | +| `webhookDoRequestFunc` test hook (375–377) | Test seam for the raw-dispatch path | **Dropped**, not ported — redundant with `http_client_executor.go`'s seam once Discord/webhook dispatch is consolidated onto the shared `transport.Wrapper` (see flagged inconsistency below). One test seam per module, not two. | +| `RenderTemplate` (788–832) | Template preview/validation for the provider-editor UI | Logic moves to `providers/webhook.RenderPreview(tmplStr string, msg notify.Message) (json string, parsed any, err error)` — a public function, reusable for previewing any of the seven provider types since they all share the same template mechanism today. Charon's `CreateProvider`/`UpdateProvider` keep a thin wrapper extracting `.Config`/`.Template` from the GORM row and calling it. | +| `sanitizeForEmail` (286–299) | Control-char stripping for email hygiene | `providers/email` — generic, zero Charon dependency already. | +| `dispatchEmail`'s message composition (safeTitle/safeMessage, subject formatting, `EmailTemplateData` construction; 322–350) | Email message assembly | `providers/email`, genericized: subject becomes `Config.SubjectPrefix + msg.Title` (prefix `""` by default, no `"[Charon Alert]"` baked in — see §3.3.4). | + +**Flagged inconsistency found on this re-read, resolved as part of the extraction:** the plain +`webhook`/`generic` dispatch path (lines 639–676) does **not** go through `httpWrapper.Send` today — +unlike gotify/webhook-JSON/telegram/slack/pushover/ntfy (line 531's list, which *does* include +`"webhook"` for the JSON-template path), this fallback branch calls `security.ValidateExternalURL` + +`network.NewSafeHTTPClient` directly, bypassing the shared engine's retry/backoff entirely. This +fallback branch is in practice the **Discord** path plus the literal `"generic"` provider type, +since every other supported type is caught by the line-531 list first. Recommend consolidating +`providers/discord` and `providers/webhook`'s dispatch onto the shared `transport.Wrapper` (via the +Seam 1/2 DI in §3.2) for consistency. This is a genuine behavior change (retry/backoff semantics, +not just a refactor) — called out as a new risk in §7, not silently folded in. + +#### 3.1.3 Stays in Charon (per the revision brief) — GORM CRUD, flag gating, event routing + +| Function(s) | Reason | +|---|---| +| `SendExternal` (215–284) | Event-type filtering against Charon domain concepts (`proxy_host`/`remote_server`/`domain`/`cert`/`uptime`/`security_*`) mapped to `models.NotificationProvider` boolean columns, plus the GORM `Find` query. Becomes the seam: after filtering + flag-check, maps provider row + event data into `notify.Message` + provider `Config`, calls the module's `Sender`, logs the result. | +| `isDispatchEnabled`, `getFeatureFlagValue` (148–180) | DB-backed feature-flag gating via `models.Setting` — explicitly named as staying in the revision brief. | +| `emailTemplateForEventType` (360–371) | Charon event-type → HTML template name mapping. Stays; becomes the `TemplateName` selector Charon passes into its `providers/email` adapter (§3.3.4). | +| `Create`/`List`/`MarkAsRead`/`MarkAllAsRead` (184–211) | Pure GORM CRUD for the in-app `Notification` bell/log — never part of the engine. | +| `ListProviders`/`CreateProvider`/`UpdateProvider`/`DeleteProvider` (836–946) | Provider GORM CRUD + Charon's own field-level validation (type immutability, token retention rules). Calls the module's new `providers/webhook.RenderPreview` for custom-template validation (thin wrapper, per §3.1.2). | +| `ListTemplates`/`GetTemplate`/`CreateTemplate`/`UpdateTemplate`/`DeleteTemplate` (756–786) | GORM CRUD for `NotificationTemplate` rows. | +| `isSupportedNotificationProviderType`, `supportsJSONTemplates` (129–146) | Charon's own provider-type allowlist gating its REST API/UI input — mirrors the module's provider set but is Charon's validation boundary, not module logic. **Note**: both include `"telegram"`, which is now in scope as the module's seventh provider (§7 risk 1e, resolved). | +| `EnsureNotifyOnlyProviderMigration` (948–1029) | Pure Charon schema-evolution/migration logic (Discord-only rollout reconciliation). Unrelated to the engine. | +| `TestProvider`/`TestEmailProvider` (699–753) | Backing logic for Charon's REST "send test notification" handlers. Become thin adapters: build a `notify.Message`, call the module's `Sender`/`email` client, same seam as `SendExternal`. | + +**Deleted, not ported (dead code confirmed on this re-read):** +- `isPrivateIP(ip net.IP) bool` (679–683) in `notification_service.go` — a wrapper around + `network.IsPrivateIP` with **zero call sites within the file itself** (confirmed by reading the + full 1029 lines; the identically-named functions in `access_list_service.go` and the + `hecate/providers/{netbird,zerotier}` clients are unrelated, independently-defined helpers). Drop + entirely rather than carry forward. + +#### 3.1.4 Stays in Charon — unchanged (persistence, security infra, unrelated features) + +| File / area | Reason | +|---|---| +| `backend/internal/models/notification.go`, `notification_config.go`, `notification_provider.go`, `notification_template.go` | GORM persistence models; a generic module must not depend on GORM. | +| `backend/internal/services/security_notification_service.go`, `enhanced_security_notification_service.go` + their tests | Charon-specific security event taxonomy (WAF/ACL/rate-limit/CrowdSec) and legacy-config migration logic. Consumers of the engine, not part of it. | +| `docs/features/notifications.md` | Charon user-facing product documentation; the new module gets its own fresh README. | +| `frontend/src/api/notifications.ts`, `pages/Notifications.tsx`, `hooks/useNotifications.ts`, `components/NotificationCenter.tsx` + tests | Charon product UI, confirmed to have no reusable-module role (§2.8). | +| `backend/internal/network/*`, `backend/internal/security/*` | Shared Charon infrastructure used by Caddy, CrowdSec, uptime monitoring, auth, config — far beyond notifications (confirmed by repo-wide grep in §2.2). Stay in Charon; the new module receives DI seams instead (§3.2). | +| `mail_service.go`'s SMTP transport (`SendEmail`, `GetSMTPConfig`, connection handling) and the five HTML templates (`templates/*.html`) | SMTP credentials/connection lifecycle and Charon's branded, event-differentiated email design (`email_base.html` says "Charon" / "Charon Reverse Proxy Manager"). Charon supplies these behind the module's `Mailer`/`TemplateRenderer` interfaces (§3.3.4) — they do not move. | + +### 3.2 Coupling points to decouple + +The delivery-primitive coupling point is unchanged from the prior draft: `http_wrapper.go`'s use of +`internal/network` and `internal/security` for SSRF-safe HTTP client construction and destination +URL validation. With full provider-layer scope, this same seam is now consumed by **every** HTTP-based +provider package (`discord`, `slack`, `gotify`, `pushover`, `ntfy`, `telegram`, `webhook`) via the shared +`transport.Wrapper` (§3.3.1/§3.5), not just by a single Charon call site — which is exactly what +resolves the discord-dispatch inconsistency flagged in §3.1.2. The email package (§3.3.4) has no +equivalent coupling: it never touches `internal/network`/`internal/security` at all, since SMTP +transport stays entirely behind the host-supplied `Mailer` interface. + +#### Seam 1 — safe HTTP client factory + +```go +// new module: package transport (see §3.5 for the module layout) + +// ClientFactory builds the *http.Client used for outbound provider requests. +// The host application is responsible for SSRF hardening (private-IP blocking, +// DNS-rebinding protection, redirect limits) inside its implementation. +type ClientFactory func(allowHTTP bool, maxRedirects int) *http.Client +``` + +Charon supplies, at the call site where it constructs the wrapper: + +```go +// Charon side, e.g. in services/notification_service.go or a small adapter file +factory := func(allowHTTP bool, maxRedirects int) *http.Client { + opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)} + if allowHTTP { + opts = append(opts, network.WithAllowLocalhost()) + } + return network.NewSafeHTTPClient(opts...) +} +wrapper := transport.NewWrapper(transport.WithClientFactory(factory), ...) +``` + +#### Seam 2 — destination URL validator + +```go +// new module + +// URLValidator validates and normalizes a destination URL before dispatch, +// returning the (possibly normalized) URL or an error if the destination is +// disallowed. Implementations are expected to enforce the host application's +// SSRF policy (private-IP blocking, scheme allowlisting, etc.). +type URLValidator func(rawURL string, allowHTTP bool) (string, error) +``` + +Charon supplies an adapter that calls `security.ValidateExternalURL` with the equivalent +`WithAllowHTTP()`/`WithAllowLocalhost()` options translated from the bool flag. + +#### Seam 3 — private-IP / destination guard (used by `guardDestination`/`isAllowedDestinationIP`) + +The current code calls `network.IsPrivateIP(ip)` directly inside `HTTPWrapper.guardDestination`. +Fold this into the same `URLValidator` contract by making validator responsible for **all** +destination-safety decisions (scheme, host, IP-literal, DNS-resolved IPs) rather than splitting +SSRF logic between the module and the callback. This keeps the new module's dependency-injection +surface to exactly two functional options (`WithClientFactory`, `WithURLValidator`) instead of +three overlapping ones, and avoids the module re-implementing partial SSRF logic that could drift +from Charon's `network.IsPrivateIP`. + +**No-op / minimal default**: ship the module with a conservative built-in default validator (reject +non-HTTPS, reject IP literals resolving to RFC 1918/loopback/link-local/reserved ranges — i.e., a +self-contained reimplementation of the *IP classification* portion only, which has zero +Charon-specific dependencies as confirmed in §2.2) so the module is immediately useful standalone +without forcing every consumer to write SSRF logic from scratch. Charon overrides this default with +its own `network`/`security`-backed validator via `WithURLValidator` to keep single-source-of-truth +SSRF policy. This is explicitly called out as an **open question** in §7 — it duplicates ~140 LOC of +IP-classification logic between Charon's `internal/network` and the new module's default validator, +which is an acceptable, bounded duplication (public IP-range constants, not business logic) but +should be a conscious choice, not an accident. + +#### Seam 4 — env-var overrides (`CHARON_NOTIFY_ALLOW_HTTP`, `CHARON_NOTIFY_MAX_REDIRECTS`) + +Currently read via `os.Getenv` directly inside `http_wrapper.go`. Replace with constructor +parameters (`allowHTTP bool`, `maxRedirects int`) passed by the caller. Charon's +`NewNotificationService`/adapter reads its own env vars (still named `CHARON_NOTIFY_*` for +backward compatibility with existing deployments) and passes the resolved values in. This removes +the module's only direct env/config coupling and makes it framework-agnostic (a consumer using +Viper, flags, or hardcoded config all work identically). + +### 3.3 Public API surface (new module) + +The module now has four layers, not one: the delivery primitive (§3.3.1, unchanged design from the +prior draft, just relocated to a `transport` subpackage — see the naming note in §3.5/§7), a +generic `Message` type shared by every provider (§3.3.2), a `Sender` interface implemented per +provider package (§3.3.3), and an email-specific `Mailer`/`TemplateRenderer` design (§3.3.4). Only +§3.3.1 was in the original Phase-1 scope; §§3.3.2–3.3.4 are new to this revision. + +#### 3.3.1 Delivery primitive (`transport` package) + +```go +package transport + +// Wrapper dispatches outbound notification payloads with SSRF-safe validation, +// retry/backoff, and response-size caps. +type Wrapper struct { /* unexported */ } + +// Option configures a Wrapper at construction time. +type Option func(*wrapperConfig) + +func WithClientFactory(f ClientFactory) Option +func WithURLValidator(v URLValidator) Option +func WithRetryPolicy(p RetryPolicy) Option +func WithAllowHTTP(allow bool) Option +func WithMaxRedirects(n int) Option + +func NewWrapper(opts ...Option) *Wrapper + +type RetryPolicy struct { + MaxAttempts int + BaseDelay time.Duration + MaxDelay time.Duration +} + +type Request struct { + URL string + Headers map[string]string + Body []byte +} + +type Result struct { + StatusCode int + ResponseBody []byte + Attempts int +} + +func (w *Wrapper) Send(ctx context.Context, req Request) (*Result, error) +``` + +This is a rename/generalization of `HTTPWrapper`/`HTTPWrapperRequest`/`HTTPWrapperResult`. Behavior +(retry/backoff, header allowlist, size caps, redirect re-validation) is unchanged from +`http_wrapper.go`. **What's new in this revision**: every HTTP-based provider package in §3.3.3 +calls into this same `Wrapper` rather than making its own `net/http` calls — this is what resolves +the discord/generic-path inconsistency flagged in §3.1.2 (today, Discord dispatch bypasses the +wrapper entirely). + +#### 3.3.2 Generic `Message` type (module root package, proposed `notify`) + +Replaces Charon's `HostName`/`ServiceCount`-style fields with something generic enough for an +unrelated project's domain: + +```go +package notify + +// Message is the generic, provider-agnostic notification payload. Host +// applications map their own domain events into a Message before calling a +// Sender or the email package's client. +type Message struct { + // Title is a short headline (was Charon's data["Title"]). + Title string + + // Body is the human-readable message text (was Charon's data["Message"]). + Body string + + // EventType is a free-form, host-defined category string. Provider + // packages treat it as an opaque template field only — never for + // routing, access-control, or filtering decisions. (Charon's own + // proxy_host/cert/uptime/security_* routing logic stays entirely in + // Charon's SendExternal — see §3.1.3. A different adopter would define + // its own event-type vocabulary; the module has no opinion on it.) + EventType string + + // Timestamp defaults to time.Now() if zero when Send is called. + Timestamp time.Time + + // Data holds arbitrary structured extras (replaces Charon's + // HostName/HostIP/ServiceCount/Services fields). Provider templates + // expose it as {{toJSON .Data}} or {{index .Data "key"}}. + Data map[string]any +} +``` + +#### 3.3.3 `Sender` interface and provider packages + +```go +package notify + +// Sender dispatches a Message through one specific provider's transport and +// payload shape. Every providers/* package (including providers/email) +// returns a type implementing this, so a host application can treat all +// configured destinations uniformly. +type Sender interface { + Send(ctx context.Context, msg Message) error +} +``` + +Each HTTP-based provider package owns its own `Config`, URL/token validation, JSON payload shape, +and header/auth construction (per the function-level table in §3.1.2), but shares the same template +engine (`providers/internal/render`, unexported) and the same `transport.Wrapper` for dispatch. +Two representative examples — the rest (`slack`, `gotify`, `pushover`, `ntfy`, `telegram`) follow the +identical shape, differing only in `Config` fields and the provider-specific validation/header logic +already itemized in §3.1.2: + +```go +package discord + +type Config struct { + WebhookURL string // validated against discord.com/canary.discord.com per §3.1.2 + Template string // "minimal" | "detailed" | "custom" + CustomTemplate string +} + +func New(cfg Config, w *transport.Wrapper) *Client +func (c *Client) Send(ctx context.Context, msg notify.Message) error +``` + +```go +package webhook + +type Config struct { + URL string // arbitrary destination; no host allowlist (unlike discord/slack) + Template string + CustomTemplate string +} + +func New(cfg Config, w *transport.Wrapper) *Client +func (c *Client) Send(ctx context.Context, msg notify.Message) error + +// RenderPreview renders tmplStr against msg without dispatching — used by a +// host app's provider-editor UI to validate a custom template before saving +// it. Replaces Charon's RenderTemplate (§3.1.2); reusable for previewing any +// of the seven provider types since they share the same template mechanism. +func RenderPreview(tmplStr string, msg notify.Message) (rendered string, parsed any, err error) +``` + +Telegram, unlike in earlier drafts of this spec, **is** one of these packages — the user confirmed +including it as the module's seventh provider (§7 risk 1e, resolved; §3.6 step 6), so it moves and +is cut over on the same per-provider commit pattern as the other six (§6). + +**Extensibility design, without building it yet (see §8):** the uniform `Sender` interface and the +"each provider is a fully self-contained package, importable independently, with no central +switch-statement inside the module" convention are chosen deliberately so that a future +Apprise-style registry (e.g. `notify.Register(scheme string, factory func(cfg string) (Sender, +error))`, letting a caller dispatch off a URL like `discord://...`) can be bolted on later as pure +addition — no existing provider package needs to change shape to support it. This extraction does +**not** build that registry: with seven known providers and one consumer (Charon), a generic +registry mechanism today would be premature abstraction. The provider-type-string-to-package +mapping stays where it already naturally lives — Charon's own `notify_provider_adapter.go` (§3.6) — +rather than inside the module, which is exactly the seam a future registry would replace without +touching `providers/discord`, `providers/slack`, etc. individually. + +#### 3.3.4 `Mailer`/`TemplateRenderer` and the `providers/email` package + +Unlike the other five providers, email dispatch today lives entirely in Charon's `mail_service.go` +— SMTP transport, connection lifecycle, and five branded HTML templates +(`email_base.html`/`email_security_alert.html`/`email_ssl_event.html`/`email_uptime_event.html`/ +`email_system_event.html`, all saying "Charon" / "Charon Reverse Proxy Manager"). There is no +existing `internal/notifications` email code to move — this is new abstraction design layered over +existing Charon code, which makes it the highest-design-risk piece of this extraction (see §7). + +**Design decision**: the module never dials SMTP and never renders HTML directly by default. Two +interfaces isolate the two concerns the host application owns: + +```go +package email + +// Mailer transports an already-composed email. The host application owns +// SMTP configuration, authentication, and connection lifecycle — the module +// never sees credentials. +type Mailer interface { + Send(ctx context.Context, recipients []string, subject, htmlBody string) error +} + +// TemplateRenderer renders an HTML email body for a Message using a +// host-selected template name. Optional: if the host doesn't supply one, +// the package falls back to its own single neutral built-in template. +type TemplateRenderer interface { + Render(templateName string, msg notify.Message) (htmlBody string, err error) +} + +type Config struct { + Recipients []string + SubjectPrefix string // "" by default — no "[Charon Alert]" baked in + TemplateName func(msg notify.Message) string // optional; host's event-type -> template-name mapping. nil = constant "default". + Renderer TemplateRenderer // optional; nil uses the package's built-in neutral template + Mailer Mailer // required +} + +func New(cfg Config) *Client +func (c *Client) Send(ctx context.Context, msg notify.Message) error // implements notify.Sender +``` + +**Tradeoff, decided**: ship exactly **one** neutral, unbranded, inline-styled default HTML template +in the module (no logo, no product name) so the module is immediately useful standalone with zero +config — a bare `Mailer` implementation is enough to get working email out of a fresh adopter. +Charon **must** override `Renderer` (wrapping its existing `MailServiceInterface.RenderNotificationEmail` +and its five branded templates) and `TemplateName` (wrapping `emailTemplateForEventType`, which +stays in Charon per §3.1.3) — this is not optional, since dropping to the module's neutral default +would be a user-visible regression of Charon's existing branded, event-differentiated email design. +This requirement is called out explicitly in the Charon migration plan (§3.6) and as a risk in §7, +not left implicit. + +`sanitizeForEmail`'s control-char stripping (§3.1.2) is applied unconditionally inside `Send` to +`msg.Title`/`msg.Body` before either subject formatting or template rendering — generic hygiene, +zero Charon dependency, no reason to make it optional. + +**Branding removal, applied module-wide**: the `User-Agent: Charon-Notify/1.0` header +(`sendJSONPayload`, line 534) becomes a generic module default (e.g. `notify-transport/1.0`), +overridable per-provider-package `Config` if a host wants its own UA string. `"[Charon Alert]"` and +`"[Charon Test]"` become Charon-side `SubjectPrefix` values passed into the adapter (§3.6), not +module defaults. + +### 3.4 Database schema changes + +None. This extraction touches zero database schema — all persistence stays in Charon's +`internal/models`. + +### 3.5 New repo structure + +Provider-specific senders are now built out for real (the prior draft sketched a `providers/` +layout as "not built now" — that's flipped). **The repo already exists** at +`/projects/go_notify_yourself` (`github.com/Wikid82/go_notify_yourself`, currently just `LICENSE` + +a placeholder `README.md`) — the extraction session scaffolds into it, it does not create a new +repo. The Go package name at the module root is `notify` (see the rev-2 note at the top of this +document for why that's a deliberate, unproblematic mismatch with the directory/module name). + +``` +go_notify_yourself/ # existing repo at /projects/go_notify_yourself — see rev-2 note +├── go.mod # module github.com/Wikid82/go_notify_yourself +├── go.sum +├── LICENSE # match Charon's license +├── README.md # public API docs, usage examples per provider, SSRF-seam explanation +├── CHANGELOG.md # Keep a Changelog format, driven by Conventional Commits +├── .goreleaser.yaml # mirrors Charon's root .goreleaser.yaml, Go-module release only +├── .github/ +│ └── workflows/ +│ ├── ci.yml # go test ./..., go vet, staticcheck, coverage gate +│ └── release.yml # tag-triggered GoReleaser run +├── message.go # Message struct (§3.3.2) +├── sender.go # Sender interface (§3.3.3) +├── message_test.go +├── transport/ # the SSRF-safe delivery primitive (§3.3.1) +│ ├── wrapper.go # Wrapper, Option, NewWrapper, Send — from http_wrapper.go +│ ├── wrapper_test.go +│ ├── client_executor.go # test seam, from http_client_executor.go +│ ├── retry.go # RetryPolicy + backoff/jitter helpers +│ ├── validate_default.go # built-in conservative URLValidator default (§3.2 Seam 3) +│ ├── validate_default_test.go +│ └── integration/ +│ └── wrapper_integration_test.go # from backend/integration/notification_http_wrapper_integration_test.go +└── providers/ + ├── internal/ + │ └── render/ # unexported shared text/template + toJSON engine (§3.1.2) + │ ├── render.go + │ └── render_test.go + ├── discord/ + │ ├── discord.go # Config, New, (*Client).Send — webhook validation, content/embeds normalization + │ └── discord_test.go + ├── slack/ + │ ├── slack.go # Config, New, (*Client).Send — webhook validation, text/blocks normalization + │ └── slack_test.go + ├── gotify/ + │ ├── gotify.go # Config, New, (*Client).Send — X-Gotify-Key header, message-field validation + │ └── gotify_test.go + ├── pushover/ + │ ├── pushover.go # Config, New, (*Client).Send — token/user injection, hostname pin + │ └── pushover_test.go + ├── ntfy/ + │ ├── ntfy.go # Config, New, (*Client).Send — Bearer auth header + │ └── ntfy_test.go + ├── telegram/ + │ ├── telegram.go # Config, New, (*Client).Send — bot-token-in-URL dispatch, chat_id injection, hostname pin + │ └── telegram_test.go + ├── webhook/ + │ ├── webhook.go # Config, New, (*Client).Send — generic/custom JSON dispatch + │ ├── preview.go # RenderPreview(tmplStr, msg) — public (§3.1.2) + │ └── webhook_test.go + └── email/ + ├── email.go # Config, Mailer, TemplateRenderer, New, (*Client).Send (§3.3.4) + ├── default_template.go # single neutral, unbranded built-in HTML template + └── email_test.go +``` + +### 3.6 Migration plan for Charon (after the new module exists) + +1. **Add dependency**: `go get github.com/Wikid82/go_notify_yourself@v0.1.0` in `backend/go.mod`. +2. **Delete extracted files/logic**: + - Whole files: `backend/internal/notifications/{http_wrapper,http_wrapper_test,http_client_executor,engine,router,router_test}.go`. + - Function-level deletions inside `notification_service.go` (now that the module owns this + logic — not kept as dead code): `minimalTemplate`/`detailedTemplate` consts, the template + parse/exec block, all Discord/Slack/Gotify/Pushover/Ntfy/Telegram regex/validation/ + normalization/header/dispatch-URL-build code, `sendJSONPayload` and `RenderTemplate` themselves + (replaced by adapter calls), `sanitizeForEmail`, `dispatchEmail`'s message-composition + internals, `webhookDoRequestFunc`, and the dead `isPrivateIP` wrapper (§3.1.3). + - Keep `feature_flags.go` (per §2.2, it stays — Charon policy, not engine code); fold it into + `internal/services` rather than keeping a single-file package once the rest of + `internal/notifications` is gone. +3. **Add Charon-side adapters** (new files): + - `notify_client_adapter.go`: wires `network.NewSafeHTTPClient`/`security.ValidateExternalURL` + into `transport.ClientFactory`/`URLValidator` (§3.2), resolves `CHARON_NOTIFY_ALLOW_HTTP`/ + `CHARON_NOTIFY_MAX_REDIRECTS`. One shared `*transport.Wrapper` instance, injected into every + HTTP-based provider package. + - `notify_provider_adapter.go` (**new**): per-`provider.Type` factory mapping a GORM + `models.NotificationProvider` row into the matching `discord.Config`/`slack.Config`/ + `gotify.Config`/`pushover.Config`/`ntfy.Config`/`telegram.Config`/`webhook.Config` and + constructing the corresponding `notify.Sender`. + - `notify_email_adapter.go` (**new**): implements `email.Mailer` (wrapping + `s.mailService.SendEmail`) and `email.TemplateRenderer` (wrapping + `s.mailService.RenderNotificationEmail`), and supplies `TemplateName: emailTemplateForEventType` + (kept in Charon, §3.1.3) and `SubjectPrefix: "[Charon Alert] "` to preserve the exact + user-visible subject format. +4. **Update `notification_service.go`**: `SendExternal`'s per-provider dispatch becomes — build a + `notify.Message` from `title`/`message`/`eventType`/`data`; use `notify_provider_adapter.go` to + build the right `Config` + `Sender` for `provider.Type`; call `sender.Send(ctx, msg)`; log the + result. `TestProvider`/`TestEmailProvider` become the same shape. Everything named in §3.1.3 + (CRUD, `isDispatchEnabled`/`getFeatureFlagValue`, `emailTemplateForEventType`, + `EnsureNotifyOnlyProviderMigration`, the provider-type allowlists) is unchanged. +5. **Resolve the "detailed" template backward-compatibility question** (flagged in §3.1.2/§7): the + module's generic `detailed` template nests host-specific fields under `Data` instead of exposing + `HostName`/`HostIP`/`ServiceCount`/`Services` at the JSON top level. Decide, explicitly, whether + Charon supplies its own `CustomTemplate` string reproducing the old flat shape for + already-configured `detailed`-template providers (safer, avoids a silent payload-shape change + for existing integrations) or accepts the shape change with a changelog note. Do not let this be + decided implicitly by whichever behavior the ported code happens to produce. +6. **Telegram gap — RESOLVED.** User confirmed: add `providers/telegram` to the module alongside + the other six, so all seven of Charon's current provider types are consistent (none bypass the + module as a special case). Update §3.1.3, §3.5's `providers/` layout, and the Appendix move-list + to include a `providers/telegram` package; it moves through the same commit-per-provider pattern + as the other six in §6. +7. **Update the integration test import**: delete + `backend/integration/notification_http_wrapper_integration_test.go` (moved to the new repo); if + Charon wants an adapter-level integration test proving the DI seams work end-to-end with real + `network`/`security` code, write a **new**, small integration test — not a port of the old one. +8. **Preserve coverage, and expect substantial test rewrites, not just import changes**: this + extraction now removes the majority of `notification_service.go`'s production code (roughly 450 + of 1029 lines — everything in §3.1.2), not just the ~570 LOC `http_wrapper.go` alone. The three + existing test files most affected — + `notification_service_test.go`/`notification_service_json_test.go`/ + `notification_service_discord_only_test.go` (125 KB combined) — assert directly on internals + (payload shapes, validation error strings, header construction) that are moving to the module. + These suites need **real rewriting** against the new adapter seam, not a mechanical + import-path swap. See the relaxed acceptance criterion in §5 and the new risk in §7 — the prior + draft's "existing suites pass unmodified" bar does not hold for this scope. +9. **Update `docs/features/notifications.md`**: no required content change (documents the product + feature, not the internal package); optionally credit the new module. +10. **Update `ARCHITECTURE.md`**: per CLAUDE.md's mandatory rule, add a line noting outbound + notification dispatch (all seven provider types plus email) now goes through the external + `notify` module with Charon-supplied SSRF/SMTP/template adapters, rather than internal packages. + +### 3.7 Error handling / edge cases for the extraction session to watch + +- **Import cycle risk**: none anticipated — `internal/network` and `internal/security` don't + import `internal/notifications` or `internal/services`, so removing the notifications→network/ + security edge and replacing it with notifications←(Charon adapter)→network/security is a clean + DI inversion, not a cycle fix. This still holds with the larger scope: the new + `notify_provider_adapter.go`/`notify_email_adapter.go` adapters are additional inversion points of + the same shape, not new dependency directions. +- **Env var behavior drift**: `allowNotifyHTTPOverride()` currently special-cases + `os.Args[0]` ending in `.test` to auto-allow HTTP during `go test`. If this logic moves to + Charon's `notify_client_adapter.go` (per Seam 4), the adapter must preserve this test-detection + behavior or existing tests that rely on it will start failing against real HTTPS-only validation. +- **`os.Args[0]` test-detection is itself a code smell** worth flagging to the extraction session: + it's exactly the kind of implicit-environment coupling the module boundary should force out. + Recommend the Charon adapter accept an explicit `allowHTTP bool` (e.g. from `CHARON_ENV`) rather + than sniffing `os.Args[0]`, and that test setup pass it explicitly. This is a minor + behavior-preserving refactor to fold into the migration commits, not a new risk. +- **Provider-specific dispatch-URL construction is not uniform** (confirmed on this re-read): + Telegram/Pushover build their dispatch URL from a base-URL + path (with a hostname-pin check + against DNS-spoofed base URLs); Slack substitutes a decrypted token as the entire dispatch URL; + Gotify/Ntfy dispatch to `provider.URL` directly and add an auth header instead. Each provider + package's `Send` must reproduce its specific construction exactly — this is precisely the + behavior-parity risk called out in §7, not a detail that can be generalized away. + +--- + +## 4. Implementation Plan (for the future extraction session — not executed here) + +This plan is written for reference by the session that actually performs the extraction. It follows +this repo's phase convention but the "Playwright"/"Frontend" phases are replaced since this is a +backend-only, cross-repo change with no UI surface. Expanded from the prior 5-phase Phase-1-only +plan to cover the full provider layer. + +### Phase 1: New-repo scaffolding + shared types + transport (new repo) +- Scaffold into the **existing** `/projects/go_notify_yourself` repo (`LICENSE` already present): + `go.mod` (`module github.com/Wikid82/go_notify_yourself`), CI workflow, GoReleaser config (§3.5). +- Write `message.go` (`Message`, §3.3.2) and `sender.go` (`Sender`, §3.3.3) at the module root. +- Copy `http_wrapper.go` → `transport/wrapper.go`, renaming exported identifiers per §3.3.1, + replacing the two Charon imports with the `ClientFactory`/`URLValidator` seam (§3.2). +- Copy/adapt `http_wrapper_test.go`, `http_client_executor.go` into `transport/`. +- Write the built-in default `URLValidator` (§3.2 Seam 3) + its own tests. +- Copy the integration test (§2.6) into `transport/integration/`, update package/import path. +- `go test ./...`, `go vet ./...`, `staticcheck ./...` all green. + +### Phase 2: Provider packages (new repo) +- `providers/internal/render`: extract the shared `text/template` + `toJSON` engine (§3.1.2) out of + `sendJSONPayload`/`RenderTemplate`, generic over `notify.Message`. +- `providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, + `providers/telegram`, `providers/webhook`: one package at a time, each porting its slice of §3.1.2's table (URL + validation, JSON normalization, dispatch URL/header construction), consolidating Discord onto + `transport.Wrapper` per the flagged inconsistency. `providers/webhook` additionally gets + `RenderPreview`. +- `providers/email`: build `Mailer`/`TemplateRenderer`/`Config`/`Client` (§3.3.4), including the one + neutral default HTML template. +- Each package ships with its own tests at parity with (or exceeding) the coverage the equivalent + logic had inside `notification_service_*_test.go`. +- Tag `v0.1.0` once all provider packages + transport are green. + +### Phase 3: Charon-side adapters (Charon repo, this repo) +- Add `github.com/Wikid82/go_notify_yourself` to `backend/go.mod`. +- Write `notify_client_adapter.go` (transport seam, §3.6 step 3). +- Write `notify_provider_adapter.go` (per-type `Config`/`Sender` factory, §3.6 step 3). +- Write `notify_email_adapter.go` (`Mailer`/`TemplateRenderer` wiring, §3.6 step 3). +- Resolve the "detailed" template backward-compat decision (§3.6 step 5) and the Telegram gap + (§3.6 step 6) explicitly, before proceeding to cutover. + +### Phase 4: Charon cutover +- Update `notification_service.go`'s `SendExternal`/`TestProvider`/`TestEmailProvider` to call the + new adapters (§3.6 step 4), **split per provider per §6** for reviewability rather than one giant + commit. +- Delete extracted files and dead function-level code from `notification_service.go` and + `backend/internal/notifications/` (§3.6 step 2). +- Delete `backend/integration/notification_http_wrapper_integration_test.go`. +- Rewrite (not just relink) the affected slices of + `notification_service_test.go`/`notification_service_json_test.go`/ + `notification_service_discord_only_test.go` against the new adapter seam. +- Run full backend test suite + coverage gate (§3.6 step 8). + +### Phase 5: Hardening + docs +- `ARCHITECTURE.md` update (§3.6 step 10). +- CodeQL/Trivy re-run on Charon (new external dependency, larger surface than the Phase-1 draft). +- Confirm `go.sum`/supply-chain scan clean for the new module dependency. + +### Phase 6: Deployment +- Tag Charon release per normal Conventional Commits flow. Unlike the Phase-1-only draft, this is + **not** guaranteed behavior-invisible — the "detailed" template shape decision (Phase 3) and the + Discord-dispatch consolidation (§3.1.2) are both potential user-visible or operationally-visible + changes and should be called out in release notes if either is accepted as-is rather than shimmed + for compatibility. + +--- + +## 5. Acceptance Criteria (for the extraction session's Definition of Done) + +- [ ] New repo exists, `go test ./...` and `staticcheck` pass with zero findings, tagged `v0.1.0`. +- [ ] New module has zero imports of anything under `github.com/Wikid82/charon/*`. +- [ ] Each of the seven provider packages (`discord`, `slack`, `gotify`, `pushover`, `ntfy`, + `telegram`, `webhook`) plus `providers/email` has its own test suite at ≥85% coverage (mirrors + Charon's own bar). +- [ ] `providers/webhook.RenderPreview` covers custom-template validation equivalent to the old + `RenderTemplate`'s test coverage. +- [ ] `providers/email`'s default built-in template is neutral/unbranded — a grep for `Charon` (or + any other host-app name) across the new repo returns zero hits outside README/CHANGELOG. +- [ ] Charon's `go.mod` depends on the new module at a pinned semver tag (no `replace` directive + left in place post-merge). +- [ ] `backend/internal/notifications/` package is deleted entirely (unlike the Phase-1 draft, + `feature_flags.go` is folded into `internal/services` rather than left as a lone-file package + — §3.6 step 2). +- [ ] `notification_service.go`'s black-box behavior is unchanged **except** for the two explicitly + documented, deliberate changes (the "detailed" template payload shape and the Discord-dispatch + retry/backoff consolidation) — both must be resolved as conscious decisions per §3.6 steps 5 + and the risk in §7, not accidental drift. This replaces the prior draft's stronger claim that + existing test assertions pass *unmodified*: with this scope, rewriting + `notification_service_test.go`/`notification_service_json_test.go`/ + `notification_service_discord_only_test.go` is expected and required, but the rewritten + assertions must still prove equivalent (or knowingly-changed) external behavior. +- [ ] Charon's backend coverage gate (`scripts/go-test-coverage.sh`, min 85%) still passes after the + ~450-line reduction in `notification_service.go` and the corresponding test rewrites. +- [ ] `ARCHITECTURE.md` updated. +- [ ] No behavior change observable from the frontend or API beyond the two documented exceptions + above. + +--- + +## 6. Commit Slicing Strategy + +This spec spans **two repositories**, so "one feature = one PR" applies **per repository**: the new +module's scaffolding-through-providers work is one PR in the new repo; Charon's consumption of it is +a second, separate PR in *this* repo (two different features in two different codebases, each +individually complete and mergeable on its own — not a violation of one-feature-one-PR). Within each +PR, commits are ordered and logical. The full-scope decision roughly **triples** the new-module PR's +commit count and requires splitting the Charon cutover per-provider for reviewability, per the +revision brief. + +### New-module repo — PR "Initial notify engine + provider layer" + +1. **Commit 1** — Scaffolding: `go.mod`, LICENSE, README stub, CI workflow (no behavior). Gate: `go build ./...`. +2. **Commit 2** — Shared types: `message.go` (`Message`), `sender.go` (`Sender`). Gate: `go build ./...`. +3. **Commit 3** — Transport core: `transport/wrapper.go`, `client_executor.go`, `retry.go`, ported + from `http_wrapper.go`/`http_client_executor.go` with seam interfaces substituted for direct + `network`/`security` calls. Gate: `go vet`, `staticcheck`. +4. **Commit 4** — Transport tests: `transport/wrapper_test.go`, adapted to inject fake + `ClientFactory`/`URLValidator`. Gate: `go test ./...` green, coverage ≥85%. +5. **Commit 5** — Default validator: `transport/validate_default.go` + tests (§3.2 Seam 3). Gate: tests green. +6. **Commit 6** — Transport integration test: `transport/integration/wrapper_integration_test.go`. + Gate: `go test -tags=integration ./...`. +7. **Commit 7** — Shared render engine: `providers/internal/render` (§3.1.2). Gate: tests green. +8. **Commit 8** — `providers/discord` (webhook validation, content/embeds normalization, dispatch + consolidated onto `transport.Wrapper`). Gate: `go test ./providers/discord/...` ≥85%. +9. **Commit 9** — `providers/slack`. Gate: same pattern. +10. **Commit 10** — `providers/gotify`. Gate: same pattern. +11. **Commit 11** — `providers/pushover`. Gate: same pattern. +12. **Commit 12** — `providers/ntfy`. Gate: same pattern. +13. **Commit 13** — `providers/telegram` (bot-token-in-URL dispatch build, hostname pin, `chat_id` + injection, `text`/`message`-field payload validation per §3.1.2). Gate: `go test + ./providers/telegram/...` ≥85%. +14. **Commit 14** — `providers/webhook` (generic dispatch + `RenderPreview`). Gate: same pattern. +15. **Commit 15** — `providers/email` (`Mailer`/`TemplateRenderer`/`Config`, one neutral default + template). Gate: `go test ./providers/email/...` ≥85%; this is the highest-design-risk commit + (§7) and should get dedicated review attention, not be rubber-stamped alongside the others. +16. **Commit 16** — Release plumbing: `.goreleaser.yaml`, release workflow, `CHANGELOG.md` seed. + Gate: dry-run `goreleaser release --snapshot`. + +Rollback: any commit can be reverted independently since the repo has no existing consumers yet; +worst case the repo simply isn't tagged until it's right. + +### Charon repo — PR "Consume extracted notify module" + +1. **Commit 1** — Dependency + transport adapter: add `go.mod` requirement, write + `notify_client_adapter.go` + tests. No behavior change yet. Gate: `go build ./...`, adapter tests pass. +2. **Commit 2** — Provider + email adapters: `notify_provider_adapter.go`, + `notify_email_adapter.go` (unused by production code paths yet). Gate: `go build ./...`, adapter + tests pass. +3. **Commit 3** — Cutover: Discord. `SendExternal`/`TestProvider` route Discord dispatch through + `providers/discord`. Gate: `notification_service_discord_only_test.go` passes (rewritten per + §3.6 step 8) and explicitly documents the retry/backoff behavior change from consolidating onto + `transport.Wrapper`. **This commit's description and the release changelog entry must call out + the retry-behavior change explicitly** — state it plainly as "Discord notifications now retry on + transient failures" (per §7 risk 1c, resolved) rather than letting it read as "just a refactor"; + it is a user-visible, operator-noticeable improvement, not an implementation detail. +4. **Commit 4** — Cutover: Slack. Gate: relevant slice of `notification_service_test.go` rewritten and green. +5. **Commit 5** — Cutover: Gotify. Gate: same pattern. +6. **Commit 6** — Cutover: Pushover. Gate: same pattern. +7. **Commit 7** — Cutover: Ntfy. Gate: same pattern. +8. **Commit 8** — Cutover: Telegram. `SendExternal`/`TestProvider` route Telegram dispatch through + `providers/telegram` (bot-token-in-URL dispatch build, `chat_id` injection from `p.URL`, + hostname-pin check). Gate: relevant slice of `notification_service_test.go` rewritten and green, + same pattern as the other provider cutovers. +9. **Commit 9** — Cutover: generic Webhook, including replacing `RenderTemplate` call sites in + `CreateProvider`/`UpdateProvider` with `providers/webhook.RenderPreview`. Gate: same pattern, plus + the "detailed" template backward-compat decision (§3.6 step 5) is implemented here, not deferred. +10. **Commit 10** — Cutover: Email. `dispatchEmail`/`TestEmailProvider` route through + `notify_email_adapter.go`; `SubjectPrefix`/`TemplateName` preserve exact current subject/template + behavior. Gate: email-path tests rewritten and green; grep confirms no accidental exposure of the + module's neutral default template in production. +11. **Commit 11** — Cleanup: delete now-dead code — old `sendJSONPayload`/`RenderTemplate`/ + `dispatchEmail`/`sanitizeForEmail`/validation functions, `isPrivateIP`, all of + `backend/internal/notifications/`, the old integration test. Gate: `go build ./...`, no unused + imports/symbols (staticcheck). +12. **Commit 12** — Coverage/lint/docs hardening: re-run `scripts/go-test-coverage.sh`, fix any gate + regression; update `ARCHITECTURE.md`. Gate: full Definition of Done per CLAUDE.md. + +Rollback for the PR as a whole: since this is a pure dependency swap with no schema/API change, a +full revert of the PR is safe at any point before merge; post-merge, `go.mod` can be pinned back to +the pre-extraction commit and the deleted files restored from git history if an unforeseen +regression surfaces — no data migration to unwind. Per-provider commit slicing (3–10) additionally +means a single provider's cutover can be reverted in isolation post-merge without unwinding the +others, which was not possible under the prior single-commit-cutover plan. + +Contingency: if the extraction session discovers a provider's DI seam is insufficient (e.g. SSRF +policy or auth-header handling genuinely can't be expressed through the shared interfaces without +either leaking Charon internals or weakening a provider's safety), stop before that provider's +cutover commit and re-scope that one provider — the per-provider commit slicing means this no longer +blocks the other six providers' cutover from proceeding. + +--- + +## 7. Risks / Open Questions + +1. **Scope of "the engine" — RESOLVED.** The user has confirmed full provider-layer scope: the + Discord/Slack/Gotify/Pushover/Ntfy/webhook payload builders and email dispatch move into the new + module now. This replaces the prior draft's open question. The scope increase introduces the + following new risks (1a–1e), which did not exist under the Phase-1-only plan: + + - **1a. Behavior-parity risk across 6+ providers is much higher than for a single HTTP wrapper.** + Each provider's URL validation, header construction, and auth-injection quirks (Discord's + regex/host-allowlist, Slack's token-substitution, Pushover's hostname-pinned URL build, Gotify's + header vs. Ntfy's bearer-auth header) must be reproduced exactly or the 125 KB of existing + `notification_service_*_test.go` coverage will catch regressions the extraction session must + then triage one provider at a time. Budget real time for this — it is not a mechanical port. + - **1b. Template/payload shape change risk.** Genericizing the built-in `detailed` template + (dropping top-level `HostName`/`HostIP`/`ServiceCount`/`Services` in favor of a nested + `{{toJSON .Data}}`) is a **user-visible breaking change** for any existing custom integration + parsing the old flat JSON keys. §3.6 step 5 requires this to be a conscious decision (Charon + ships a compatibility `CustomTemplate` for existing providers, or accepts the change with a + changelog note) — flag to the user before the cutover commit ships either way. + - **1c. Discord/generic-webhook dispatch consolidation — RESOLVED.** §3.1.2 found that Discord + dispatch today bypasses `HTTPWrapper` entirely (direct `network`/`security` calls, no + retry/backoff). User confirmed: fold Discord onto the shared `transport.Wrapper` along with + every other provider — it gains retry/backoff it lacked before, and all providers share one + dispatch path with no special case. This is a deliberate, user-approved behavior change (not + merely a refactor); document it in the Charon PR description and changelog as "Discord + notifications now retry on transient failures," since it's an observable improvement an + operator could notice. + - **1d. Email is the trickiest single piece.** It currently lives entirely in Charon's + `mail_service.go` (SMTP + 5 branded HTML templates), not in `internal/notifications` at all — + so unlike the other five providers, there's no existing engine code to port, only a new + `Mailer`/`TemplateRenderer` abstraction to design and retrofit around existing Charon code. + Higher design risk than any HTTP provider; §6 gives it a dedicated commit in both PRs and flags + it for extra review attention rather than folding it in alongside the others. + - **1e. Telegram gap — RESOLVED.** Charon supports a 7th provider type (`telegram`) that was + outside the original six-provider list. User confirmed: include it — `providers/telegram` is + added to the module alongside the other six (§3.6 step 6), so the move-list is now seven HTTP + providers + email, not six. + - **1f. Scope-creep guardrail for the Apprise-inspired long-term direction (§8).** The provider + list in §3.1 is deliberately exactly Charon's existing seven HTTP providers + email (six plus + Telegram, per 1e above) — nothing more. The extraction session must resist the temptation to + "just add one more" (Matrix, + PagerDuty, Twilio, etc.) even though the API is designed to make that easy later (§3.3.3, §8). + Adding providers Charon doesn't use today is explicitly out of scope for this extraction and + would need its own separate decision from the user once the project is closer to maintenance + mode. + +2. **Default `URLValidator` duplication** (§3.2 Seam 3): shipping a built-in conservative SSRF + validator in the new module duplicates IP-classification logic already in Charon's + `internal/network`. Bounded and low-risk (public CIDR constants, not business logic), but worth + the user's explicit sign-off since "duplicate SSRF logic" is the kind of thing that should never + happen by accident. +3. **Module name/org — RESOLVED.** The repo already exists at `/projects/go_notify_yourself` + (`github.com/Wikid82/go_notify_yourself`, remote confirmed via `git remote -v`), so this is no + longer an open placeholder question — every reference in this spec now uses that path, with the + Go package name kept as `notify` at the root (module-path/package-name mismatch is intentional, + see the rev-2 note at the top of this document). The one remaining sub-decision is whether the + delivery-primitive subpackage is literally named `transport` as sketched in §3.3.1/§3.5, or + something else — cosmetic, not blocking. +4. **`os.Args[0]` test-detection removal** (§3.7): behavior-preserving in intent, but any change to + how `CHARON_NOTIFY_ALLOW_HTTP` is resolved touches existing test setup across + `notification_service_test.go` and friends (125 KB file) — the extraction session should budget + time to verify every test relying on the old auto-detect still passes under explicit + configuration. +5. **`feature_flags.go` fate**: stays in Charon (§3.1.2), folded into `internal/services` once the + rest of `internal/notifications` is deleted (§3.6 step 2) — a style call, not a functional one. +6. **GoReleaser artifact shape for a pure library**: Charon's existing `.goreleaser.yaml` builds + binaries/Docker images; a library module needs a much lighter GoReleaser config (just changelog + + GitHub release, no build/archive stanzas). This is unaffected by the larger provider-layer + scope — GoReleaser still just tags the whole module regardless of how many packages it contains. + The extraction session should not copy Charon's `.goreleaser.yaml` wholesale — treat it as a + reference for style/conventions only. +7. **CI cost for a single-maintainer module**: recommend the new repo's CI stay to lint + unit test + + coverage on PR/push, with release only on tag push — explicitly no CodeQL/Trivy/multi-browser + E2E apparatus. The coverage surface is now larger (transport + 6 providers + email vs. just the + wrapper), but the CI *policy* is unchanged — flagging so the extraction session doesn't + over-engineer CI to match Charon's much larger surface just because the module itself grew. +8. **Email default-template tradeoff needs explicit sign-off.** §3.3.4 decides to ship one neutral + built-in template and require Charon to override it. An unrelated future adopter who *doesn't* + override it gets a plain, unbranded email — acceptable for a zero-config default, but the + extraction session should confirm this "ship one neutral default, hosts override for anything + branded" position with the user before locking in the `TemplateRenderer` interface shape, since + it's a design opinion, not a mechanical extraction fact. +9. **`notification_service_test.go`/`_json_test.go`/`_discord_only_test.go` (125 KB combined) need + substantial rewriting, not import/construction changes.** The prior Phase-1-only draft's + acceptance criterion — "existing suites pass without modification to their assertions" — no + longer holds now that the bulk of the file's production logic (§3.1.2, ~450 of 1029 lines) is + deleted outright rather than relinked. §5 has been relaxed accordingly: black-box behavior must + remain equivalent (except the two documented deliberate changes in 1b/1c above), but the + assertions themselves are expected to be rewritten against the new adapter seam. + +--- + +## 8. Future Direction (context for whoever picks this up later) + +**Long-term goal, stated by the user:** `go_notify_yourself` should eventually become a Go +equivalent of [Apprise](https://github.com/caronc/apprise) — the Python library that lets a caller +dispatch a single notification across a large, open-ended catalog of services through one common +interface/URL-scheme convention, rather than hand-rolling per-service integration code. + +**Near-term constraint, also stated by the user and binding on this extraction:** Charon and the +user's other small family project are both still under active development, not yet in "maintenance +mode." Scope creep into new provider integrations right now would compete with that active-dev time +for no near-term payoff — there is exactly one consumer (Charon) and it uses exactly seven HTTP +providers + email. §3.1's move-list is intentionally capped at those seven, and §7 risk 1f exists +specifically to stop a future session from "just adding one more" opportunistically during this +extraction. + +**What this spec deliberately does do, to keep the Apprise path open without building it now:** +- The `Sender` interface (§3.3.3) is uniform across every provider — `Send(ctx, Message) error` — + regardless of transport (HTTP POST, SMTP) or payload shape. A future registry only needs one + interface to key off, not per-provider special cases. +- Every provider package is fully self-contained and independently importable, with **no** central + switch-statement inside the module mapping type-strings to packages — that mapping lives in + Charon's own `notify_provider_adapter.go` (§3.6), outside the module. This means the module itself + has zero knowledge of "which providers exist" beyond the packages present in the repo, which is + exactly the property an Apprise-style URL-scheme registry (`discord://...`, `mailto://...`) would + need to slot in as a pure addition later. +- `providers/internal/render`, the shared template engine (§3.1.2), is already factored out as a + reusable internal dependency rather than duplicated per-package — a future provider package (once + the scope constraint is lifted) can reuse it immediately instead of re-solving JSON templating. + +**What this spec deliberately does NOT do, to avoid scope creep now:** +- No `notify.Register`/registry type is built in this extraction (§3.3.3's extensibility note) — + with one consumer and seven known providers, a generic registry today is premature abstraction, not + a real need. +- No URL-scheme parsing/dispatch convention (Apprise's signature feature) is designed or built here + — that's a substantial API-design exercise in its own right and belongs in a dedicated future spec + once the user decides it's time to grow past Charon's provider set. +- No providers beyond Charon's existing seven + email are added, discussed as candidates, or + scaffolded as stubs — see §7 risk 1f. + +A future session picking this up for "add provider N" or "build the Apprise-style registry" should +treat this section as the record of *why* the provider list was small at extraction time and +*which* properties of the API (uniform `Sender`, no in-module type registry, shared internal +template engine) were chosen specifically so that later work would be additive rather than a +breaking rework. + +--- + +## Appendix: File-level move list (flat reference) + +**Move to new repo (whole files):** +- `backend/internal/notifications/http_wrapper.go` +- `backend/internal/notifications/http_wrapper_test.go` +- `backend/internal/notifications/http_client_executor.go` +- `backend/integration/notification_http_wrapper_integration_test.go` + +**Move to new repo (function-level extraction out of `notification_service.go`, genericized — see +§3.1.2 for exact line ranges and destination packages; the file itself does not move, it shrinks):** +- Built-in `minimal`/`detailed` JSON templates → `providers/webhook` +- Template parse/exec engine (`text/template` + `toJSON` funcmap, size/timeout limits) → `providers/internal/render` +- Discord webhook regex/host validation/normalization → `providers/discord` +- Slack webhook regex/validation/token substitution → `providers/slack` +- Gotify message-field validation + auth header → `providers/gotify` +- Pushover message/priority validation + URL build + token/user injection → `providers/pushover` +- Ntfy message-field validation + bearer auth header → `providers/ntfy` +- Telegram text/message-field validation + bot-token-in-URL dispatch build + `chat_id` injection + hostname pin → `providers/telegram` +- Generic/custom webhook dispatch → `providers/webhook` +- `RenderTemplate` → `providers/webhook.RenderPreview` +- `sanitizeForEmail` + `dispatchEmail`'s message composition → `providers/email` + +**Delete (dead code, do not port as-is):** +- `backend/internal/notifications/engine.go` +- `backend/internal/notifications/router.go` +- `backend/internal/notifications/router_test.go` +- `notification_service.go`'s unused `isPrivateIP(ip net.IP) bool` wrapper (§3.1.3) +- `notification_service.go`'s `webhookDoRequestFunc` test hook (superseded by the module's own test seam) + +**Stays in Charon, unmodified:** +- `backend/internal/models/notification.go` +- `backend/internal/models/notification_config.go` +- `backend/internal/models/notification_provider.go` +- `backend/internal/models/notification_provider_test.go` +- `backend/internal/models/notification_template.go` +- `backend/internal/models/notification_test.go` +- `backend/internal/services/security_notification_service.go` + test +- `backend/internal/services/enhanced_security_notification_service.go` + tests +- `backend/internal/services/uptime_service_notification_test.go` +- `backend/internal/services/mail_service.go`'s SMTP transport + `templates/*.html` (behind the new `Mailer`/`TemplateRenderer` seam, §3.3.4) +- `docs/features/notifications.md` +- `frontend/src/api/notifications.ts` + tests +- `frontend/src/pages/Notifications.tsx` + tests +- `frontend/src/hooks/useNotifications.ts` + tests +- `frontend/src/components/NotificationCenter.tsx` + tests +- `frontend/src/components/SecurityNotificationSettingsModal.tsx` + tests + +**Stays in Charon, modified (Charon migration phase, §3.6):** +- `backend/internal/notifications/feature_flags.go` (relocate into `internal/services`, don't extract) +- `backend/internal/services/notification_service.go` (shrinks substantially — §3.1.3 keeps CRUD/flag-gating/event-routing; §3.1.2's logic is deleted, replaced by thin calls into three new adapter files) +- `ARCHITECTURE.md` (documentation update) diff --git a/docs/plans/notify_provider_registry_spec.md b/docs/plans/notify_provider_registry_spec.md new file mode 100644 index 000000000..615dec447 --- /dev/null +++ b/docs/plans/notify_provider_registry_spec.md @@ -0,0 +1,989 @@ +# Notify Provider Registry — Self-Registering Factory Pattern + +Status: Scoping/design only. No code changes performed under this spec. This document specifies +the design for a future implementation session, to land as additional commits on the existing, +open branch `feature/notifications-engine-extraction` (PR #1253), across two repositories. + +Owner for this document: **planning** agent. +Owner for execution (future session): a `management`-orchestrated pipeline for the Charon-side +commits; direct TDD implementation in `/projects/go_notify_yourself` per that repo's own +`CLAUDE.md` (which explicitly says not to build out a multi-agent roster there) for the +module-side commits. + +--- + +## 1. Introduction + +### 1.1 Objective + +`go_notify_yourself` (`github.com/Wikid82/go_notify_yourself`, currently tagged `v0.1.0`) already +ships seven HTTP notification providers plus email, each a self-contained package exposing a typed +`Config` struct and a `New(cfg, wrapper) *Client` constructor implementing the shared +`notify.Sender` interface (confirmed by reading `sender.go`, `message.go`, and the `discord`, +`webhook`, and `email` packages in full). There is deliberately **no** registry today — §8 of the +original extraction spec (`docs/plans/notifications_extraction_spec.md`) called this out as a +conscious "not yet" decision, made when there was exactly one consumer (Charon) and seven known +providers. + +That deferred need is now live: the user wants to add a Web Push provider to +`go_notify_yourself` for another project, and wants any future provider — theirs or a third +party's — to become available to a host application (Charon or otherwise) automatically after a +version bump, without hand-editing host application code. Confirmed by reading +`backend/internal/services/notify_provider_adapter.go` (already merged on this branch, commit +`11d3489c`) and `notification_service.go`: Charon currently hardcodes a `switch p.Type { case +"discord": ... }` in `buildNotifySender`, plus three more provider-type allowlists +(`isSupportedNotificationProviderType`, `supportsJSONTemplates`, `isDispatchEnabled`). Adding a +provider today requires a code change in **both** repos. + +### 1.2 Goals + +- Design a self-registering factory pattern for `go_notify_yourself` — the `database/sql` / + `image` idiom — fitted to what's actually in the repo today (typed per-provider `Config` structs, + email's non-serializable `Mailer`/`TemplateRenderer` dependencies), not an idealized redesign. +- Specify the exact `Register`/`New` signatures, resolving the config-typing question (generic map + vs. `json.RawMessage` vs. something else) with a concrete recommendation and rationale. +- Specify a `providers/all` blank-import bundle package as the closest idiomatic equivalent to true + auto-discovery Go can offer. +- Specify how Charon's adapter layer collapses onto the registry, and flag (not silently resolve) + the allowlist-vs-full-discovery design tension this creates for Charon's own UI/API surface. +- Specify three documentation deliverables the user explicitly asked for: + `go_notify_yourself/ARCHITECTURE.md`, a new README section, and + `go_notify_yourself/docs/INTEGRATION.md`. +- Sequence all of this as additional commits on the existing `feature/notifications-engine-extraction` + branch/PR — not a new branch, not a new PR. + +### 1.3 Non-goals + +- No URL-scheme parsing/dispatch convention (Apprise's signature feature, e.g. `discord://...`) — + still explicitly out of scope, unchanged from the original spec's §8. +- No new provider packages are added or scaffolded under this spec (no Web Push implementation) — + the registry is the enabling mechanism; the user's Web Push provider is a separate future piece + of work that becomes trivial once this lands. +- No frontend schema-driven form generator is designed here — flagged as an open question (§3.6), + not solved. +- No changes to `docs/plans/current_spec.md` (unrelated active work, per the task constraint). +- No `go.mod` edits, no branch creation, no code written — this is planning only. + +--- + +## 2. Research Findings + +### 2.1 Current state of `go_notify_yourself` (read in full, not assumed) + +| File | Finding | +|---|---| +| `sender.go` | `Sender` interface: `Send(ctx context.Context, msg Message) error`. Every provider package returns a type implementing this. No registry, no factory type exists anywhere in the repo. | +| `message.go` | `Message{Title, Body, EventType, Timestamp, Data map[string]any}` + `Normalized()`. Stable, generic, provider-agnostic — no changes needed for the registry. | +| `providers/discord/discord.go` | `Config{WebhookURL, Template, CustomTemplate}`; `New(cfg Config, w *transport.Wrapper) *Client`. All fields are plain strings — trivially JSON-serializable. | +| `providers/webhook/webhook.go` | Same shape: `Config{URL, Template, CustomTemplate}`, `New(cfg, w)`. Also exposes `RenderPreview` and re-exports `MinimalTemplate`/`DetailedTemplate` consts. | +| `providers/slack`, `gotify`, `pushover`, `ntfy`, `telegram` | Same `New(cfg, w *transport.Wrapper) *Client` shape (confirmed by reading all five `Config` structs — see table in §3.1). All fields are plain strings (`URL`, `Token`, `UserKey`, `APIToken`, `BotToken`, `ChatID`, `BaseURL`, `Template`, `CustomTemplate`). **All eight non-email packages are pure-data, JSON-serializable configs.** | +| `providers/email/email.go` | **The odd one out, confirmed by reading it in full.** `Config{Recipients []string, SubjectPrefix string, TemplateName func(msg notify.Message) string, Renderer TemplateRenderer, Mailer Mailer}`. `Mailer` and `TemplateRenderer` are **behavioral interfaces** the host application implements (e.g. Charon's `notify_email_adapter.go` wraps `MailServiceInterface`/`RenderNotificationEmail`); `TemplateName` is a **Go closure**. None of these three fields can round-trip through JSON. `New(cfg Config) *Client` — no `*transport.Wrapper` parameter at all (email never dials HTTP). This asymmetry is the central design constraint for the registry (see §3.2). | +| `transport/wrapper.go` | `*transport.Wrapper` is constructed once per host application (`transport.NewWrapper(opts...)`) and injected into every HTTP-based provider's `New`. It is itself DI-configured (`ClientFactory`, `URLValidator`, `RetryPolicy`) — the registry must not bypass or duplicate that construction, only thread the already-built `*Wrapper` through. | +| Module root | `package notify` at `github.com/Wikid82/go_notify_yourself` (no subpackage) — `Register`/`New` naturally belong here, alongside `Message`/`Sender`, per Go convention (mirrors `sql.Register`/`sql.Open` living in `database/sql` itself, not a subpackage). | + +### 2.2 Prior art: `database/sql` and `image` self-registration idiom + +- `database/sql`: `sql.Register(name string, driver driver.Driver)` — panics on duplicate + registration or nil driver, called from each driver package's `init()`. `sql.Open(driverName, + dataSourceName string)` looks up the registered driver and returns a `*DB`. The `dataSourceName` + is an opaque string each driver parses itself (e.g. a DSN) — `database/sql` has zero opinion on + its shape. This is the closest fit: **the registry core has no opinion on config shape**, it's a + keyed lookup over a factory function; shape-parsing is entirely the registered package's problem. +- `image`: `image.RegisterFormat(name, magic string, decode DecodeFunc, decodeConfig DecodeConfigFunc)` + — decoders self-register via blank import (`_ "image/png"`), and `image.Decode` sniffs the magic + bytes to pick a decoder. No generic "config" concept at all — irrelevant to the config-typing + question here, but reinforces the blank-import-for-discovery pattern (§3.3). +- Both prior-art examples confirm two things this spec adopts: (1) `Register` panics on + double-registration (a programmer error caught at `init()` time, not a runtime error path), and + (2) the registry package itself never imports the packages that register into it — the + dependency arrow points inward (provider → registry), never outward, which is exactly what keeps + `providers/all` (§3.3) as the only place that "knows about" every provider. + +### 2.3 Charon-side current coupling (re-confirmed on this branch, post-extraction) + +`backend/internal/services/notify_provider_adapter.go` (already on this branch, commit `11d3489c`) +has `buildNotifySender`, a `switch strings.ToLower(...) provider.Type { case "discord": ... +discord.New(discord.Config{WebhookURL: provider.URL, ...}, w) ... }` — one case per provider type, +each mapping specific GORM columns to that provider's specific `Config` field names (documented +in-file, non-uniformly: `discord.WebhookURL <- provider.URL`, but `slack.WebhookURL <- +provider.Token`; `pushover.UserKey <- provider.URL`, `pushover.APIToken <- provider.Token`; +`telegram.BotToken <- provider.Token`, `telegram.ChatID <- provider.URL`). **This field-mapping +non-uniformity is itself a research finding that constrains the registry design** — see §3.4. + +`backend/internal/services/notification_service.go` (lines 126–166, read directly, not +paraphrased): + +```go +func supportsJSONTemplates(providerType string) bool { + switch strings.ToLower(providerType) { + case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy": + return true + default: + return false + } +} + +func isSupportedNotificationProviderType(providerType string) bool { + switch strings.ToLower(strings.TrimSpace(providerType)) { + case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy": + return true + default: + return false + } +} + +func (s *NotificationService) isDispatchEnabled(providerType string) bool { + switch strings.ToLower(strings.TrimSpace(providerType)) { + case "discord": + return true + case "email": + return s.getFeatureFlagValue(FlagEmailServiceEnabled, false) + case "gotify": + return s.getFeatureFlagValue(FlagGotifyServiceEnabled, true) + // ... one flag-gated case per provider type ... + default: + return false + } +} +``` + +Three independent hardcoded allowlists, one hardcoded factory switch. All four must be reconciled +with any registry that makes providers "discoverable" — see §3.5 for why they should **not** all +collapse onto the registry automatically. + +### 2.4 GORM model — `backend/internal/models/notification_provider.go` (read in full) + +```go +type NotificationProvider struct { + ID string + Name string + Type string // provider type discriminator + URL string // reused across types: webhook URL, server URL, user key, chat ID... + Token string `json:"-"` // reused across types: API token, webhook token, bot token... + Config string // JSON payload template for custom webhooks + ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config + Template string `gorm:"default:minimal"` + // ... Notify* preference bools, migration/audit fields ... +} +``` + +**Finding, confirmed by repo-wide grep**: `ServiceConfig` is declared but has **zero read or write +call sites anywhere in `backend/internal/`** outside its own struct tag. It is dead/reserved +schema — a column that exists but nothing populates or reads it yet. This is directly relevant: +the model already has a `text` column earmarked (by its doc comment, "JSON blob for typed service +config") for exactly the kind of flexible, not-known-in-advance provider config a new provider type +(Web Push) would need, without a migration. See §3.6 for the recommendation. + +The existing `URL`/`Token` pair is a **fixed two-slot** scheme — every provider type today has been +squeezed into "one URL-ish string, one secret-ish string," which is why the field-mapping table in +§2.3 is non-uniform (each type has its own private convention for what `URL` vs `Token` *means*). +A provider needing more than two config values (e.g. Web Push's VAPID public/private keypair + +subscription endpoint — three values, none of which is a natural fit for "URL" or "Token") cannot +be expressed in the current two-slot scheme at all. `ServiceConfig` is the natural place for this, +but is not wired up. + +### 2.5 Frontend (confirmed by file search, not deep-read — out of scope per task framing) + +`frontend/src/pages/Notifications.tsx` (758 LOC, per the original extraction spec's §2.8) is the +only frontend file referencing `NotificationProvider`/provider type. No separate +`NotificationProviderForm.tsx` component exists — the per-type config fields are rendered inline in +this one page, keyed off `provider.type` (pattern confirmed by grep; the file was not read in full +under this task's scope, per the instruction to flag rather than solve the frontend question). +**Finding**: today, adding a provider type to the UI requires editing this file directly, the same +way adding one to the backend requires editing `notify_provider_adapter.go` — there is no +schema-driven form rendering today. See §3.6 for how this is flagged as an open question. + +--- + +## 3. Technical Specifications + +### 3.1 Provider `Config` field inventory (all eight packages, read directly) + +| Package | `Config` fields | Serializable? | +|---|---|---| +| `discord` | `WebhookURL, Template, CustomTemplate` | Yes — all strings | +| `slack` | `WebhookURL, Template, CustomTemplate` | Yes — all strings | +| `gotify` | `URL, Token, Template, CustomTemplate` | Yes — all strings | +| `pushover` | `UserKey, APIToken, BaseURL, Template, CustomTemplate` | Yes — all strings | +| `ntfy` | `URL, Token, Template, CustomTemplate` | Yes — all strings | +| `telegram` | `BotToken, ChatID, BaseURL, Template, CustomTemplate` | Yes — all strings | +| `webhook` | `URL, Template, CustomTemplate` | Yes — all strings | +| `email` | `Recipients []string, SubjectPrefix string, TemplateName func(Message) string, Renderer TemplateRenderer, Mailer Mailer` | **No** — `TemplateName`/`Renderer`/`Mailer` are Go closures/interfaces, not data | + +This table is the deciding evidence for §3.2's config-typing recommendation: seven of eight +packages have a pure-data `Config`; email's does not, and never can while it keeps the +DI-seam design principle (module never dials SMTP/renders HTML itself) that the original +extraction spec deliberately chose. + +### 3.2 Config-typing decision: `map[string]any`, not `json.RawMessage` + +**Recommendation: the registry boundary uses `map[string]any`, not `json.RawMessage`/typed +generics.** + +Rationale, directly from §3.1's evidence: + +- `json.RawMessage` (or any JSON-shaped boundary) works cleanly for the seven HTTP providers — each + factory would `json.Unmarshal(raw, &Config{})`. It **cannot** work for `email.Config` at all: + `Mailer`, `TemplateRenderer`, and `TemplateName` are behavioral Go values a host application + constructs at startup (e.g. Charon's `notify_email_adapter.go` wrapping `MailServiceInterface`), + not data that arrives over a wire. There is no JSON representation of "call this Go function." + Forcing email through a JSON boundary would mean either (a) breaking email out of the registry + entirely as a special case — undermining the "one path for every provider" goal that motivated + this work — or (b) inventing a side-channel to inject non-JSON deps alongside the JSON blob, + which is just `map[string]any` with extra steps. +- `map[string]any` handles both cases uniformly: for the seven HTTP providers, callers put plain + Go strings under well-known keys; for email, the caller puts the actual `Mailer`/`TemplateRenderer` + values and `TemplateName` closure directly into the map under their own well-known keys. Each + factory type-asserts what it expects and returns a config error (not a panic) on a + missing/wrong-typed key. +- **Tradeoff, stated plainly**: this loses compile-time type safety at the registry boundary — a + caller can put a `string` under a key a factory expects to be `*transport.Wrapper` and won't + find out until `New()` returns an error at runtime. This is an accepted, explicit cost. The typed + per-provider `Config` structs (`discord.Config`, `email.Config`, etc.) **remain the primary, + fully type-safe public API** — a host application that wants compile-time safety and doesn't need + runtime discovery calls `discord.New(discord.Config{...}, wrapper)` directly, exactly as it does + today. The registry is an **additive convenience/discovery layer**, not a replacement for the + typed constructors — this must be explicit in the module's docs (§3.7) so users don't think + `notify.New` is the only or preferred way to construct a `Sender`. +- A pure `json.RawMessage`-only design was considered and rejected specifically because it would + make email a permanent second-class citizen of the registry (excluded, or requiring an awkward + parallel non-JSON registration path) — inconsistent with the goal of one uniform discovery + mechanism across all provider types, present and future (Web Push, unlike email, is + HTTP-transport-based like the other seven, but the registry design must not special-case around + today's provider mix). + +### 3.3 `Register`/`New` API (module root, `package notify`) + +```go +// factory.go (new file, module root) + +package notify + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// Factory constructs a Sender from a generic configuration map. Each +// provider package's factory type-asserts the keys/types it expects out of +// config and returns a descriptive error for anything missing or +// wrong-typed — Factory implementations must never panic on bad input from +// a caller (panicking is reserved for Register's own misuse-by-programmer +// checks, per the database/sql convention — see below). +// +// Well-known convention (documented per-package in each provider's doc +// comment and in ARCHITECTURE.md, §3.7): HTTP-based providers expect a +// "transport" key holding the shared *transport.Wrapper; provider-specific +// Config fields are expected under their lowercase snake_case field name +// (e.g. discord's WebhookURL -> config["webhook_url"]). This module makes +// no attempt to enforce these conventions structurally — see the open +// question in §5 risk 2 on whether a stricter typed-key mechanism is worth +// the added complexity. +type Factory func(config map[string]any) (Sender, error) + +var ( + registryMu sync.RWMutex + registry = map[string]Factory{} +) + +// Register makes a provider Factory available under name (case-insensitive; +// stored lowercased). Intended to be called from a provider package's +// init(), mirroring database/sql.Register and image.RegisterFormat. +// +// Register panics if factory is nil or if name is already registered — +// exactly like sql.Register — because a duplicate/nil registration is +// always a programmer error discoverable at package-init time (e.g. two +// packages both claiming "webhook"), never a legitimate runtime condition +// a caller should have to handle. +func Register(name string, factory Factory) { + if factory == nil { + panic("notify: Register called with nil Factory for " + name) + } + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + panic("notify: Register called with empty name") + } + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[key]; exists { + panic("notify: Register called twice for provider " + key) + } + registry[key] = factory +} + +// New looks up the Factory registered under name (case-insensitive) and +// invokes it with config. Returns an error — never panics — if name is not +// registered or if the factory itself returns an error (e.g. a missing +// required config key). +func New(name string, config map[string]any) (Sender, error) { + key := strings.ToLower(strings.TrimSpace(name)) + registryMu.RLock() + factory, ok := registry[key] + registryMu.RUnlock() + if !ok { + return nil, fmt.Errorf("notify: no provider registered for type %q (registered types: %s)", + name, strings.Join(RegisteredTypes(), ", ")) + } + return factory(config) +} + +// RegisteredTypes returns the sorted list of currently registered provider +// type names. Useful for a host application that wants to validate a +// config value or populate a UI dropdown against exactly what's compiled +// in, without hardcoding its own list (see §3.5's discussion of Charon's +// allowlist-vs-discovery tradeoff). +func RegisteredTypes() []string { + registryMu.RLock() + defer registryMu.RUnlock() + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} +``` + +**Design notes:** + +- `sync.RWMutex`-guarded map: registrations happen at `init()` time (effectively single-threaded, + before `main` runs), but `New`/`RegisteredTypes` may be called concurrently from request-handling + goroutines in a host application (Charon's HTTP handlers), so read-locking those paths is cheap + insurance, not overengineering. +- `Register` panicking on misuse mirrors `database/sql` exactly and is the right call here for the + same reason: a duplicate provider name is a build-time-discoverable defect (two packages both + registering `"webhook"`), and panicking during `init()` fails the program immediately and loudly + rather than silently shadowing one provider with another. +- `New` returning an error (never panicking) for an *unregistered* name is the opposite case + deliberately: "provider type X isn't registered" is a **runtime** condition (a host forgot to + blank-import the package, or a config file references a typo'd/future type) that calling code + must be able to handle gracefully — e.g. Charon surfacing "unsupported provider type" back to its + API caller instead of crashing the process. +- Placed at the module root (`factory.go`, `package notify`) alongside `message.go`/`sender.go` + rather than a new subpackage — avoids an import-cycle problem symmetric to the one in §3.2: if + `Register`/`New` lived in a subpackage that provider packages needed to import, and the root + `notify` package needed to reference that subpackage's types, the two would have to share + identifiers anyway. Keeping the registry in the root package (which every provider package + already imports, per `discord.go`'s `notify "github.com/Wikid82/go_notify_yourself"` import) is + the only placement with zero new import edges. + +### 3.4 Provider package migration — each package's `init()` + +Every one of the eight existing packages adds a small registration file/block. Two representative +examples (HTTP-based and email), the remaining six follow the same shape as `discord`: + +```go +// providers/discord/register.go (new file) +package discord + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func init() { + notify.Register("discord", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`discord: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + Template: stringField(config, "template"), + CustomTemplate: stringField(config, "custom_template"), + WebhookURL: stringField(config, "webhook_url"), + } + return New(cfg, w), nil + }) +} +``` + +```go +// providers/email/register.go (new file) +package email + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" +) + +func init() { + notify.Register("email", func(config map[string]any) (notify.Sender, error) { + mailer, ok := config["mailer"].(Mailer) + if !ok || mailer == nil { + return nil, fmt.Errorf(`email: config["mailer"] must be a non-nil Mailer`) + } + cfg := Config{ + Mailer: mailer, + SubjectPrefix: stringField(config, "subject_prefix"), + Recipients: stringSliceField(config, "recipients"), + } + if r, ok := config["renderer"].(TemplateRenderer); ok { + cfg.Renderer = r + } + if tn, ok := config["template_name"].(func(notify.Message) string); ok { + cfg.TemplateName = tn + } + return New(cfg), nil + }) +} +``` + +(`stringField`/`stringSliceField` are small unexported helpers in a shared internal location, +e.g. `providers/internal/regconfig`, to avoid duplicating the same type-assert-with-default +boilerplate across eight `register.go` files — DRY per this repo's own conventions.) + +**What breaks / changes in the public API as a result — this is a breaking change:** + +- No existing exported identifier changes signature (`Config`, `New(cfg, w)`, `Send` are untouched) + — this is purely additive at the Go-API level. +- **However**, adding `init()`-time registration means: (a) importing any provider package now has + a side effect (registering into the global `notify` registry) it didn't have before — a host + application that imports `providers/discord` for its types but never calls `notify.New` will + still pay the (tiny) registration cost and take a global-map write at startup. This is normal for + the self-registration pattern (identical to every `database/sql` driver package) but is a + behavior change worth calling out in the changelog, not silently shipping. (b) Two packages + registering the same name in the same binary now **panics at init time** — not a concern for the + eight packages in this repo (names are fixed and distinct), but is a new failure mode a future + third-party provider package could trigger if it collided with a built-in name; document this in + `ARCHITECTURE.md` (§3.7) as a naming-collision hazard to design around. + - Since this changes both the module's behavior (global registration side effect) and shape + (new root-package `Register`/`New`/`RegisteredTypes` exported API, new `map[string]any` + convention every provider must honor), this ships as **`v0.2.0`**, not a patch release — + consistent with semver: no existing exported signature breaks, but new, previously-absent + runtime behavior (init-time global mutation, panic-on-collision) is a real enough shift to + warrant a minor bump at minimum. (The module is pre-1.0 per its `v0.1.0` tag, so strictly + semver would even permit this as a `v0.1.x`-breaking change without a major bump — recommend + `v0.2.0` as the clearer, more conventional signal to the one current consumer.) + +### 3.5 `providers/all` bundle package + +```go +// providers/all/all.go (new file) + +// Package all blank-imports every provider package shipped in this module, +// registering all of them into the root notify package's registry as a +// side effect. Import this package for its side effects only — +// +// import _ "github.com/Wikid82/go_notify_yourself/providers/all" +// +// — when you want every built-in provider type available to notify.New +// without importing each provider package individually. This is the +// closest equivalent Go offers to true runtime auto-discovery: Go has no +// mechanism to discover and load packages that were not compiled into the +// binary, so *some* single import is unavoidable — providers/all exists so +// that import is exactly one line, added once, rather than one line per +// provider that must be kept in sync by hand as the provider list grows. +package all + +import ( + _ "github.com/Wikid82/go_notify_yourself/providers/discord" + _ "github.com/Wikid82/go_notify_yourself/providers/email" + _ "github.com/Wikid82/go_notify_yourself/providers/gotify" + _ "github.com/Wikid82/go_notify_yourself/providers/ntfy" + _ "github.com/Wikid82/go_notify_yourself/providers/pushover" + _ "github.com/Wikid82/go_notify_yourself/providers/slack" + _ "github.com/Wikid82/go_notify_yourself/providers/telegram" + _ "github.com/Wikid82/go_notify_yourself/providers/webhook" +) +``` + +**Tradeoff, stated explicitly (per the task's ask):** importing `providers/all` means a host +binary always links every provider package the module ships, even ones the host never configures +or wants to expose (e.g. Charon linking a future `providers/webpush` it has no UI for yet) — larger +binary, and every provider's transitive dependencies come along. The alternative — hand-picking +individual `import _ "…/providers/discord"` lines — keeps binary size and blast radius under the +host's control but means a host must edit its own import list every time it wants a newly-added +provider, which is exactly the manual step the user is trying to eliminate for *their* project (not +necessarily for Charon — see §3.6's recommendation, which treats these as two different answers for +two different consumers). `providers/all` is offered as **one available choice**, not a mandate — +`go_notify_yourself` ships it for consumers who want zero-touch discovery; a consumer that wants +tighter control simply doesn't import it and hand-picks instead. Both remain equally supported by +the registry design; neither requires a different `Register`/`New` API. + +### 3.6 Charon-side changes + +**3.6.1 `notify_provider_adapter.go`'s switch collapses to a `notify.New` call.** + +`buildNotifySender` (§2.3) becomes, in shape: + +```go +func buildNotifySender(provider models.NotificationProvider, w *transport.Wrapper, mailer email.Mailer) (notify.Sender, error) { + tmpl, customTemplate := resolveTemplateFields(provider) + config := providerConfigMap(provider, w, mailer, tmpl, customTemplate) // per-type field mapping — see below + return notify.New(provider.Type, config) +} +``` + +**Important finding, not glossed over**: collapsing the *constructor dispatch* onto `notify.New` +does **not** eliminate the per-type field-mapping logic documented in §2.3 — Charon's GORM schema +reuses `URL`/`Token` with different *meanings* per provider type (discord's `URL` is a webhook URL; +pushover's `URL` is a user key; telegram's `URL` is a chat ID). Something in Charon must still +decide "for type X, config key `webhook_url` <- `provider.URL`; for type Y, config key `user_key` +<- `provider.URL`." The registry removes the "which Go constructor do I call" branch; it does +**not** remove the "which GORM column means what for this type" branch, because that mapping is a +Charon persistence-schema fact, not something `go_notify_yourself` can know. `providerConfigMap` +would likely still contain a `switch provider.Type` internally — smaller in scope (pure data +mapping, no `discord.New(...)`/`slack.New(...)` calls, no per-package imports needed in this file +at all once every field name is passed as a map key) but not eliminated outright. This should be +stated plainly to the user rather than oversold as "the switch statement disappears entirely" — it +shrinks and stops needing to import every provider package, but a mapping table remains until/unless +§2.4's `ServiceConfig` idea (below) is adopted for new types. + +**3.6.2 The three provider-type allowlists — open design question, not silently resolved.** + +`isSupportedNotificationProviderType`, `supportsJSONTemplates`, and `isDispatchEnabled` currently +hardcode Charon's own opinion about which provider types it exposes in its UI/API — independent of +what the module happens to support. Two options: + +- **Option A — keep Charon's allowlist hardcoded (recommended).** Charon's REST API/UI continues to + explicitly enumerate the provider types it supports, same as today. If Charon later imports + `providers/all` (or a future `providers/webpush`), that provider becomes *constructible* via + `notify.New` but Charon's API still rejects it at `isSupportedNotificationProviderType` until a + human deliberately adds it to the allowlist (and, typically, builds UI for it). **Rationale**: a + host application importing a module for its providers is not the same decision as committing to + support that provider in its own product surface — Charon may want `providers/all` linked for + convenience (§3.5) while still curating what it exposes to users, exactly the same reasoning the + original extraction spec used for feature-flag gating (`isDispatchEnabled`, which is Charon + policy, not engine behavior, and stays Charon's regardless of this change). +- **Option B — query the registry directly (`notify.RegisteredTypes()`)** for full auto-discovery: + Charon's allowlist becomes computed, not hardcoded — any provider the compiled binary happens to + have linked (via whatever `providers/*` imports exist) is automatically exposed through Charon's + API. **Rationale for considering it**: this is the literal "add a provider, it just works, + automatically, after a version bump" behavior the user described wanting. **Rationale against**: + it removes Charon's ability to link a provider package (for binary-size/testing/future-readiness + reasons) without also immediately exposing it to end users — e.g. Web Push landing in + `go_notify_yourself` before Charon has built any UI for it would suddenly appear as a "supported" + type in Charon's API with no way to configure it meaningfully from the UI, a broken half-feature + exposed by version bump alone. + +**Recommendation: Option A**, with `notify.RegisteredTypes()` used only as an internal +*consistency check* (e.g. a startup assertion or unit test asserting Charon's hardcoded allowlist +is a subset of `notify.RegisteredTypes()`, catching the case where Charon claims to support a type +the linked module build doesn't actually have registered) — not as the live source of truth for +what the API accepts. **This is exactly the kind of decision the task called out as needing the +user's confirmation before implementation** — flagged here, not silently picked. + +**3.6.3 `providers/all` vs. hand-picked imports in Charon — recommendation.** + +Recommend Charon **hand-picks** individual provider imports +(`import _ "github.com/Wikid82/go_notify_yourself/providers/discord"`, one per supported type, in +`notify_provider_adapter.go` or a dedicated `notify_providers_import.go`) rather than importing +`providers/all`. This is the direct consequence of the Option A recommendation in §3.6.2: if +Charon's allowlist is the actual gate on what's exposed (not the registry), then importing +`providers/all` only adds binary size and a larger transitive dependency surface for providers +Charon's allowlist will reject anyway — there's no discovery benefit to importing more than +Charon's own allowlist currently names. `providers/all` remains the right choice for a *different* +kind of consumer — one that wants Option-B-style full auto-discovery — which is exactly the +scenario the user described for their *other* project, not necessarily for Charon. + +**3.6.4 GORM model — `ServiceConfig` is the identified extension point, not yet wired up.** + +Confirmed by re-reading `notification_provider.go` (§2.4): the two-slot `URL`/`Token` scheme is +sufficient for all eight of today's provider types (each needs at most two secrets/identifiers, +already squeezed in with per-type reinterpretation), but is **not** sufficient for a +not-yet-known-in-advance provider needing three or more distinct config values with no natural +"URL" or "Token" framing (Web Push's VAPID keypair + endpoint being the concrete example driving +this whole request). No schema change is strictly required to land the registry itself (today's +eight providers keep working unchanged), but **a future provider needing >2 config values will +need `ServiceConfig` wired up** — recommend, as a follow-up (not blocking this registry work): treat +`ServiceConfig` as a JSON-encoded `map[string]string` (or `map[string]any`) column, decoded and +merged into the `config` map passed to `notify.New` alongside the existing `URL`/`Token`-derived +keys, giving future provider types an escape hatch without another migration. Flagged as a +recommendation for a **later** spec/commit, not built now — out of scope for "make existing +providers discoverable," in scope for "the next provider that doesn't fit two slots." + +**3.6.5 Frontend — flagged, not solved.** + +`Notifications.tsx` renders per-type config fields inline, keyed off `provider.type` (§2.5) — the +same "hardcoded per type" pattern this spec removes from the Go backend. Making the *frontend* form +genuinely schema-driven (e.g. deriving which fields to render from something the backend exposes, +rather than a hardcoded TSX conditional) is a materially different, larger piece of design work +(a form-schema wire format, versioning of that schema, backward compat for already-saved configs) +that this spec does **not** attempt to solve. Flagging per the task's explicit instruction: the +user asked specifically about the engine being "drop-in ready" at the Go level; whether that +implies auto-generated UI is a separate question for the user to weigh in on before any frontend +work is planned. + +### 3.7 Documentation deliverables + +**3.7.1 `/projects/go_notify_yourself/ARCHITECTURE.md` (new file) — required sections** + +Must be precise enough for a human contributor *or a coding agent* to add a provider without +guessing. Required sections, in order: + +1. **Module overview** — one paragraph: what this module is, the four-layer shape (`Message`/ + `Sender` at root, `transport` for SSRF-safe HTTP, `providers/*` per-service implementations, + the registry tying them together), link to README for the quick-start. +2. **The `Sender` contract** — the interface, its one method, the behavioral expectations already + documented in `sender.go`'s doc comment (respect `ctx`, wrap errors, never panic) restated here + with a "why" (uniform treatment by host applications fanning a message out to N destinations). +3. **Adding a new provider — step-by-step**, the core of the document: + - File/package layout convention: `providers//.go` (main `Config`/`New`/`Send`), + `providers//_test.go`, optionally `providers//register.go` for the `init()` + registration (kept separate from the main file — mirrors this spec's §3.4 examples — so the + core type/constructor logic isn't cluttered by registry plumbing). + - The `Config` struct convention: exported struct, `Template`/`CustomTemplate` fields present + *only* if the provider is HTTP/JSON-payload-based (email is the documented exception — + explain why, pointing at §3.1/§3.2's reasoning). + - The `New(cfg Config, w *transport.Wrapper) *Client` signature convention for HTTP-based + providers (email's `New(cfg Config) *Client` documented as the one exception, with the reason + — no HTTP transport). + - `var _ notify.Sender = (*Client)(nil)` compile-time interface assertion — required in every + provider package, per the existing pattern (confirmed present in `discord.go`, `email.go`). + - **The `Register`/factory pattern**: exact template for `register.go`'s `init()`, the + `map[string]any` key-naming convention (lowercase snake_case of the `Config` field name; the + `"transport"` key reserved for `*transport.Wrapper` on HTTP-based providers), and the + requirement that factories return errors (never panic) for bad/missing keys. + - **Adding to `providers/all`**: the one-line blank-import addition required in + `providers/all/all.go`, and why this step is easy to forget (it's not enforced by the + compiler — a new provider that registers itself but isn't added to `providers/all` still + works for direct `import _ "…/providers/newone"` consumers but silently isn't part of the + "one import gets everything" bundle). Recommend a CI check (e.g. a small test in + `providers/all` asserting `len(notify.RegisteredTypes()) == ` with a comment requiring the + constant be bumped alongside any new provider, catching an accidental omission) — specify this + as a required addition, not just a suggestion, since it's the one step with no compiler + safety net. + - **Naming conventions**: provider package names are lowercase, no underscores, matching the + `Register` key exactly (e.g. package `webpush`, `Register("webpush", ...)`) — collisions + panic at `init()` per §3.3, so this section should state that explicitly as the reason naming + matters. + - **Test expectations**, mirroring the existing eight providers' patterns (to be confirmed + against actual test files in `providers/discord/discord_test.go` etc. by whoever implements + this — this spec specifies *that* the doc must describe them, not their exact content, since + verifying each existing test file's structure is implementation-time work): table-driven + `Send` tests against a fake `transport.Wrapper` (via injected `ClientFactory`), config + validation error-path tests, ≥85% coverage per package (per this repo's own `CLAUDE.md` + coverage bar), and — new for this spec — a registration test asserting `notify.New("", + validConfig)` succeeds and returns a `Sender`, plus at least one test asserting a + missing-required-key config produces an error, not a panic. + - **Config validation/error handling conventions**: factories validate structurally (right type + present under each expected key) and return `fmt.Errorf`-wrapped errors describing exactly + which key/type was expected; the underlying `Config`-consuming `New`/`Send` still perform their + own semantic validation (e.g. Discord's webhook host allowlist) exactly as they do today — + the registry layer adds a validation step in front of, not instead of, existing validation. +4. **The registry internals** — brief: where `Register`/`New`/`RegisteredTypes` live (module root, + `factory.go`), the panic-on-duplicate/nil-factory contract, the `RWMutex` concurrency note, and + an explicit statement that this is an *additive convenience layer* — the typed `New(cfg, w)` + constructors remain fully supported and are not deprecated by the registry's existence. +5. **Versioning note** — this document itself should state that adding a provider (a new package) + is a `feat:`/minor-version change under this module's semver policy (unchanged provider API + surface for existing providers = non-breaking), while changing `Register`/`New`/`Factory`'s + signature is a breaking/major-version change — giving a contributor or agent the semver + judgment call up front instead of leaving them to guess at PR time. + +**3.7.2 New README section (`/projects/go_notify_yourself/README.md`)** + +Brief — a new `## Provider registry` section (placed after the existing "Provider packages" table, +before "Transport"), roughly 150–250 words: what `notify.Register`/`notify.New` are, the one-line +`providers/all` blank-import quick-start, a pointer ("see `ARCHITECTURE.md` for how to add a new +provider, and `docs/INTEGRATION.md` for a full integration walkthrough"), and one sentence stating +the typed constructors remain the recommended path when a caller doesn't need runtime discovery +(consistent with §3.2's "additive, not a replacement" framing, so the README doesn't contradict +`ARCHITECTURE.md`). + +**3.7.3 `/projects/go_notify_yourself/docs/INTEGRATION.md` (new file) — required structure (Five Ws +and One H)** + +Written for someone integrating the module into their **own, unrelated** project — not Charon. +Required sections, in this order, each specified so a future writer doesn't have to invent +structure: + +1. **Who this is for** — the target reader: a Go developer building a self-hosted or small-team + application (the README's own framing: "most projects... end up re-implementing the same things + badly") who needs to fire outbound alerts to chat/push/email destinations and doesn't want to + hand-roll SSRF-safe HTTP dispatch, retries, or per-service payload quirks. Explicitly *not* for: + someone needing an Apprise-style URL-scheme dispatcher today (§1.3/non-goals), or someone who + needs inbound/two-way messaging (this module is send-only). +2. **What it does / doesn't do** — a two-column or two-list breakdown. Does: SSRF-safe outbound + HTTP with retry/backoff (`transport.Wrapper`), a uniform `Sender` interface across eight + built-in provider types, JSON payload templating with a shared `text/template` + `toJSON` + engine, a self-registering factory/discovery layer (this spec's addition). Doesn't: own any + database, config file format, or HTTP framework; provide inbound webhook receiving; provide + scheduling/queueing/retry-after-process-restart (retries are in-process, in-request only); + provide a URL-scheme dispatch convention (yet — link to §8 of the extraction spec / this + module's own long-term-direction note for the Apprise aspiration). +3. **When to reach for it vs. rolling your own** — a short decision checklist: reach for it if you + need ≥2 of {Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, email} dispatch, + want retry/backoff and SSRF hardening without writing it yourself, and are fine supplying your + own HTTP client factory / SSRF policy / SMTP mailer via the DI seams. Roll your own if you need + exactly one destination type with a very custom payload shape *and* don't want any of the shared + machinery, or need transport types this module doesn't have (SMS, inbound webhooks, message + queues). +4. **Where it fits in a typical app's architecture** — a short architecture sketch (text or a + simple diagram) showing: your app's business logic layer maps a domain event into a + `notify.Message`; a startup-time wiring step builds one shared `*transport.Wrapper` (with your + `ClientFactory`/`URLValidator` seams) and, if using email, your `Mailer`/`TemplateRenderer` + implementations; either call typed constructors directly or use `notify.New(type, config)` via + `providers/all`; the resulting `Sender`(s) are invoked from wherever your app currently fires + alerts (a notification service, an error handler, a monitoring loop). Explicitly locate this as + "a dispatch layer your service layer calls into," not a framework that owns your request + lifecycle. +5. **Why it's built this way** — the DI-seam philosophy: the module has zero DB/framework/HTTP-server + knowledge by design (restate the non-negotiable rule from this module's own `CLAUDE.md`); every + environment-specific concern is a constructor-injected interface; this is what makes the module + equally usable from Charon (GORM/Gin), a CLI tool, or a serverless function, and is why the + registry (this spec) uses `map[string]any` rather than forcing a specific config-file format or + framework binding (§3.2's rationale, restated briefly for this different audience). +6. **How to integrate — end-to-end walkthrough**: a real, copy-pasteable sequence — + `go get github.com/Wikid82/go_notify_yourself@v0.2.0`; blank-import `providers/all` (or + hand-pick, with the same tradeoff explanation as §3.5, written for this general audience rather + than Charon-specifically); construct a `transport.Wrapper`; construct one or more senders via + `notify.New` with a worked `map[string]any` example for at least one HTTP provider and email; + dispatch a message; handle/log a `Send` error; a short "testing your integration" pointer to the + README's existing "Testing your own integration" section (don't duplicate it, link to it). + +### 3.8 Public API surface summary (new/changed in this spec) + +| Symbol | Package | Status | +|---|---|---| +| `type Factory func(config map[string]any) (Sender, error)` | `notify` (root) | New | +| `func Register(name string, factory Factory)` | `notify` (root) | New | +| `func New(name string, config map[string]any) (Sender, error)` | `notify` (root) | New | +| `func RegisteredTypes() []string` | `notify` (root) | New | +| `package all` (blank-import bundle) | `providers/all` | New package | +| `func init()` in each of 8 provider packages | `providers/*` | New (registration side effect) | +| `Config`, `New(cfg, w)`, `Send` in each of 8 provider packages | `providers/*` | **Unchanged** — no breaking signature change | + +### 3.9 Database schema changes + +None required to land the registry itself. §3.6.4 identifies `ServiceConfig` (already present, +currently unused) as the extension point a *future* provider needing >2 config values would need +wired up — explicitly deferred, not part of this spec's implementation. + +### 3.10 Error handling / edge cases + +- **Unregistered type at `notify.New`**: returns a wrapped error listing currently-registered + types (§3.3) — Charon surfaces this as its existing "unsupported provider type" API error, no new + Charon-side error path needed for this case since `buildNotifySender`'s `default:` branch already + returns an error today. +- **Missing/wrong-typed required config key inside a factory** (e.g. `config["transport"]` absent + or not a `*transport.Wrapper`): each factory returns a descriptive `fmt.Errorf` — never panics + — per §3.4's examples. This is a *caller* bug (Charon's adapter built the map wrong), distinct + from the *programmer* bugs `Register` panics on. +- **Duplicate registration** (two packages registering the same name in one binary): panics at + `init()`, crashing the process immediately at startup — by design (§3.3), surfaces the + misconfiguration as loudly and early as possible rather than silently picking one. +- **`providers/all` omission**: a new provider that registers correctly but isn't added to + `providers/all` doesn't break anything for a consumer hand-picking imports; it silently isn't + part of the "everything" bundle for `providers/all` consumers. Mitigated by the CI count-check + recommended in §3.7.1, not by any runtime mechanism (Go cannot detect "a package that exists in + this repo but wasn't imported"). +- **Charon allowlist drift from the registry** (§3.6.2 Option A risk): Charon's hardcoded allowlist + could, over time, diverge from what's actually registered in the linked module build (claiming + support for a type whose provider package isn't imported, or vice versa). Mitigated by the + recommended consistency check (`isSupportedNotificationProviderType`'s set ⊆ + `notify.RegisteredTypes()`), run as a Charon unit test, not a runtime assertion (avoids a + startup-time panic risk in production for what's fundamentally a build-configuration mismatch + best caught in CI). + +--- + +## 4. Implementation Plan (for the future execution session — not executed here) + +### Phase 1: `go_notify_yourself` — registry core + provider migrations (module repo) + +- Write `factory.go` at module root (`Factory`, `Register`, `New`, `RegisteredTypes`, §3.3) + + `factory_test.go` (duplicate-registration panic, nil-factory panic, unregistered-name error, + concurrent-access test). +- Add `register.go` to each of the eight provider packages (§3.4), plus a shared + `providers/internal/regconfig` helper package for the `stringField`/`stringSliceField` + boilerplate (DRY — avoids 8x duplication). +- Add `providers/all/all.go` (§3.5) + a test asserting `len(notify.RegisteredTypes()) == 8` (the + CI safety net from §3.7.1). +- `go build ./...`, `go vet ./...`, `staticcheck ./...`, `go test ./...` (≥85% coverage on new + code) all green. + +### Phase 2: `go_notify_yourself` — documentation (module repo) + +- Write `ARCHITECTURE.md` per §3.7.1's required sections. +- Add the README section per §3.7.2. +- Write `docs/INTEGRATION.md` per §3.7.3's required sections. +- Update `CHANGELOG.md`'s `[Unreleased]` section with the registry addition, explicitly noting the + `v0.2.0` semver bump and its rationale (§3.4). +- Tag `v0.2.0` once Phase 1 + Phase 2 are both merged in this repo. + +### Phase 3: Charon — adapter simplification (this repo, same branch) + +- Bump `backend/go.mod` to `github.com/Wikid82/go_notify_yourself v0.2.0`. +- Add hand-picked provider imports (§3.6.3) — one `import _ "…/providers/"` line per + Charon-supported type, in a new small file (e.g. `notify_providers_import.go`) rather than + scattered across existing files, so the "what's linked" list stays in one obvious place. +- Rewrite `buildNotifySender` to call `notify.New(provider.Type, config)` (§3.6.1), with + `providerConfigMap` doing the remaining per-type field-name mapping (still present, smaller + scope — no direct `providers/*` package function calls left in this file, only the blank imports + above plus `transport`/`email` types needed to build the config map's values). +- Add the recommended consistency-check unit test (§3.6.2/§3.10): + `isSupportedNotificationProviderType`'s set is a subset of `notify.RegisteredTypes()` given + Charon's actual linked imports. +- Update `ARCHITECTURE.md` per this repo's own mandatory-update rule. + +### Phase 4: Integration and testing (Charon repo) + +- Rewrite/extend `notify_provider_adapter_test.go` to cover the new `notify.New`-based dispatch + path — assert the same per-type behaviors the existing switch-based tests already assert (§2.3), + now via the registry, plus new tests for the "unregistered/unsupported type" and + "missing-transport-in-config" error paths. +- Full backend suite + `scripts/go-test-coverage.sh` (≥85%) green. +- No frontend changes in this phase (§3.6.5 is explicitly deferred) — no Playwright changes needed + since no user-observable behavior changes (same provider types, same UI, same API responses). + +### Phase 5: Documentation and deployment (both repos) + +- Confirm `go.sum` supply-chain scan clean for the bumped dependency. +- No `docs/features/notifications.md` change required (product-facing behavior is unchanged; + optional credit-the-module line only, per the original extraction spec's precedent). +- Release Charon per normal Conventional Commits flow on this existing PR/branch. + +--- + +## 5. Acceptance Criteria + +- [ ] `go_notify_yourself`: `Register`/`New`/`RegisteredTypes` exist at module root, `go test + ./...` and `staticcheck` pass with zero findings, tagged `v0.2.0`. +- [ ] All eight provider packages self-register via `init()`; `providers/all` blank-imports all + eight and its count-check test passes. +- [ ] `notify.New("discord", map[string]any{"transport": w, "webhook_url": "...", "template": + "minimal"})` returns a working `*discord.Client` equivalent to `discord.New(discord.Config{...}, + w)` — behavioral parity between the typed constructor and the registry path is asserted by + test, not assumed. +- [ ] `ARCHITECTURE.md`, README's new section, and `docs/INTEGRATION.md` exist and cover every + required subsection listed in §3.7. +- [ ] Charon's `notify_provider_adapter.go`'s `buildNotifySender` calls `notify.New`; the file's + import list no longer imports the eight `providers/*` packages directly for constructor calls + (blank-imports for registration live in a separate, clearly-named file). +- [ ] Charon's three provider-type allowlists (`isSupportedNotificationProviderType`, + `supportsJSONTemplates`, `isDispatchEnabled`) remain hardcoded per §3.6.2's Option A + recommendation, with a new test asserting they're a subset of `notify.RegisteredTypes()`. +- [ ] No change to `docs/plans/current_spec.md`. +- [ ] No behavior change observable from Charon's frontend or REST API — same provider types + supported, same request/response shapes, same dispatch semantics (retry/backoff, SSRF policy + unchanged from the already-merged extraction). +- [ ] Charon's backend coverage gate (`scripts/go-test-coverage.sh`, min 85%) still passes. +- [ ] `ARCHITECTURE.md` (Charon's) updated per the mandatory rule. + +--- + +## 6. Commit Slicing Strategy + +**Decision**: this work is **not** a new feature/new PR — it folds into the existing, still-open +PR #1253 on `feature/notifications-engine-extraction`, exactly as the task specified. Per this +repo's own commit-slicing convention, it spans two repositories (module repo, then Charon repo), +each with its own ordered commit sequence, landing on the *same* existing branch/PR shape the +original extraction already established (two-repo-two-PR-shaped-as-one-feature, not a new branch). + +### `go_notify_yourself` repo — additional commits (same repo, no PR needed there per its own +### CLAUDE.md's "direct TDD, no multi-agent roster" note — but still ordered/bisectable commits) + +1. **Commit 1** — Registry core: `factory.go` + `factory_test.go` (§3.3, Phase 1). Gate: `go build + ./...`, `go vet ./...`, `go test ./...` green, new code ≥85% coverage. +2. **Commit 2** — Shared registration helper: `providers/internal/regconfig` (`stringField`/ + `stringSliceField` + tests). Gate: same pattern. Dependency: Commit 1. +3. **Commit 3** — Discord + Slack registration (`register.go` in each, §3.4). Gate: `go test + ./providers/discord/... ./providers/slack/...` green, registry round-trip test passes. + Dependency: Commits 1–2. +4. **Commit 4** — Gotify + Pushover + Ntfy registration. Gate: same pattern. Dependency: 1–2. +5. **Commit 5** — Telegram + Webhook registration. Gate: same pattern. Dependency: 1–2. +6. **Commit 6** — Email registration (highest design-risk piece, per the original extraction + spec's precedent for treating email specially — the non-serializable `Mailer`/`TemplateRenderer` + type-assertion path deserves dedicated review attention). Gate: `go test ./providers/email/...` + green, explicit test for missing-`Mailer` error path. Dependency: 1–2. +7. **Commit 7** — `providers/all` bundle + count-check test (§3.5). Gate: `go test + ./providers/all/...` asserts `len(notify.RegisteredTypes()) == 8`. Dependency: Commits 3–6 (all + eight must be registered first). +8. **Commit 8** — Documentation: `ARCHITECTURE.md`, README section, `docs/INTEGRATION.md` (§3.7). + Gate: manual review against §3.7's required-sections checklist (no automated gate for doc + content). Dependency: Commits 1–7 (docs describe the finished API). +9. **Commit 9** — `CHANGELOG.md` update + tag `v0.2.0`. Gate: `goreleaser release --snapshot` + dry-run succeeds. Dependency: Commit 8. + +Rollback: any commit 3–7 can be reverted independently (each provider's registration is additive +and isolated); Commit 1 is the sole hard dependency for everything after it — reverting it reverts +the whole registry addition cleanly since nothing else in the module depended on a registry +existing before this work. + +### Charon repo — additional commits on the existing `feature/notifications-engine-extraction` +### branch (same PR #1253) + +1. **Commit 1** — Dependency bump: `go.mod`/`go.sum` to `go_notify_yourself v0.2.0`. Gate: `go + build ./...`. Dependency: module repo's `v0.2.0` tag must exist first. +2. **Commit 2** — Registration imports: new `notify_providers_import.go` with hand-picked + blank-imports (§3.6.3) for Charon's eight currently-supported types. No behavior change yet + (nothing calls `notify.New` still). Gate: `go build ./...`. +3. **Commit 3** — Adapter rewrite: `buildNotifySender` calls `notify.New` (§3.6.1); `resolveTemplateFields` + and the per-type `providerConfigMap` helper carry forward from the existing switch, adapted to + produce `map[string]any` instead of typed `Config` structs. Gate: `notify_provider_adapter_test.go` + rewritten and green, asserting identical behavior to the pre-registry switch for all eight types + (parity, not just "compiles"). Dependency: Commits 1–2. +4. **Commit 4** — Allowlist consistency test: new test asserting + `isSupportedNotificationProviderType`'s set ⊆ `notify.RegisteredTypes()` (§3.6.2/§3.10). Gate: + test passes given Commit 2's imports. Dependency: Commit 3. +5. **Commit 5** — Cleanup: remove now-unused direct `providers/*` package imports from + `notify_provider_adapter.go` if any remain post-rewrite; confirm no unused imports via + staticcheck. Gate: `go build ./...`, staticcheck clean. Dependency: Commit 3. +6. **Commit 6** — Docs: `ARCHITECTURE.md` update noting the registry-based dispatch (supersedes + the switch-statement description added by the original extraction's Commit 12). Gate: manual + review. Dependency: Commit 3. +7. **Commit 7** — Coverage/hardening: re-run `scripts/go-test-coverage.sh`, fix any regression. + Gate: full Definition of Done per CLAUDE.md. Dependency: Commits 1–6. + +Rollback for the Charon side: since PR #1253 is still open (not yet merged to `development`), any +of Commits 1–7 can be reverted or the whole set squashed out of the branch before merge with zero +production impact — there is no live consumer of the registry-based path yet. Post-merge, the +dependency bump (Commit 1) is the natural revert boundary: rolling back to the pre-`v0.2.0` pin and +restoring the switch-based `buildNotifySender` (Commits 2–5) is a clean, self-contained revert since +no schema/API/GORM change accompanies this work. + +Contingency: if Phase 3/4 discovers the `map[string]any` boundary is meaningfully harder to keep in +sync with GORM's `URL`/`Token` reinterpretation-per-type scheme than expected (§3.6.1's caveat), +stop before Charon's Commit 3 and re-scope `providerConfigMap` as its own small design pass — this +does not block the module-repo commits (1–9 above), which are self-contained and useful to the +user's other project regardless of exactly how Charon's adapter ends up shaped. + +--- + +## 7. Risks / Open Questions (for the user to confirm before implementation) + +1. **Allowlist vs. full discovery (§3.6.2) — needs explicit user sign-off.** Recommended: Option A + (Charon keeps its own hardcoded allowlist; `notify.RegisteredTypes()` used only as a CI/test + consistency check, not a live API gate). Alternative: Option B (Charon's API directly reflects + `notify.RegisteredTypes()`, true zero-touch exposure). This is a genuine product decision about + how eagerly Charon should surface engine capabilities it hasn't built UI/support for yet — not a + technical question this spec can resolve unilaterally. +2. **Config-typing at the registry boundary (§3.2) — `map[string]any`, recommended, with a stated + type-safety cost.** Confirm the user is comfortable losing compile-time safety at this one + boundary (the typed constructors remain fully available and are the recommended path when + runtime discovery isn't needed) in exchange for a single uniform mechanism that also + accommodates email's non-serializable `Mailer`/`TemplateRenderer` dependencies (§3.1's evidence + for why `json.RawMessage` alone doesn't work). +3. **`providers/all` vs. hand-picked imports, per consumer (§3.5/§3.6.3).** Recommended: Charon + hand-picks (tighter control, consistent with the Option A allowlist decision); the user's other + project (the original motivating use case) more plausibly wants `providers/all` for genuine + zero-touch discovery. Confirm this isn't meant to be a uniform policy across both consumers — + the spec currently treats it as a per-consumer choice, which the user should explicitly agree + is the right framing rather than assuming one answer fits both projects. +4. **`ServiceConfig` wiring (§3.6.4) is flagged, not built.** A future provider needing >2 config + values (Web Push being the concrete driver) will need this GORM field actually wired up — this + spec identifies it as the extension point and recommends a shape (JSON-encoded + `map[string]string`, merged into the registry's `config` map) but treats implementing it as + follow-up work, not part of this commit sequence. Confirm this sequencing is acceptable — i.e., + that the user wants the registry to land first, generically, with the schema question addressed + only once Web Push (or another >2-field provider) is actually being built against + `go_notify_yourself`. +5. **Frontend schema-driven forms (§3.6.5) — explicitly out of scope for this pass.** Confirm the + user agrees "drop-in ready" for this round means the Go module/Charon backend only, and that + frontend auto-generation is a separate, later decision (potentially never, if Charon's product + philosophy prefers hand-built, polished per-provider forms over generic ones — a design opinion + worth surfacing explicitly rather than assuming). +6. **Naming-collision hazard for third-party providers (§3.4).** `Register` panics on a duplicate + name. This is fine for the eight built-in providers (fixed, non-colliding names) but is a new + failure mode once truly third-party provider packages exist (per the user's stated long-term + goal of others contributing providers) — e.g. two unrelated third-party packages both choosing + `Register("webpush", ...)`. No global namespace-reservation mechanism is proposed here (would be + premature — there's no third-party provider ecosystem yet); flagging so the user is aware this + is a real, if distant, consequence of the self-registration pattern, same as it is for + `database/sql` drivers today. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 46f5606ec..0746eeea5 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -106,3 +106,105 @@ This PR's doc changes introduce zero new lint findings. Pre-existing lint debt i 2. SECURITY.md/ARCHITECTURE.md carry substantial pre-existing markdownlint debt (207 combined findings) unrelated to this PR. Worth a future standalone cleanup pass, but explicitly out of scope here per this feature's CI/shell-script-only mandate. ## Final Overall Verdict: **PASS — ready to be marked done.** + +--- +--- + +# QA/Security Audit — Notify Provider Registry (Self-Registering Factory) (Independent Verification) + +**Branch**: `feature/notifications-engine-extraction` +**Commit range reviewed**: `a28f0db9..HEAD` (`e3e971ec`, `326d28e9`, `14421c06`, `567de4e7`, `7c48f009`) +**Reviewed by**: qa-security agent +**Date**: 2026-08-17 +**Scope**: Backend-only. Replaces a hardcoded `switch p.Type { case "discord": ... }` provider-construction dispatch in `backend/internal/services/notify_provider_adapter.go` with a call into a new self-registering factory registry (`notify.New`) shipped by a companion module, `github.com/Wikid82/go_notify_yourself` (pinned `v0.2.0` via a local-path `replace` directive to `/projects/go_notify_yourself`, branch `feature/provider-registry`, not yet pushed to GitHub — expected/tracked, not a defect). +**Prior reviews**: `backend-dev` implementation pass + `supervisor` code review — **APPROVED WITH MINOR NOTES** (no blocking issues; email construction path and the local-path `replace` directive both flagged as known, non-blocking). +**Purpose**: Independent re-verification of all Definition of Done gates plus a dedicated security review of the new `map[string]any` config-boundary and provider registry, per `docs/plans/notify_provider_registry_spec.md` §3 and §5. + +## Summary Verdict: **READY TO MERGE** — no blocking issues found. All Definition of Done gates independently re-run and passed with real, non-cached numbers where applicable. + +--- + +## 1. Definition of Done Gates (all independently re-run from repo root / `backend/`) + +| Gate | Command | Result | +|---|---|---| +| Backend build | `cd backend && go build ./...` | **PASS** — clean, zero errors | +| Static vet | `cd backend && go vet ./...` | **PASS** — zero findings | +| Staticcheck | `make lint-staticcheck-only` | **PASS** — `0 issues.` (backend), `0 issues.` (agent) | +| Full backend test suite | `cd backend && go test ./...` | **PASS** — all packages `ok`, zero failures. `internal/services` (the touched package) ran uncached, 111.308s, all green. | +| Backend coverage gate | `bash scripts/go-test-coverage.sh` (`CHARON_MIN_COVERAGE` default 87%) | **PASS** — Statement coverage **89.3%**, line coverage **89.2%** (gate: line coverage ≥ 87%). Console: `Coverage gate (line coverage): minimum required 87% / Coverage requirement met`. | +| Local patch coverage preflight | `bash scripts/local-patch-report.sh` | **PASS** — Backend: 235/235 changed lines covered = **100.0%** patch coverage (threshold 85%). Overall/Frontend/Agent all 100.0% (0 changed lines outside backend). Artifacts confirmed at `test-results/local-patch-report.md` and `test-results/local-patch-report.json`. | +| GORM security scan | `./scripts/scan-gorm-security.sh --check` | **PASS** — `CRITICAL: 0`, `HIGH: 0`, `MEDIUM: 0`, `INFO: 2` (both pre-existing, in `backend/internal/models/user.go`'s `UserPermittedHost` struct — unrelated to this change; 61 files / 3675 lines scanned). | +| Lefthook pre-commit | `lefthook run pre-commit` | **N/A / clean** — working tree is clean (no staged changes; this is an audit pass on already-committed code, `git status` confirmed clean at session start), so every hook reported `(skip) no matching staged files`. The equivalent checks (`go vet`, staticcheck) were independently re-run directly above and passed. | + +Playwright E2E: **not run**, per task scope. Verified during the security review (§3 below) that provider dispatch behavior is byte-for-byte unchanged — `notify_provider_adapter_test.go`'s existing per-provider tests assert the exact same dispatch URL, headers, and JSON payload shape as the pre-registry switch (e.g. Gotify's `X-Gotify-Key` header, Pushover's hardcoded production URL + `user`/`token` payload fields, Telegram's `bot/sendMessage` URL). No new HTTP payload/header/URL behavior was introduced for any provider type, so the Playwright skip condition in the task brief is satisfied. + +--- + +## 2. Commit-Range Diff Verification + +- `git diff --stat a28f0db9..HEAD`: 7 files changed, 223 insertions(+), 84 deletions(-) — `ARCHITECTURE.md`, `backend/go.mod`, `backend/go.sum`, `notification_service_registry_consistency_test.go` (new), `notify_provider_adapter.go`, `notify_provider_adapter_test.go`, `notify_providers_import.go` (new). +- `git diff a28f0db9..HEAD -- backend/internal/models/notification_provider.go` → **empty**. Confirms the `Token` field's `json:"-"` GORM protection is untouched. +- `git diff a28f0db9..HEAD -- backend/internal/services/notification_service.go` → **empty**. Confirms `isSupportedNotificationProviderType`, `supportsJSONTemplates`, `isDispatchEnabled` are byte-for-byte unchanged, per the spec's hard design requirement (§3.6.2 Option A). +- `grep -rn "providers/all" backend/` → only 2 matches, both inside a comment in `notify_providers_import.go` explicitly explaining *why* `providers/all` is deliberately **not** imported. No actual import anywhere in `backend/`. + +--- + +## 3. Security-Specific Findings + +### 3.1 No token/secret leakage into logs or errors — **PASS** + +Read `backend/internal/services/notify_provider_adapter.go` and `notify_providers_import.go` in full, plus every `providers/*/register.go` in `/projects/go_notify_yourself` (discord, slack, gotify, pushover, ntfy, telegram, webhook, email) and `factory.go`. + +- The only error-wrapping call site in `notify_provider_adapter.go` is `fmt.Errorf("notify provider adapter: %w", err)` (line 175) — wraps, never re-formats field values. +- Every registry-level error (`notify.New`'s "no provider registered for type %q", each factory's `config["transport"] must be a non-nil *transport.Wrapper` / `config["mailer"] must be a non-nil Mailer`) references only **field/key names and the provider type discriminator** — never `provider.URL`, `provider.Token`, or any config map value. +- Log call sites that consume `buildNotifySender`'s error (`notification_service.go:312`, `:324`) log only `util.SanitizeForLog(p.Name)` plus the wrapped error — never `p.URL`/`p.Token`. +- No `fmt.Println`, `log.Print`, or debug statements were introduced anywhere in the diff. + +**Conclusion**: no Gotify token, webhook URL-as-secret, or any provider credential can reach logs, error messages, or test artifacts through this code path. SECURITY.md's "Gotify Token Hygiene" requirement is satisfied. + +### 3.2 `Token` field GORM protection untouched — **PASS** + +Confirmed via the empty diff above (§2). `Token string \`json:"-"\`` is unchanged in `backend/internal/models/notification_provider.go`. + +### 3.3 `map[string]any` config-boundary type-confusion risk — **PASS, verified independently, not just re-trusted** + +Read `/projects/go_notify_yourself/factory.go` and all eight `providers/*/register.go` files directly (not assumed from the spec). Every factory: + +- Type-asserts `config["transport"].(*transport.Wrapper)` (or `config["mailer"].(Mailer)` for email) with the two-value `ok` form and an explicit nil check — **never a bare/panicking assertion**. +- Returns a descriptive `fmt.Errorf` (never panics) when the assertion fails or the value is nil. +- Delegates all scalar/slice field extraction to `providers/internal/regconfig.StringField`/`StringSliceField`, both of which are deliberately lenient: a wrong-typed or missing key produces the zero value (`""`/`nil`), never a panic. Verified via `regconfig`'s own test table, which explicitly covers `wrong type int`, `wrong type nil value`, `any slice with non-string element`, and `wrong type entirely` — all resolve to zero values, not panics. +- `Register` itself panics only on programmer misuse (`nil` factory, empty name, duplicate registration) at `init()` time — never on caller-supplied runtime data, matching the `database/sql`/`image` prior-art convention the spec cites. + +Charon's own tests (`notify_provider_adapter_test.go`) independently exercise this boundary: `TestBuildNotifySenderMissingTransportErrors` and `TestBuildNotifySenderInvalidTransportInConfigMapErrors` both assert a nil/invalid transport produces an error containing "transport", not a panic. `TestBuildNotifySenderUnsupportedTypeErrors` asserts an unregistered type ("carrier-pigeon") produces a "no provider registered" error, not a panic. + +**Conclusion**: no type-confusion panic path exists at the registry boundary. Supervisor's prior claim is independently confirmed, not merely re-trusted. + +### 3.4 `isSupportedNotificationProviderType` / `supportsJSONTemplates` / `isDispatchEnabled` unchanged — **PASS** (see §2, empty diff) + +### 3.5 `providers/all` not imported anywhere in `backend/` — **PASS** (see §2) + +`notify_providers_import.go` hand-picks exactly Charon's eight supported types (`discord`, `email`, `gotify`, `ntfy`, `pushover`, `slack`, `telegram`, `webhook`), matching `isSupportedNotificationProviderType`'s allowlist. A new consistency test, `TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes` (`notification_service_registry_consistency_test.go`), asserts this allowlist is a subset of `notify.RegisteredTypes()` — guarding against future drift between the hand-picked imports and the allowlist. + +### 3.6 `go.mod` local-path replace directive — tracked, not a defect + +``` +replace github.com/Wikid82/go_notify_yourself => /projects/go_notify_yourself +``` + +Annotated in `go.mod` with a `TODO` explaining it must be removed once `go_notify_yourself v0.2.0` is pushed/tagged upstream. This is a real, temporary condition (confirmed: `/projects/go_notify_yourself` is on local branch `feature/provider-registry`, not yet on GitHub) and matches what the task brief and supervisor's prior review both already flagged as expected. Not a merge blocker for the Charon-side PR per the spec's own commit-slicing sequencing, but **must** be resolved before this local-path pin would work in CI or for any other contributor — flagged below as a required follow-up. + +--- + +## 4. Non-Blocking Follow-Ups (tracked, not blocking this PR) + +1. **`go.mod` local-path `replace` directive** (`backend/go.mod:9`) must be removed and re-pinned to the real published `go_notify_yourself v0.2.0` tag once `/projects/go_notify_yourself`'s `feature/provider-registry` branch is pushed and tagged on GitHub. Already tracked via the in-file `TODO` comment; CI/other contributors cannot build this branch until resolved. +2. Email's construction (`notification_service.go:350`, `dispatchEmailViaNotify`) still calls `email.New(...)` directly rather than routing through `notify.New` — consistent with pre-existing architecture (email's `Mailer`/`TemplateRenderer` DI seam predates this work) and not a regression, per supervisor's prior note. No action required by this PR. + +## Blocking Issues + +**None.** + +## Final Overall Verdict: **READY TO MERGE** + +All seven independently re-run Definition of Done gates pass with real numbers (89.3%/89.2% overall backend coverage against an 87% gate; 100% patch coverage on the 235 changed lines against an 85% gate; 0 CRITICAL/HIGH/MEDIUM GORM findings; 0 staticcheck/vet findings; full backend suite green). The security-specific review independently verified — by reading the actual factory/register code in `/projects/go_notify_yourself`, not by re-trusting the prior supervisor review — that no token/secret can reach logs or error messages, that the `map[string]any` registry boundary cannot panic on caller-controlled input, that the `Token` field's `json:"-"` protection and Charon's three provider allowlists are byte-for-byte unchanged from before this work, and that `providers/all` is never imported in `backend/`. The one open item (local-path `go.mod` replace directive) is already tracked and does not block merging this Charon-side PR per the spec's own two-repo commit-slicing sequencing. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 083f2e39c..d49a70d22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -449,9 +449,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", "dev": true, "funding": [ { @@ -493,9 +493,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", "dev": true, "funding": [ { @@ -509,7 +509,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.0", + "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "engines": { @@ -544,9 +544,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", - "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", "dev": true, "funding": [ { @@ -5371,9 +5371,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.406", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", - "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", "dev": true, "license": "ISC" }, @@ -6909,9 +6909,9 @@ } }, "node_modules/immer": { - "version": "11.1.16", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", - "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz", + "integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==", "license": "MIT", "funding": { "type": "opencollective",