From 84a4b13398b52aeaa52cbc44d8711aa19639fc50 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 14 Aug 2026 17:14:15 +0000 Subject: [PATCH 01/18] docs: add notifications engine extraction spec Scopes the extraction of Charon's notification delivery (HTTP wrapper, provider payload builders, email dispatch) into the standalone go_notify_yourself module, including the decoupling design, new module's public API, and the two-repo commit slicing strategy. --- docs/plans/notifications_extraction_spec.md | 1146 +++++++++++++++++++ 1 file changed, 1146 insertions(+) create mode 100644 docs/plans/notifications_extraction_spec.md 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) From 9af2d3e90fc16fadda62cf9b1263f59c24b2c362 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 00:14:28 +0000 Subject: [PATCH 02/18] feat: add Charon-side transport adapter for extracted notify module --- backend/go.mod | 6 + .../services/notify_client_adapter.go | 135 ++++++++++++++ .../services/notify_client_adapter_test.go | 171 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 backend/internal/services/notify_client_adapter.go create mode 100644 backend/internal/services/notify_client_adapter_test.go diff --git a/backend/go.mod b/backend/go.mod index 19a9d28be..b1a9d2490 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.0.0-00010101000000-000000000000 github.com/gin-contrib/gzip v1.2.6 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 @@ -113,3 +114,8 @@ require ( modernc.org/memory v1.12.0 // indirect modernc.org/sqlite v1.56.0 // indirect ) + +// TODO: go_notify_yourself has not been pushed to GitHub yet. Remove this replace +// directive and re-pin the require above to a real published tag once the module +// is pushed to github.com/Wikid82/go_notify_yourself. +replace github.com/Wikid82/go_notify_yourself => /projects/go_notify_yourself diff --git a/backend/internal/services/notify_client_adapter.go b/backend/internal/services/notify_client_adapter.go new file mode 100644 index 000000000..5dc5fc2d7 --- /dev/null +++ b/backend/internal/services/notify_client_adapter.go @@ -0,0 +1,135 @@ +package services + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/Wikid82/charon/backend/internal/network" + "github.com/Wikid82/charon/backend/internal/security" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// notifyClientTimeout is the outbound HTTP timeout used for every extracted +// notify-module provider dispatch. Matches the timeout the old +// internal/notifications.HTTPWrapper used before extraction (§3.2 Seam 1 of +// docs/plans/notifications_extraction_spec.md). +const notifyClientTimeout = 10 * time.Second + +// resolveNotifyAllowHTTP determines whether outbound notification dispatch +// via the extracted notify module may use plain HTTP (and connect to +// localhost/private destinations) instead of requiring HTTPS. +// +// This is a deliberate, documented replacement for the old +// internal/notifications.allowNotifyHTTPOverride, which auto-allowed HTTP +// whenever os.Args[0] ended in ".test" (i.e. whenever the process was a +// compiled `go test` binary, regardless of any env var). That binary-name +// sniffing was flagged as a code smell in the extraction spec (§3.7) because +// it is implicit-environment coupling: a Go test binary is not, by itself, a +// declaration of intent to disable HTTPS enforcement. +// +// The replacement resolution is explicit and env-var-driven: +// +// - CHARON_ENV=test auto-allows HTTP unconditionally, mirroring what the +// ".test" suffix check was actually trying to achieve (frictionless +// httptest.Server-backed unit tests) — but as an explicit opt-in test +// setups declare via CHARON_ENV rather than something inferred from the +// compiled binary's name. Test setup that relied on the old implicit +// detection must set CHARON_ENV=test explicitly once this adapter is +// wired into production dispatch (a later cutover phase, not this one). +// - Outside of CHARON_ENV=test, behavior is unchanged from the old logic's +// second branch: HTTP requires an explicit operator opt-in via +// CHARON_NOTIFY_ALLOW_HTTP=true (env var name kept for backward +// compatibility with existing deployments) AND CHARON_ENV set to +// "development" or "test". +func resolveNotifyAllowHTTP() bool { + env := strings.ToLower(strings.TrimSpace(os.Getenv("CHARON_ENV"))) + if env == "test" { + return true + } + + allowHTTP := strings.EqualFold(strings.TrimSpace(os.Getenv("CHARON_NOTIFY_ALLOW_HTTP")), "true") + if !allowHTTP { + return false + } + return env == "development" || env == "test" +} + +// resolveNotifyMaxRedirects reads CHARON_NOTIFY_MAX_REDIRECTS, clamping the +// result to [0, 5]. Mirrors the old internal/notifications.notifyMaxRedirects +// exactly (same env var name and clamping behavior, for backward +// compatibility with existing deployments). +func resolveNotifyMaxRedirects() 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 +} + +// notifyClientFactory implements transport.ClientFactory, backed by Charon's +// existing SSRF-safe HTTP client (internal/network) — Seam 1 of the +// extraction spec (§3.2). +func notifyClientFactory(allowHTTP bool, maxRedirects int) *http.Client { + opts := []network.Option{ + network.WithTimeout(notifyClientTimeout), + network.WithMaxRedirects(maxRedirects), + } + if allowHTTP { + opts = append(opts, network.WithAllowLocalhost()) + } + return network.NewSafeHTTPClient(opts...) +} + +// notifyURLValidator implements transport.URLValidator, backed by Charon's +// existing SSRF-safe URL validation (internal/security) — Seam 2 of the +// extraction spec (§3.2). +func notifyURLValidator(rawURL string, allowHTTP bool) (string, error) { + var opts []security.ValidationOption + if allowHTTP { + opts = append(opts, security.WithAllowHTTP(), security.WithAllowLocalhost()) + } + + validated, err := security.ValidateExternalURL(rawURL, opts...) + if err != nil { + return "", fmt.Errorf("notify client adapter: validate destination URL: %w", err) + } + return validated, nil +} + +// NewNotifyTransportWrapper builds the single shared *transport.Wrapper +// instance that every extracted notify-module provider package's +// New(cfg, wrapper) call is injected with (§3.6 step 3 of the extraction +// spec). It wires Charon's existing SSRF infrastructure +// (internal/network, internal/security) into the module's +// ClientFactory/URLValidator DI seams, and resolves the same +// CHARON_NOTIFY_ALLOW_HTTP / CHARON_NOTIFY_MAX_REDIRECTS env vars the old +// internal/notifications.HTTPWrapper read, keeping the same env var names for +// backward compatibility with existing deployments. +func NewNotifyTransportWrapper() *transport.Wrapper { + return transport.NewWrapper( + transport.WithClientFactory(notifyClientFactory), + transport.WithURLValidator(notifyURLValidator), + transport.WithRetryPolicy(transport.RetryPolicy{ + MaxAttempts: 3, + BaseDelay: 200 * time.Millisecond, + MaxDelay: 2 * time.Second, + }), + transport.WithAllowHTTP(resolveNotifyAllowHTTP()), + transport.WithMaxRedirects(resolveNotifyMaxRedirects()), + ) +} diff --git a/backend/internal/services/notify_client_adapter_test.go b/backend/internal/services/notify_client_adapter_test.go new file mode 100644 index 000000000..2f95f9c21 --- /dev/null +++ b/backend/internal/services/notify_client_adapter_test.go @@ -0,0 +1,171 @@ +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestResolveNotifyAllowHTTP(t *testing.T) { + tests := []struct { + name string + charonEnv string + allowHTTP string + wantResult bool + }{ + {"test env auto-allows regardless of flag", "test", "", true}, + {"test env auto-allows even when flag explicitly false", "test", "false", true}, + {"development env requires explicit flag true", "development", "true", true}, + {"development env without flag stays false", "development", "", false}, + {"development env with flag false stays false", "development", "false", false}, + {"production env ignores flag", "production", "true", false}, + {"unset env ignores flag", "", "true", false}, + {"case-insensitive test env", "TEST", "", true}, + {"case-insensitive flag value", "development", "TRUE", true}, + {"whitespace padded env", " test ", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CHARON_ENV", tt.charonEnv) + t.Setenv("CHARON_NOTIFY_ALLOW_HTTP", tt.allowHTTP) + + if got := resolveNotifyAllowHTTP(); got != tt.wantResult { + t.Fatalf("resolveNotifyAllowHTTP() = %v, want %v", got, tt.wantResult) + } + }) + } +} + +func TestResolveNotifyMaxRedirects(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 := resolveNotifyMaxRedirects(); got != tt.expected { + t.Fatalf("resolveNotifyMaxRedirects() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestNotifyClientFactoryAllowsLocalhostWhenAllowHTTPTrue(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := notifyClientFactory(true, 0) + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("expected localhost request to succeed with allowHTTP=true, got error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestNotifyClientFactoryBlocksLocalhostWhenAllowHTTPFalse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := notifyClientFactory(false, 0) + _, err := client.Get(server.URL) + if err == nil { + t.Fatal("expected localhost request to fail with allowHTTP=false") + } +} + +func TestNotifyURLValidatorAllowsLocalhostHTTPWhenAllowed(t *testing.T) { + validated, err := notifyURLValidator("http://127.0.0.1:8080/webhook", true) + if err != nil { + t.Fatalf("expected localhost http URL to validate with allowHTTP=true, got error: %v", err) + } + if validated == "" { + t.Fatal("expected a non-empty normalized URL") + } +} + +func TestNotifyURLValidatorRejectsHTTPWhenNotAllowed(t *testing.T) { + _, err := notifyURLValidator("http://example.com/webhook", false) + if err == nil { + t.Fatal("expected http URL to be rejected when allowHTTP=false") + } +} + +func TestNotifyURLValidatorRejectsPrivateIPWithoutOverride(t *testing.T) { + _, err := notifyURLValidator("https://192.168.1.5/webhook", false) + if err == nil { + t.Fatal("expected private IP destination to be rejected") + } +} + +func TestNewNotifyTransportWrapperEndToEnd(t *testing.T) { + t.Setenv("CHARON_ENV", "test") + t.Setenv("CHARON_NOTIFY_ALLOW_HTTP", "") + t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", "") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + wrapper := NewNotifyTransportWrapper() + if wrapper == nil { + t.Fatal("expected a non-nil transport.Wrapper") + } + + result, err := wrapper.Send(context.Background(), transport.Request{ + URL: server.URL, + Body: []byte(`{"message":"hi"}`), + }) + if err != nil { + t.Fatalf("expected Send to succeed against local test server, got error: %v", err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.StatusCode) + } +} + +func TestNewNotifyTransportWrapperBlocksExternalHTTPOutsideTestEnv(t *testing.T) { + t.Setenv("CHARON_ENV", "production") + t.Setenv("CHARON_NOTIFY_ALLOW_HTTP", "") + t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", "") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + wrapper := NewNotifyTransportWrapper() + + _, err := wrapper.Send(context.Background(), transport.Request{ + URL: server.URL, + Body: []byte(`{"message":"hi"}`), + }) + if err == nil { + t.Fatal("expected Send to a local httptest server to fail outside test/dev env") + } +} From 11d3489c4e8c60c8cb375245bd1ecd99951d9b03 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 00:22:28 +0000 Subject: [PATCH 03/18] feat: add Charon-side provider and email adapters for notify module --- .../internal/services/notify_email_adapter.go | 87 ++++ .../services/notify_email_adapter_test.go | 212 +++++++++ .../services/notify_provider_adapter.go | 173 ++++++++ .../services/notify_provider_adapter_test.go | 412 ++++++++++++++++++ 4 files changed, 884 insertions(+) create mode 100644 backend/internal/services/notify_email_adapter.go create mode 100644 backend/internal/services/notify_email_adapter_test.go create mode 100644 backend/internal/services/notify_provider_adapter.go create mode 100644 backend/internal/services/notify_provider_adapter_test.go diff --git a/backend/internal/services/notify_email_adapter.go b/backend/internal/services/notify_email_adapter.go new file mode 100644 index 000000000..08603237b --- /dev/null +++ b/backend/internal/services/notify_email_adapter.go @@ -0,0 +1,87 @@ +package services + +import ( + "context" + "fmt" + "time" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/email" +) + +// mailServiceMailerAdapter implements email.Mailer by delegating to Charon's +// existing MailServiceInterface.SendEmail — the extracted module never dials +// SMTP directly (§3.3.4 of the extraction spec); Charon supplies transport +// behind this interface. +type mailServiceMailerAdapter struct { + mailService MailServiceInterface +} + +var _ email.Mailer = (*mailServiceMailerAdapter)(nil) + +func (a *mailServiceMailerAdapter) Send(ctx context.Context, recipients []string, subject, htmlBody string) error { + if a.mailService == nil { + return fmt.Errorf("notify email adapter: mail service is not configured") + } + if err := a.mailService.SendEmail(ctx, recipients, subject, htmlBody); err != nil { + return fmt.Errorf("notify email adapter: send email: %w", err) + } + return nil +} + +// mailServiceTemplateRendererAdapter implements email.TemplateRenderer by +// delegating to Charon's existing MailServiceInterface.RenderNotificationEmail +// and Charon's five branded HTML templates, mapping notify.Message fields +// onto Charon's EmailTemplateData shape. The extracted module's own neutral +// default template is intentionally never used in production — Charon always +// supplies this renderer (§3.3.4's required-override tradeoff). +type mailServiceTemplateRendererAdapter struct { + mailService MailServiceInterface +} + +var _ email.TemplateRenderer = (*mailServiceTemplateRendererAdapter)(nil) + +func (a *mailServiceTemplateRendererAdapter) Render(templateName string, msg notify.Message) (string, error) { + if a.mailService == nil { + return "", fmt.Errorf("notify email adapter: mail service is not configured") + } + + data := EmailTemplateData{ + EventType: msg.EventType, + Title: msg.Title, + Message: msg.Body, + Timestamp: msg.Timestamp.Format(time.RFC3339), + } + + htmlBody, err := a.mailService.RenderNotificationEmail(templateName, data) + if err != nil { + return "", fmt.Errorf("notify email adapter: render template: %w", err) + } + return htmlBody, nil +} + +// NewNotifyEmailConfig builds an email.Config wired to Charon's existing +// mail service, preserving the exact subject-prefix and template-selection +// behavior of the old dispatchEmail/emailTemplateForEventType logic (§3.1.3 / +// §3.6 step 3 of the extraction spec): +// - SubjectPrefix is "[Charon Alert] ", matching the old +// fmt.Sprintf("[Charon Alert] %s", safeTitle) subject format exactly +// (email.Client.Send builds the subject as SubjectPrefix + msg.Title). +// - TemplateName wraps emailTemplateForEventType (kept in Charon per +// §3.1.3 — Charon's own event-type -> HTML template name mapping is +// product logic, not engine logic). +// - Renderer/Mailer wrap Charon's existing mailService via the two +// adapters above, so email dispatch keeps using Charon's five branded +// HTML templates and real SMTP transport — never the extracted module's +// neutral built-in default. +func NewNotifyEmailConfig(mailService MailServiceInterface, recipients []string) email.Config { + return email.Config{ + Recipients: recipients, + SubjectPrefix: "[Charon Alert] ", + TemplateName: func(msg notify.Message) string { + return emailTemplateForEventType(msg.EventType) + }, + Renderer: &mailServiceTemplateRendererAdapter{mailService: mailService}, + Mailer: &mailServiceMailerAdapter{mailService: mailService}, + } +} diff --git a/backend/internal/services/notify_email_adapter_test.go b/backend/internal/services/notify_email_adapter_test.go new file mode 100644 index 000000000..8748ee0e4 --- /dev/null +++ b/backend/internal/services/notify_email_adapter_test.go @@ -0,0 +1,212 @@ +package services + +import ( + "context" + "fmt" + "testing" + "time" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/email" +) + +// fakeMailServiceForEmailAdapter is a dedicated MailServiceInterface fake for +// notify_email_adapter.go's tests. It captures every RenderNotificationEmail +// call's templateName + EmailTemplateData (unlike notification_service_test.go's +// mockMailService, which discards the templateName argument) so tests here +// can assert the exact template-name/field wiring the adapter produces. +type fakeMailServiceForEmailAdapter struct { + isConfigured bool + + sendCalls []struct { + to []string + subject string + body string + } + sendErr error + + renderCalls []struct { + templateName string + data EmailTemplateData + } + renderResult string + renderErr error +} + +func (f *fakeMailServiceForEmailAdapter) IsConfigured() bool { return f.isConfigured } + +func (f *fakeMailServiceForEmailAdapter) SendEmail(_ context.Context, to []string, subject, htmlBody string) error { + f.sendCalls = append(f.sendCalls, struct { + to []string + subject string + body string + }{to: to, subject: subject, body: htmlBody}) + return f.sendErr +} + +func (f *fakeMailServiceForEmailAdapter) RenderNotificationEmail(templateName string, data EmailTemplateData) (string, error) { + f.renderCalls = append(f.renderCalls, struct { + templateName string + data EmailTemplateData + }{templateName: templateName, data: data}) + if f.renderErr != nil { + return "", f.renderErr + } + return f.renderResult, nil +} + +func TestMailServiceMailerAdapterDelegatesSend(t *testing.T) { + fake := &fakeMailServiceForEmailAdapter{} + adapter := &mailServiceMailerAdapter{mailService: fake} + + err := adapter.Send(context.Background(), []string{"a@example.com", "b@example.com"}, "subject line", "

body

") + if err != nil { + t.Fatalf("Send returned error: %v", err) + } + + if len(fake.sendCalls) != 1 { + t.Fatalf("expected 1 SendEmail call, got %d", len(fake.sendCalls)) + } + call := fake.sendCalls[0] + if call.subject != "subject line" || call.body != "

body

" { + t.Fatalf("unexpected call: %+v", call) + } + if len(call.to) != 2 || call.to[0] != "a@example.com" || call.to[1] != "b@example.com" { + t.Fatalf("unexpected recipients: %v", call.to) + } +} + +func TestMailServiceMailerAdapterPropagatesError(t *testing.T) { + wantErr := fmt.Errorf("smtp exploded") + fake := &fakeMailServiceForEmailAdapter{sendErr: wantErr} + adapter := &mailServiceMailerAdapter{mailService: fake} + + err := adapter.Send(context.Background(), []string{"a@example.com"}, "s", "b") + if err == nil { + t.Fatal("expected an error to be propagated") + } +} + +func TestMailServiceMailerAdapterNilMailServiceErrors(t *testing.T) { + adapter := &mailServiceMailerAdapter{mailService: nil} + + if err := adapter.Send(context.Background(), []string{"a@example.com"}, "s", "b"); err == nil { + t.Fatal("expected an error when mail service is not configured") + } +} + +func TestMailServiceTemplateRendererAdapterDelegatesRender(t *testing.T) { + fake := &fakeMailServiceForEmailAdapter{renderResult: "rendered"} + adapter := &mailServiceTemplateRendererAdapter{mailService: fake} + + ts := time.Date(2026, 8, 15, 9, 30, 0, 0, time.UTC) + msg := notify.Message{Title: "Cert expiring", Body: "example.com expires soon", EventType: "cert", Timestamp: ts} + + html, err := adapter.Render("email_ssl_event.html", msg) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + if html != "rendered" { + t.Fatalf("unexpected html: %q", html) + } + + if len(fake.renderCalls) != 1 { + t.Fatalf("expected 1 RenderNotificationEmail call, got %d", len(fake.renderCalls)) + } + call := fake.renderCalls[0] + if call.templateName != "email_ssl_event.html" { + t.Fatalf("templateName = %q, want %q", call.templateName, "email_ssl_event.html") + } + if call.data.EventType != "cert" || call.data.Title != "Cert expiring" || call.data.Message != "example.com expires soon" { + t.Fatalf("unexpected EmailTemplateData: %+v", call.data) + } + if call.data.Timestamp != ts.Format(time.RFC3339) { + t.Fatalf("Timestamp = %q, want %q", call.data.Timestamp, ts.Format(time.RFC3339)) + } +} + +func TestMailServiceTemplateRendererAdapterPropagatesError(t *testing.T) { + fake := &fakeMailServiceForEmailAdapter{renderErr: fmt.Errorf("template missing")} + adapter := &mailServiceTemplateRendererAdapter{mailService: fake} + + _, err := adapter.Render("missing.html", notify.Message{}) + if err == nil { + t.Fatal("expected an error to be propagated") + } +} + +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..8b70c611c --- /dev/null +++ b/backend/internal/services/notify_provider_adapter.go @@ -0,0 +1,173 @@ +package services + +import ( + "fmt" + "strings" + + 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" +) + +// 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 +} + +// buildNotifySender maps a GORM models.NotificationProvider row into the +// matching extracted-module provider Config and constructs the +// corresponding notify.Sender, per §3.6 step 3 of the extraction spec. w is +// the single shared *transport.Wrapper built by NewNotifyTransportWrapper +// (notify_client_adapter.go), injected into every HTTP-based provider +// package. +// +// 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): +// - discord: WebhookURL <- provider.URL +// - slack: WebhookURL <- 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: UserKey <- provider.URL, APIToken <- provider.Token +// (matching the old code's `jsonPayload["user"] = p.URL` / +// `decryptedToken := p.Token`); BaseURL left empty, 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: BotToken <- provider.Token, ChatID <- provider.URL (matching +// the old code's `decryptedToken := p.Token` / +// `jsonPayload["chat_id"] = p.URL`); BaseURL left empty, 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 +// +// Returns an error for any provider.Type not supported by the extracted +// module. 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). +func buildNotifySender(provider models.NotificationProvider, w *transport.Wrapper) (notify.Sender, error) { + tmpl, customTemplate := resolveTemplateFields(provider) + + switch strings.ToLower(strings.TrimSpace(provider.Type)) { + case "discord": + return discord.New(discord.Config{ + WebhookURL: provider.URL, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "slack": + return slack.New(slack.Config{ + WebhookURL: provider.Token, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "gotify": + return gotify.New(gotify.Config{ + URL: provider.URL, + Token: provider.Token, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "pushover": + return pushover.New(pushover.Config{ + UserKey: provider.URL, + APIToken: provider.Token, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "ntfy": + return ntfy.New(ntfy.Config{ + URL: provider.URL, + Token: provider.Token, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "telegram": + return telegram.New(telegram.Config{ + BotToken: provider.Token, + ChatID: provider.URL, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + case "webhook", "generic": + return webhook.New(webhook.Config{ + URL: provider.URL, + Template: tmpl, + CustomTemplate: customTemplate, + }, w), nil + + default: + return nil, fmt.Errorf("notify provider adapter: unsupported provider type %q", provider.Type) + } +} 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..c67299631 --- /dev/null +++ b/backend/internal/services/notify_provider_adapter_test.go @@ -0,0 +1,412 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "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 200 OK +// response. It lets tests assert on the exact HTTP request a provider +// package builds 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 +} + +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) + + return &http.Response{ + StatusCode: http.StatusOK, + 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 provider type") + } +} + +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"]) + } +} From f037ba0b1596a91b007770995941ceec1a410ab9 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 00:51:49 +0000 Subject: [PATCH 04/18] feat: cut over Discord notifications to extracted notify module Discord dispatch now routes through buildNotifySender/transport.Wrapper (the extracted go_notify_yourself module) instead of the legacy sendJSONPayload path. This is a deliberate behavior change, not just a refactor: today's Discord dispatch bypasses the old HTTPWrapper entirely (direct network/security calls, no retry/backoff). Discord notifications now retry on transient failures, consistent with every other provider. SendExternal and TestProvider route Discord through a shared dispatchViaNotify/testProviderViaNotify seam, gated by a notifyMigratedProviderTypes allowlist that will grow one provider at a time as the rest of the migration lands. The notify.Message sent for Discord carries HostName/HostIP/ServiceCount/Services under Data so a provider configured with the old "detailed" template keeps rendering via legacyDetailedTemplate's backward-compat shape. Tests exercising Discord dispatch now inject a capturing fake RoundTripper via a new WithNotifyTransportWrapper test option, since the extracted module's own Discord webhook validation only accepts discord.com/canary.discord.com hosts and can no longer be pointed at an httptest.Server the way pre-cutover tests were. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 104 +++++ .../notification_service_json_test.go | 75 ++-- .../services/notification_service_test.go | 372 ++++++++---------- .../services/notify_provider_adapter_test.go | 23 +- 4 files changed, 318 insertions(+), 256 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index c48ed6d6f..ae44bac68 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -14,6 +14,9 @@ import ( "text/template" "time" + notify "github.com/Wikid82/go_notify_yourself" + "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" @@ -28,12 +31,24 @@ import ( type NotificationService struct { DB *gorm.DB httpWrapper *notifications.HTTPWrapper + notifyWrapper *transport.Wrapper mailService MailServiceInterface telegramAPIBaseURL string pushoverAPIBaseURL string validateSlackURL func(string) error } +// notifyMigratedProviderTypes lists the notification provider types whose +// dispatch has been cut over from the legacy sendJSONPayload path to the +// extracted notify module (buildNotifySender, notify_provider_adapter.go). +// It is extended one provider type at a time as each commit in the +// extraction migration lands (docs/plans/notifications_extraction_spec.md +// §6). Provider types not yet listed here keep dispatching through the +// legacy sendJSONPayload/dispatchEmail path unchanged. +var notifyMigratedProviderTypes = map[string]bool{ + "discord": true, +} + // NotificationServiceOption configures a NotificationService at construction time. type NotificationServiceOption func(*NotificationService) @@ -45,10 +60,23 @@ func WithSlackURLValidator(fn func(string) error) NotificationServiceOption { } } +// WithNotifyTransportWrapper overrides the *transport.Wrapper used to +// dispatch notifications for provider types listed in +// notifyMigratedProviderTypes. 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", @@ -271,6 +299,12 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title continue } go func(p models.NotificationProvider) { + pType := strings.ToLower(strings.TrimSpace(p.Type)) + if notifyMigratedProviderTypes[pType] { + s.dispatchViaNotify(ctx, p, eventType, title, message, data) + return + } + if !supportsJSONTemplates(p.Type) { logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime") return @@ -283,6 +317,55 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } } +// 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 a provider type that has +// been cut over from the legacy sendJSONPayload path to the extracted +// notify module (buildNotifySender, notify_provider_adapter.go). It builds +// a notify.Message from the same source data sendJSONPayload used +// (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") + } +} + // 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. @@ -710,6 +793,10 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return fmt.Errorf("provider type %q does not support JSON templates", providerType) } + if notifyMigratedProviderTypes[providerType] { + return s.testProviderViaNotify(provider) + } + data := map[string]any{ "Title": "Test Notification", "Message": "This is a test notification from Charon", @@ -721,6 +808,23 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return s.sendJSONPayload(context.Background(), provider, data) } +// testProviderViaNotify sends a test notification through a provider type +// that has been cut over from the legacy sendJSONPayload path to 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) + } + + 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. func (s *NotificationService) TestEmailProvider(provider models.NotificationProvider) error { diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 3403b5595..578dd039f 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "net/url" "strings" - "sync/atomic" "testing" "time" @@ -426,29 +425,24 @@ func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) { 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,50 +450,47 @@ 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) + + _, body := rt.last() + require.NotNil(t, body) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotNil(t, payload["content"]) } func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) { diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 89a085e7e..89dc589ce 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -16,6 +16,8 @@ import ( "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" @@ -128,65 +130,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 +182,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") } } @@ -529,27 +503,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) @@ -724,45 +688,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"]) }) } @@ -1594,24 +1565,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,16 +1592,18 @@ 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) } }) } @@ -1715,23 +1677,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 +1692,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,17 +1798,21 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { } } +// TestTestProvider_DiscordUsesNotifyPathInPR1 verifies Discord dispatches +// through the extracted notify module (buildNotifySender/transport.Wrapper) +// rather than the legacy sendJSONPayload path — webhookDoRequestFunc (the +// legacy path's HTTP hook) is deliberately left untouched here and must NOT +// be invoked. func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) - serverCalled := atomic.Bool{} + legacyPathCalled := 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 + legacyPathCalled.Store(true) + return client.Do(req) } defer func() { webhookDoRequestFunc = originalDo }() @@ -1869,39 +1824,44 @@ func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { err := svc.TestProvider(provider) require.NoError(t, err) - assert.True(t, serverCalled.Load(), "discord provider should use JSON webhook path") + assert.False(t, legacyPathCalled.Load(), "discord provider should no longer use the legacy sendJSONPayload path") + + req, body := rt.last() + require.NotNil(t, req, "discord provider should dispatch through the notify module") + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) } 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 +1871,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) }) } diff --git a/backend/internal/services/notify_provider_adapter_test.go b/backend/internal/services/notify_provider_adapter_test.go index c67299631..8c15ed6de 100644 --- a/backend/internal/services/notify_provider_adapter_test.go +++ b/backend/internal/services/notify_provider_adapter_test.go @@ -24,15 +24,15 @@ import ( ) // capturingRoundTripper is a fake http.RoundTripper that records every -// outbound request (method, URL, headers, body) and returns a canned 200 OK -// response. It lets tests assert on the exact HTTP request a provider -// package builds without hitting any real network destination — including -// providers like pushover/telegram whose dispatch URL is hardcoded to a -// production API host. +// 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 + mu sync.Mutex + requests []*http.Request + bodies [][]byte + statusCode int } func (c *capturingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -47,8 +47,13 @@ func (c *capturingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er 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: http.StatusOK, + StatusCode: status, Body: io.NopCloser(bytes.NewReader(nil)), Header: make(http.Header), }, nil From 5d3e68a95f5484541072978f75a5ac9e65209453 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 00:58:44 +0000 Subject: [PATCH 05/18] feat: cut over Slack notifications to extracted notify module Slack dispatch now routes through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord in notifyMigratedProviderTypes. Field mapping matches the old sendJSONPayload branch exactly: the Slack webhook URL comes from provider.Token (provider.URL remains an unused placeholder), so no provider-facing behavior changes. Tests exercising Slack dispatch now inject a capturing fake RoundTripper via WithNotifyTransportWrapper and use a real hooks.slack.com-shaped webhook URL, since the extracted module's own Slack webhook validation enforces the same URL shape Charon's service-level validator did and can no longer be pointed at an httptest.Server via a validator override. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 1 + .../services/notification_service_test.go | 67 ++++++++++--------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index ae44bac68..e7a7527d7 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -47,6 +47,7 @@ type NotificationService struct { // legacy sendJSONPayload/dispatchEmail path unchanged. var notifyMigratedProviderTypes = map[string]bool{ "discord": true, + "slack": true, } // NotificationServiceOption configures a NotificationService at construction time. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 89dc589ce..b34e557e2 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -3259,54 +3259,54 @@ func TestNotificationService_UpdateProvider_Slack_PreservesToken(t *testing.T) { assert.Equal(t, "https://hooks.slack.com/services/T00000/B00000/xxxx", update.Token) } +// TestNotificationService_TestProvider_Slack verifies TestProvider dispatch +// after Slack's cutover to the extracted notify module +// (buildNotifySender). Slack's own webhook validation +// (providers/slack.ValidateWebhookURL) only accepts hooks.slack.com URLs +// matching the standard incoming-webhook shape — the same shape Charon's +// own validateSlackWebhookURL already enforced — so an httptest.Server URL +// (as used before cutover, via a WithSlackURLValidator override that only +// gated the old service-level check) can no longer stand in for a Slack +// webhook. A capturing fake RoundTripper via WithNotifyTransportWrapper +// replaces it, matching the pattern used for Discord's tests. func TestNotificationService_TestProvider_Slack(t *testing.T) { db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) - var capturedBody []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedBody, _ = io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - })) - defer server.Close() - - svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - - provider := models.NotificationProvider{ + provider := models.NotificationProvider{ //nolint:gosec // G101: test credential Type: "slack", URL: "#test", - Token: server.URL, + Token: "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", Template: "minimal", } 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(capturedBody, &payload)) + require.NoError(t, json.Unmarshal(body, &payload)) assert.NotEmpty(t, payload["text"]) } +// TestNotificationService_SendExternal_Slack is the SendExternal +// counterpart of TestNotificationService_TestProvider_Slack — see its +// comment for why a capturing fake RoundTripper replaces the old +// httptest.Server. func TestNotificationService_SendExternal_Slack(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) - received := make(chan []byte, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - received <- body - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - })) - defer server.Close() - - svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) - provider := models.NotificationProvider{ + provider := models.NotificationProvider{ //nolint:gosec // G101: test credential Name: "Slack E2E", Type: "slack", URL: "#alerts", - Token: server.URL, + Token: "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", Enabled: true, NotifyProxyHosts: true, Template: "minimal", @@ -3315,14 +3315,15 @@ func TestNotificationService_SendExternal_Slack(t *testing.T) { svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) - select { - case body := <-received: - var payload map[string]any - require.NoError(t, json.Unmarshal(body, &payload)) - assert.NotEmpty(t, payload["text"]) - case <-time.After(2 * time.Second): - t.Fatal("Timed out waiting for slack webhook") - } + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, 2*time.Second, 10*time.Millisecond, "Timed out waiting for slack webhook") + + _, body := rt.last() + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotEmpty(t, payload["text"]) } func TestNotificationService_Slack_PayloadNormalizesMessageToText(t *testing.T) { From fd535d7747453e61da0801512a435ddf986a2b95 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:05:04 +0000 Subject: [PATCH 06/18] feat: cut over Gotify notifications to extracted notify module Gotify dispatch now routes through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord and Slack in notifyMigratedProviderTypes. Field mapping is unchanged from the old sendJSONPayload branch: URL from provider.URL, token sent as X-Gotify-Key when non-empty. Two TestProvider tests that dispatch to a local httptest.Server now set CHARON_ENV=test explicitly, since the extracted module's transport wrapper gates plain-HTTP/localhost dispatch on that env var rather than the old implicit os.Args[0]-based test-binary detection. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- backend/internal/services/notification_service.go | 1 + backend/internal/services/notification_service_test.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index e7a7527d7..981991e84 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -48,6 +48,7 @@ type NotificationService struct { var notifyMigratedProviderTypes = map[string]bool{ "discord": true, "slack": true, + "gotify": true, } // NotificationServiceOption configures a NotificationService at construction time. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index b34e557e2..38bf6110d 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2361,6 +2361,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) @@ -2401,6 +2407,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) From 6ef17a9c9c36bb6a3649175b0259ab63a798825a Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:11:46 +0000 Subject: [PATCH 07/18] feat: cut over Pushover notifications to extracted notify module Pushover dispatch now routes through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord, Slack, and Gotify in notifyMigratedProviderTypes. Field mapping matches the old sendJSONPayload branch: user key from provider.URL, API token from provider.Token, injected into the payload's token/user fields server-side after template rendering (same anti-injection behavior as before). The extracted module's Config leaves BaseURL empty, so dispatch targets Pushover's real production API exactly as before (the old svc.pushoverAPIBaseURL test-only override doesn't apply to this path); new tests use a capturing fake RoundTripper to verify the notify-path dispatch without a real network call. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 7 +- .../services/notification_service_test.go | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 981991e84..5ca3c04db 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -46,9 +46,10 @@ type NotificationService struct { // §6). Provider types not yet listed here keep dispatching through the // legacy sendJSONPayload/dispatchEmail path unchanged. var notifyMigratedProviderTypes = map[string]bool{ - "discord": true, - "slack": true, - "gotify": true, + "discord": true, + "slack": true, + "gotify": true, + "pushover": true, } // NotificationServiceOption configures a NotificationService at construction time. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 38bf6110d..72aacf306 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -3822,6 +3822,71 @@ func TestIsDispatchEnabled_PushoverDisabledByFlag(t *testing.T) { assert.False(t, svc.isDispatchEnabled("pushover")) } +// TestNotificationService_TestProvider_PushoverUsesNotifyPath verifies +// Pushover TestProvider dispatch after cutover to the extracted notify +// module (buildNotifySender). buildNotifySender leaves pushover.Config's +// BaseURL empty (notify_provider_adapter.go), so — unlike the old +// svc.pushoverAPIBaseURL test seam — dispatch always targets Pushover's +// real production API; a capturing fake RoundTripper (via +// WithNotifyTransportWrapper) intercepts before any real network call, +// same as the Discord/Slack notify-path tests. +func TestNotificationService_TestProvider_PushoverUsesNotifyPath(t *testing.T) { + db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + + provider := models.NotificationProvider{ + Type: "pushover", + Token: "app-token-abc", + URL: "user-key-xyz", + Template: "minimal", + } + + err := svc.TestProvider(provider) + require.NoError(t, err) + + req, body := rt.last() + require.NotNil(t, req) + assert.Equal(t, "https://api.pushover.net/1/messages.json", req.URL.String()) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "app-token-abc", payload["token"]) + assert.Equal(t, "user-key-xyz", payload["user"]) +} + +// TestNotificationService_SendExternal_PushoverUsesNotifyPath is the +// SendExternal counterpart of +// TestNotificationService_TestProvider_PushoverUsesNotifyPath. +func TestNotificationService_SendExternal_PushoverUsesNotifyPath(t *testing.T) { + db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + + provider := models.NotificationProvider{ + Name: "Pushover E2E", + Type: "pushover", + Token: "app-token-abc", + URL: "user-key-xyz", + Enabled: true, + NotifyProxyHosts: true, + Template: "minimal", + } + require.NoError(t, svc.CreateProvider(&provider)) + + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) + + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "expected pushover webhook to be sent") + + _, body := rt.last() + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "app-token-abc", payload["token"]) + assert.Equal(t, "user-key-xyz", payload["user"]) +} + func TestPushoverDispatch_DefaultBaseURL(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db, nil) From 6805d102e8431edd0f5fe0f666cf0abda23440bc Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:18:10 +0000 Subject: [PATCH 08/18] feat: cut over Ntfy notifications to extracted notify module Ntfy dispatch now routes through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord, Slack, Gotify, and Pushover in notifyMigratedProviderTypes. Field mapping is unchanged from the old sendJSONPayload branch: URL from provider.URL, token sent as an "Authorization: Bearer " header when non-empty. New TestProvider/SendExternal tests dispatch to a local httptest.Server with CHARON_ENV=test set explicitly, since the extracted module's transport wrapper gates plain-HTTP dispatch on that env var rather than the old implicit os.Args[0]-based test-binary detection. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 1 + .../services/notification_service_test.go | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 5ca3c04db..a10ab16a0 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -50,6 +50,7 @@ var notifyMigratedProviderTypes = map[string]bool{ "slack": true, "gotify": true, "pushover": true, + "ntfy": true, } // NotificationServiceOption configures a NotificationService at construction time. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 72aacf306..e69f6ec6a 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -3922,6 +3922,78 @@ func TestIsSupportedNotificationProviderType_Ntfy(t *testing.T) { assert.True(t, isSupportedNotificationProviderType(" ntfy ")) } +// TestNotificationService_TestProvider_NtfyUsesNotifyPath verifies Ntfy +// TestProvider dispatch after cutover to the extracted notify module +// (buildNotifySender). Unlike Discord/Slack, Ntfy has no provider-side +// hostname allowlist, so it can still dispatch to a local httptest.Server — +// but that server is plain HTTP, which the extracted module's transport +// wrapper only allows when CHARON_ENV=test is set explicitly (see +// resolveNotifyAllowHTTP in notify_client_adapter.go), replacing the old +// implicit os.Args[0]-based test-binary detection. +func TestNotificationService_TestProvider_NtfyUsesNotifyPath(t *testing.T) { + t.Setenv("CHARON_ENV", "test") + + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "ntfy", + URL: server.URL, + Token: "ntfy-token", + Template: "minimal", + } + + err := svc.TestProvider(provider) + require.NoError(t, err) + assert.Equal(t, "Bearer ntfy-token", capturedAuth) +} + +// TestNotificationService_SendExternal_NtfyUsesNotifyPath is the +// SendExternal counterpart of +// TestNotificationService_TestProvider_NtfyUsesNotifyPath. +func TestNotificationService_SendExternal_NtfyUsesNotifyPath(t *testing.T) { + t.Setenv("CHARON_ENV", "test") + + received := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + received <- body + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Name: "Ntfy E2E", + Type: "ntfy", + URL: server.URL, + Enabled: true, + NotifyProxyHosts: true, + Template: "minimal", + } + require.NoError(t, svc.CreateProvider(&provider)) + + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) + + select { + case body := <-received: + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotEmpty(t, payload["message"]) + case <-time.After(time.Second): + t.Fatal("Timed out waiting for ntfy webhook") + } +} + func TestIsDispatchEnabled_NtfyDefaultTrue(t *testing.T) { db := setupNotificationTestDB(t) _ = db.AutoMigrate(&models.Setting{}) From 9c1bf6792f327fa24836899e5847fa96cc28e8bf Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:24:42 +0000 Subject: [PATCH 09/18] feat: cut over Telegram notifications to extracted notify module Telegram dispatch now routes through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining Discord, Slack, Gotify, Pushover, and Ntfy in notifyMigratedProviderTypes. Field mapping matches the old sendJSONPayload branch: bot token from provider.Token (embedded in the dispatch URL path, not a header), chat ID from provider.URL injected into the payload's chat_id field after template rendering. The extracted module's Config leaves BaseURL empty, so dispatch targets the real Telegram Bot API exactly as before (the old svc.telegramAPIBaseURL test-only override doesn't apply to this path); new tests use a capturing fake RoundTripper to verify the notify-path dispatch without a real network call. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 1 + .../services/notification_service_test.go | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index a10ab16a0..cfa32bf66 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -51,6 +51,7 @@ var notifyMigratedProviderTypes = map[string]bool{ "gotify": true, "pushover": true, "ntfy": true, + "telegram": true, } // NotificationServiceOption configures a NotificationService at construction time. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index e69f6ec6a..d9ba37b95 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -3150,6 +3150,69 @@ func TestIsDispatchEnabled_TelegramDisabledByFlag(t *testing.T) { assert.False(t, svc.isDispatchEnabled("telegram")) } +// TestNotificationService_TestProvider_TelegramUsesNotifyPath verifies +// Telegram TestProvider dispatch after cutover to the extracted notify +// module (buildNotifySender). buildNotifySender leaves telegram.Config's +// BaseURL empty (notify_provider_adapter.go), so — unlike the old +// svc.telegramAPIBaseURL test seam — dispatch always targets the real +// Telegram Bot API; a capturing fake RoundTripper (via +// WithNotifyTransportWrapper) intercepts before any real network call, +// same as the Discord/Slack/Pushover notify-path tests. +func TestNotificationService_TestProvider_TelegramUsesNotifyPath(t *testing.T) { + db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + + provider := models.NotificationProvider{ //nolint:gosec // G101: test credential + Type: "telegram", + URL: "123456789", + Token: "fake-bot-token", + Template: "minimal", + } + + err := svc.TestProvider(provider) + require.NoError(t, err) + + req, body := rt.last() + require.NotNil(t, req) + assert.Equal(t, "https://api.telegram.org/botfake-bot-token/sendMessage", req.URL.String()) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "123456789", payload["chat_id"]) +} + +// TestNotificationService_SendExternal_TelegramUsesNotifyPath is the +// SendExternal counterpart of +// TestNotificationService_TestProvider_TelegramUsesNotifyPath. +func TestNotificationService_SendExternal_TelegramUsesNotifyPath(t *testing.T) { + db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + + provider := models.NotificationProvider{ //nolint:gosec // G101: test credential + Name: "Telegram E2E", + Type: "telegram", + URL: "123456789", + Token: "fake-bot-token", + Enabled: true, + NotifyProxyHosts: true, + Template: "minimal", + } + require.NoError(t, svc.CreateProvider(&provider)) + + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) + + require.Eventually(t, func() bool { + _, body := rt.last() + return body != nil + }, time.Second, 10*time.Millisecond, "expected telegram webhook to be sent") + + _, body := rt.last() + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "123456789", payload["chat_id"]) +} + // --- Slack Notification Provider Tests --- func TestSlackWebhookURLValidation(t *testing.T) { From 1b11227f7d43f6b75981bca8df5c0f7398371753 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:32:41 +0000 Subject: [PATCH 10/18] feat: cut over generic webhook notifications to extracted notify module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook and generic-webhook dispatch now route through buildNotifySender/transport.Wrapper via the shared dispatchViaNotify/testProviderViaNotify seam, joining every other provider type except email in notifyMigratedProviderTypes. CreateProvider and UpdateProvider's custom-template preview validation now calls providers/webhook.RenderPreview instead of the old RenderTemplate, so the preview payload matches the same Title/Message/Time/EventType/Data shape actual dispatch uses — a custom template referencing {{index .Data "..."}} now validates correctly at save time instead of failing against a flat map that had no Data field. RenderTemplate itself is untouched and still backs the provider/template preview API handlers, which are out of scope for this cutover. Three TestProvider tests that dispatch to a local httptest.Server now set CHARON_ENV=test explicitly, matching the earlier Gotify/Ntfy commits' rationale: the extracted module's transport wrapper gates plain-HTTP dispatch on that env var rather than the old implicit os.Args[0]-based test-binary detection. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 22 +++++++++++++------ .../notification_service_discord_only_test.go | 6 +++++ .../services/notification_service_test.go | 8 +++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index cfa32bf66..de12eb6a0 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -15,6 +15,7 @@ import ( "time" notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/webhook" "github.com/Wikid82/go_notify_yourself/transport" "github.com/Wikid82/charon/backend/internal/logger" @@ -52,6 +53,8 @@ var notifyMigratedProviderTypes = map[string]bool{ "pushover": true, "ntfy": true, "telegram": true, + "webhook": true, + "generic": true, } // NotificationServiceOption configures a NotificationService at construction time. @@ -972,11 +975,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) } } @@ -1018,10 +1025,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_test.go b/backend/internal/services/notification_service_test.go index d9ba37b95..f5ce0463b 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2387,6 +2387,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) @@ -2434,6 +2438,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) From c073f4b2bcaad0ba49953dc3eac9f87dadb8631c Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:42:16 +0000 Subject: [PATCH 11/18] feat: cut over email notifications to extracted notify module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendExternal's email branch and TestEmailProvider now dispatch through providers/email (the extracted notify module's email package) via NewNotifyEmailConfig (notify_email_adapter.go) instead of calling the mail service directly. dispatchEmail itself is left in place, unused by production code after this commit — cleanup is a separate, later commit. TestEmailProvider builds its own inline email.Config (reusing the same Renderer/Mailer adapters) because its test-send subject prefix ("[Charon Test] ") and forced "email_system_event.html" template differ from NewNotifyEmailConfig's production values. This is a deliberate, documented behavior change: dispatchEmail's old fallback — building a manual plain HTML body and still sending when template rendering fails — does not exist in the extracted module. email.Client.Send aborts before calling Mailer.Send when the configured Renderer returns an error, so a broken/missing email template now fails the notification (or test send) closed instead of degrading gracefully to a generic fallback body. Tests that relied on the old fallback path were rewritten to assert the new fail-closed behavior. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 116 +++++++++++++++--- .../services/notification_service_test.go | 30 +++-- 2 files changed, 119 insertions(+), 27 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index de12eb6a0..0cee5d1bc 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -15,6 +15,7 @@ import ( "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" @@ -303,7 +304,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) { @@ -448,6 +449,64 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi } } +// dispatchEmailViaNotify sends an email notification through the extracted +// notify module's email package (NewNotifyEmailConfig, notify_email_adapter.go) +// instead of the legacy dispatchEmail path above. It runs in a goroutine; +// all errors are logged rather than returned. +// +// Behavior note: unlike dispatchEmail, which falls back to a manually built +// plain HTML body when template rendering fails (still sending the +// notification), the extracted module's email.Client.Send has no such +// fallback — mailServiceTemplateRendererAdapter.Render (notify_email_adapter.go) +// returns the render error directly, and Send aborts without calling Mailer.Send +// at all. A provider with a broken/missing email template now fails closed +// (the notification is not sent) rather than degrading gracefully. This is a +// deliberate consequence of the extracted module's design (§3.3.4 of the +// extraction spec: the module never has its own fallback rendering baked into +// the dispatch path), not an oversight — see this migration's PR notes for the +// full rationale. +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 + } + + recipients := parseEmailRecipients(p.URL) + if len(recipients) == 0 { + logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Email provider has no recipients configured") + return + } + + client := email.New(NewNotifyEmailConfig(s.mailService, recipients)) + + msg := notify.Message{ + Title: title, + Body: message, + EventType: eventType, + } + + timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + 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 dispatchEmail, +// 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": @@ -833,35 +892,54 @@ func (s *NotificationService) testProviderViaNotify(provider models.Notification 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 no longer falls back to a manually built plain HTML body when +// template rendering fails; a broken/missing template now fails the test +// send outright instead of silently succeeding with a generic fallback +// body. 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. diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index f5ce0463b..23b67d63a 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2648,7 +2648,11 @@ 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{ @@ -2736,7 +2740,8 @@ func TestSendExternal_EmailProviderDoesNotCallSendJSONPayload(t *testing.T) { db := setupNotificationTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) - mock := &mockMailService{isConfigured: true} + // renderResult must be set — see TestSendExternal_EmailProvider_Dispatches's comment. + mock := &mockMailService{isConfigured: true, renderResult: "

rendered

"} svc := NewNotificationService(db, mock) // Track any JSON payload call via the webhook hook @@ -2915,7 +2920,7 @@ func TestEmailProvider_BlankWhitespaceURL(t *testing.T) { func TestEmailProvider_ValidRecipient(t *testing.T) { db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true} + mock := &mockMailService{isConfigured: true, renderResult: "

rendered

"} svc := NewNotificationService(db, mock) p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "user@example.com"} @@ -2929,7 +2934,7 @@ func TestEmailProvider_ValidRecipient(t *testing.T) { func TestEmailProvider_MultipleRecipients(t *testing.T) { db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true} + mock := &mockMailService{isConfigured: true, renderResult: "

rendered

"} svc := NewNotificationService(db, mock) p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com, c@d.com , e@f.com"} @@ -2941,7 +2946,7 @@ func TestEmailProvider_MultipleRecipients(t *testing.T) { func TestEmailProvider_SendError(t *testing.T) { db := setupNotificationTestDB(t) - mock := &mockMailService{isConfigured: true, sendEmailErr: fmt.Errorf("smtp: connection refused")} + mock := &mockMailService{isConfigured: true, renderResult: "

rendered

", sendEmailErr: fmt.Errorf("smtp: connection refused")} svc := NewNotificationService(db, mock) p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} @@ -2951,6 +2956,15 @@ func TestEmailProvider_SendError(t *testing.T) { assert.Equal(t, 1, mock.callCount()) } +// TestEmailProvider_TemplateFallback previously verified that a template +// rendering failure fell back to a manually built plain HTML body and still +// sent the email. Since TestEmailProvider's cutover to the extracted notify +// module's email package (providers/email), that fallback no longer exists: +// mailServiceTemplateRendererAdapter.Render (notify_email_adapter.go) +// returns the render error directly, and email.Client.Send aborts before +// ever calling Mailer.Send. This test now verifies that fail-closed +// behavior instead — see TestEmailProvider's doc comment for the full +// rationale. func TestEmailProvider_TemplateFallback(t *testing.T) { db := setupNotificationTestDB(t) mock := &mockMailService{isConfigured: true, renderErr: fmt.Errorf("template not found")} @@ -2958,9 +2972,9 @@ func TestEmailProvider_TemplateFallback(t *testing.T) { p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} err := svc.TestEmailProvider(p) - require.NoError(t, err) - require.Equal(t, 1, mock.callCount()) - assert.Contains(t, mock.firstCall().body, "Test Notification") + require.Error(t, err) + assert.Contains(t, err.Error(), "template not found") + assert.Zero(t, mock.callCount(), "SendEmail must not be called when template rendering fails") } func TestEmailProvider_UsesRenderedTemplate(t *testing.T) { From e83b563a09a97ab04c171e2da56ecc0e32c96254 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 01:53:45 +0000 Subject: [PATCH 12/18] fix: preserve email fallback delivery on template render failure The extracted notify module's email.Client.Send aborts before calling Mailer.Send whenever the configured TemplateRenderer returns an error, so the email cutover to providers/email (commit c073f4b2) silently started failing dispatch closed on any template-render failure, instead of degrading gracefully like pre-extraction dispatchEmail did. Fix lives entirely in mailServiceTemplateRendererAdapter.Render (notify_email_adapter.go): on a RenderNotificationEmail failure it now logs a warning and returns a locally-composed plain-HTML fallback body (fallbackEmailBody) instead of propagating the error, restoring the old fail-open-with-degraded-body behavior for both dispatchEmailViaNotify and TestEmailProvider without touching the extracted module or any other already-migrated provider. Real Mailer/SMTP transport failures are unaffected and still propagate as errors. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- .../internal/services/notification_service.go | 25 ++++------ .../services/notification_service_test.go | 48 ++++++++++++++----- .../internal/services/notify_email_adapter.go | 44 ++++++++++++++++- .../services/notify_email_adapter_test.go | 42 ++++++++++++++-- 4 files changed, 127 insertions(+), 32 deletions(-) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 0cee5d1bc..50f7f1c75 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -454,17 +454,12 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi // instead of the legacy dispatchEmail path above. It runs in a goroutine; // all errors are logged rather than returned. // -// Behavior note: unlike dispatchEmail, which falls back to a manually built -// plain HTML body when template rendering fails (still sending the -// notification), the extracted module's email.Client.Send has no such -// fallback — mailServiceTemplateRendererAdapter.Render (notify_email_adapter.go) -// returns the render error directly, and Send aborts without calling Mailer.Send -// at all. A provider with a broken/missing email template now fails closed -// (the notification is not sent) rather than degrading gracefully. This is a -// deliberate consequence of the extracted module's design (§3.3.4 of the -// extraction spec: the module never has its own fallback rendering baked into -// the dispatch path), not an oversight — see this migration's PR notes for the -// full rationale. +// Behavior note: like dispatchEmail, 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") @@ -906,10 +901,10 @@ func (s *NotificationService) testProviderViaNotify(provider models.Notification // TestEmailProvider's hardcoded subject/template exactly. // // Behavior note: see dispatchEmailViaNotify's comment — like that path, -// this no longer falls back to a manually built plain HTML body when -// template rendering fails; a broken/missing template now fails the test -// send outright instead of silently succeeding with a generic fallback -// body. +// 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") diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 23b67d63a..8848c8cfa 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2956,15 +2956,13 @@ func TestEmailProvider_SendError(t *testing.T) { assert.Equal(t, 1, mock.callCount()) } -// TestEmailProvider_TemplateFallback previously verified that a template -// rendering failure fell back to a manually built plain HTML body and still -// sent the email. Since TestEmailProvider's cutover to the extracted notify -// module's email package (providers/email), that fallback no longer exists: -// mailServiceTemplateRendererAdapter.Render (notify_email_adapter.go) -// returns the render error directly, and email.Client.Send aborts before -// ever calling Mailer.Send. This test now verifies that fail-closed -// behavior instead — see TestEmailProvider's doc comment for the full -// rationale. +// TestEmailProvider_TemplateFallback verifies that a template rendering +// failure falls back to a manually built plain HTML body and the email is +// still sent — matching pre-extraction dispatchEmail behavior. The fallback +// now lives in mailServiceTemplateRendererAdapter.Render +// (notify_email_adapter.go), which never propagates a render error to +// email.Client.Send; it logs a warning and returns a degraded-but-nonempty +// body instead, so Send proceeds to Mailer.Send as normal. func TestEmailProvider_TemplateFallback(t *testing.T) { db := setupNotificationTestDB(t) mock := &mockMailService{isConfigured: true, renderErr: fmt.Errorf("template not found")} @@ -2972,9 +2970,35 @@ func TestEmailProvider_TemplateFallback(t *testing.T) { p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} err := svc.TestEmailProvider(p) - require.Error(t, err) - assert.Contains(t, err.Error(), "template not found") - assert.Zero(t, mock.callCount(), "SendEmail must not be called when template rendering fails") + require.NoError(t, err, "email must still be sent when template rendering fails") + require.Equal(t, 1, mock.callCount(), "SendEmail must still be called with a fallback body") + + call := mock.firstCall() + assert.Contains(t, call.body, "Test Notification") + assert.Contains(t, call.body, "This is a test notification from Charon. If you received this email, your email notification provider is configured correctly.") +} + +// TestEmailProvider_TransportFailureStillErrors confirms that a genuine +// Mailer/SMTP transport failure (as opposed to a template-render failure) +// still correctly propagates as an error from TestEmailProvider, and is not +// silently swallowed by the template-render fallback added to +// mailServiceTemplateRendererAdapter.Render. Template rendering succeeds +// here — only SendEmail (the transport step) fails. +func TestEmailProvider_TransportFailureStillErrors(t *testing.T) { + db := setupNotificationTestDB(t) + mock := &mockMailService{ + isConfigured: true, + renderResult: "

rendered

", + sendEmailErr: fmt.Errorf("smtp: connection refused"), + } + svc := NewNotificationService(db, mock) + + p := models.NotificationProvider{Name: "test-email", Type: "email", URL: "a@b.com"} + err := svc.TestEmailProvider(p) + require.Error(t, err, "a real transport failure must still be reported as an error") + assert.Contains(t, err.Error(), "smtp") + require.Equal(t, 1, mock.callCount()) + assert.Equal(t, "

rendered

", mock.firstCall().body, "transport failure must not be confused with a render failure") } func TestEmailProvider_UsesRenderedTemplate(t *testing.T) { diff --git a/backend/internal/services/notify_email_adapter.go b/backend/internal/services/notify_email_adapter.go index 08603237b..c489688d5 100644 --- a/backend/internal/services/notify_email_adapter.go +++ b/backend/internal/services/notify_email_adapter.go @@ -3,10 +3,14 @@ package services import ( "context" "fmt" + "html" + "strings" "time" notify "github.com/Wikid82/go_notify_yourself" "github.com/Wikid82/go_notify_yourself/providers/email" + + "github.com/Wikid82/charon/backend/internal/logger" ) // mailServiceMailerAdapter implements email.Mailer by delegating to Charon's @@ -35,6 +39,20 @@ func (a *mailServiceMailerAdapter) Send(ctx context.Context, recipients []string // onto Charon's EmailTemplateData shape. The extracted module's own neutral // default template is intentionally never used in production — Charon always // supplies this renderer (§3.3.4's required-override tradeoff). +// +// Render deliberately never returns an error for a template-rendering +// failure: email.Client.Send (providers/email/email.go) aborts before ever +// calling Mailer.Send if Renderer.Render returns an error, which would leave +// a broken/missing HTML template silently dropping notifications. That is +// not an approved behavior change from pre-extraction Charon, where +// dispatchEmail degraded to a manually built plain-HTML body and still sent +// the email. So on a RenderNotificationEmail failure, Render logs a warning +// and returns fallbackEmailBody's plain-HTML rendering instead of an error — +// restoring the old fail-open-with-degraded-body behavior entirely within +// this adapter, without needing error-type introspection across the +// extracted module's boundary. Real Mailer/SMTP transport failures are +// unaffected by this — those still occur (and propagate as errors) later, +// in mailServiceMailerAdapter.Send. type mailServiceTemplateRendererAdapter struct { mailService MailServiceInterface } @@ -55,11 +73,35 @@ func (a *mailServiceTemplateRendererAdapter) Render(templateName string, msg not htmlBody, err := a.mailService.RenderNotificationEmail(templateName, data) if err != nil { - return "", fmt.Errorf("notify email adapter: render template: %w", err) + logger.Log().WithError(err).WithField("template", templateName). + Warn("Email template rendering failed, using fallback body") + return fallbackEmailBody(msg.Title, msg.Body), nil } return htmlBody, nil } +// fallbackEmailBody builds a minimal plain-HTML email body from a message's +// title/body when template rendering fails, matching the exact fallback +// format the old (pre-extraction) dispatchEmail used. By the time Render is +// called, email.Client.Send has already run msg.Normalized() and its own +// sanitizeForEmail over msg.Title/msg.Body, stripping control characters — +// so this only needs to HTML-escape them before embedding. +func fallbackEmailBody(title, body string) string { + var b strings.Builder + if title != "" { + b.WriteString("") + b.WriteString(html.EscapeString(title)) + b.WriteString("") + } + if body != "" { + if b.Len() > 0 { + b.WriteString("
") + } + b.WriteString(html.EscapeString(body)) + } + return b.String() +} + // NewNotifyEmailConfig builds an email.Config wired to Charon's existing // mail service, preserving the exact subject-prefix and template-selection // behavior of the old dispatchEmail/emailTemplateForEventType logic (§3.1.3 / diff --git a/backend/internal/services/notify_email_adapter_test.go b/backend/internal/services/notify_email_adapter_test.go index 8748ee0e4..7c27bb375 100644 --- a/backend/internal/services/notify_email_adapter_test.go +++ b/backend/internal/services/notify_email_adapter_test.go @@ -125,13 +125,47 @@ func TestMailServiceTemplateRendererAdapterDelegatesRender(t *testing.T) { } } -func TestMailServiceTemplateRendererAdapterPropagatesError(t *testing.T) { +// TestMailServiceTemplateRendererAdapterFallsBackOnRenderError verifies the +// regression fix: Render must never propagate a template-rendering error to +// its caller (email.Client.Send aborts before Mailer.Send if it did — see +// Render's doc comment). Instead it should log a warning and return a +// locally-composed plain-HTML fallback body built from msg.Title/msg.Body, +// preserving pre-extraction dispatchEmail's fail-open behavior. +func TestMailServiceTemplateRendererAdapterFallsBackOnRenderError(t *testing.T) { fake := &fakeMailServiceForEmailAdapter{renderErr: fmt.Errorf("template missing")} adapter := &mailServiceTemplateRendererAdapter{mailService: fake} - _, err := adapter.Render("missing.html", notify.Message{}) - if err == nil { - t.Fatal("expected an error to be propagated") + msg := notify.Message{Title: "Cert expiring", Body: "example.com expires soon"} + got, err := adapter.Render("missing.html", msg) + if err != nil { + t.Fatalf("Render must not return an error on template-render failure, got: %v", err) + } + if got != "Cert expiring
example.com expires soon" { + t.Fatalf("unexpected fallback body: %q", got) + } +} + +// TestFallbackEmailBodyEscapesHTML confirms the fallback body HTML-escapes +// title/body content, since it bypasses the normal template-rendering path +// that would otherwise handle escaping. +func TestFallbackEmailBodyEscapesHTML(t *testing.T) { + got := fallbackEmailBody(``, ``) + 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) } } From 8bb9553ba1bd2c8d576f249ff2b3663a229a462c Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 15 Aug 2026 02:16:29 +0000 Subject: [PATCH 13/18] refactor: remove extracted notification engine code from Charon The notification delivery engine (HTTP dispatch wrapper, per-provider payload/validation/template logic, and unused engine/router scaffolding) now lives in the external github.com/Wikid82/go_notify_yourself module, consumed via the Charon-side adapters added in earlier commits on this branch. This commit removes the now-dead in-repo copy of that logic: - Deletes internal/notifications/ in full (http_wrapper, http_client_executor, engine, router + tests) now that every provider type dispatches through the extracted module's adapters. - Folds internal/notifications/feature_flags.go's Setting-table key constants into internal/services (Charon policy, not engine logic) so the notifications package can be removed entirely rather than left behind as a single-file package. - Removes the now-unreachable legacy JSON-payload dispatch path (sendJSONPayload and its per-provider validation/header/dispatch-URL helpers, the old dispatchEmail/sanitizeForEmail path, and the dead isPrivateIP wrapper) from notification_service.go, along with the test coverage that exercised those functions directly. Validation logic still reachable from live CRUD/test-send code paths (Discord/Slack URL validation, the template-preview endpoint) is kept unchanged. - Deletes the old engine-level integration test, superseded by the DI-seam-level coverage already added alongside the transport adapter. Claude-Session: https://claude.ai/code/session_01VeiFv1TDjzjnxbjbyNuSQX --- ...ification_http_wrapper_integration_test.go | 124 -- backend/internal/notifications/engine.go | 22 - .../notifications/http_client_executor.go | 7 - .../internal/notifications/http_wrapper.go | 540 ------ .../notifications/http_wrapper_test.go | 1001 ---------- backend/internal/notifications/router.go | 37 - backend/internal/notifications/router_test.go | 142 -- .../internal/services/coverage_boost_test.go | 13 - .../notification_feature_flags.go} | 9 +- .../internal/services/notification_service.go | 508 +---- .../notification_service_json_test.go | 625 ------ .../services/notification_service_test.go | 1695 +---------------- 12 files changed, 61 insertions(+), 4662 deletions(-) delete mode 100644 backend/integration/notification_http_wrapper_integration_test.go delete mode 100644 backend/internal/notifications/engine.go delete mode 100644 backend/internal/notifications/http_client_executor.go delete mode 100644 backend/internal/notifications/http_wrapper.go delete mode 100644 backend/internal/notifications/http_wrapper_test.go delete mode 100644 backend/internal/notifications/router.go delete mode 100644 backend/internal/notifications/router_test.go rename backend/internal/{notifications/feature_flags.go => services/notification_feature_flags.go} (63%) 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 50f7f1c75..5f480c4ef 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -5,9 +5,7 @@ import ( "context" "encoding/json" "fmt" - "html" "net" - "net/http" neturl "net/url" "regexp" "strings" @@ -20,10 +18,6 @@ import ( "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" @@ -32,7 +26,6 @@ import ( type NotificationService struct { DB *gorm.DB - httpWrapper *notifications.HTTPWrapper notifyWrapper *transport.Wrapper mailService MailServiceInterface telegramAPIBaseURL string @@ -40,24 +33,6 @@ type NotificationService struct { validateSlackURL func(string) error } -// notifyMigratedProviderTypes lists the notification provider types whose -// dispatch has been cut over from the legacy sendJSONPayload path to the -// extracted notify module (buildNotifySender, notify_provider_adapter.go). -// It is extended one provider type at a time as each commit in the -// extraction migration lands (docs/plans/notifications_extraction_spec.md -// §6). Provider types not yet listed here keep dispatching through the -// legacy sendJSONPayload/dispatchEmail path unchanged. -var notifyMigratedProviderTypes = map[string]bool{ - "discord": true, - "slack": true, - "gotify": true, - "pushover": true, - "ntfy": true, - "telegram": true, - "webhook": true, - "generic": true, -} - // NotificationServiceOption configures a NotificationService at construction time. type NotificationServiceOption func(*NotificationService) @@ -70,11 +45,11 @@ func WithSlackURLValidator(fn func(string) error) NotificationServiceOption { } // WithNotifyTransportWrapper overrides the *transport.Wrapper used to -// dispatch notifications for provider types listed in -// notifyMigratedProviderTypes. 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. +// 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 @@ -84,7 +59,6 @@ func WithNotifyTransportWrapper(w *transport.Wrapper) NotificationServiceOption 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", @@ -97,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": {}, @@ -113,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 { @@ -187,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 } @@ -308,20 +268,11 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title continue } go func(p models.NotificationProvider) { - pType := strings.ToLower(strings.TrimSpace(p.Type)) - if notifyMigratedProviderTypes[pType] { - s.dispatchViaNotify(ctx, p, eventType, title, message, data) - return - } - if !supportsJSONTemplates(p.Type) { 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) } } @@ -347,10 +298,9 @@ func notifyMessageDataFromLegacyFlatMap(data map[string]any) map[string]any { } } -// dispatchViaNotify sends a notification through a provider type that has -// been cut over from the legacy sendJSONPayload path to the extracted -// notify module (buildNotifySender, notify_provider_adapter.go). It builds -// a notify.Message from the same source data sendJSONPayload used +// 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 @@ -375,86 +325,11 @@ func (s *NotificationService) dispatchViaNotify(ctx context.Context, p models.No } } -// 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) -} - -// dispatchEmail sends an email notification for the given provider. -// 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) { - 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) - } - } - - 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) - - templateName := emailTemplateForEventType(eventType) - data := EmailTemplateData{ - 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 { - logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification") - } -} - // dispatchEmailViaNotify sends an email notification through the extracted -// notify module's email package (NewNotifyEmailConfig, notify_email_adapter.go) -// instead of the legacy dispatchEmail path above. It runs in a goroutine; -// all errors are logged rather than returned. +// notify module's email package (NewNotifyEmailConfig, notify_email_adapter.go). +// It runs in a goroutine; all errors are logged rather than returned. // -// Behavior note: like dispatchEmail, a template-rendering failure still +// 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 @@ -489,8 +364,8 @@ func (s *NotificationService) dispatchEmailViaNotify(ctx context.Context, p mode } // parseEmailRecipients splits a NotificationProvider's comma-separated URL -// field into a trimmed, non-empty recipient list. Shared by dispatchEmail, -// dispatchEmailViaNotify, and TestEmailProvider's notify-path counterpart. +// 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)) @@ -515,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) { @@ -855,24 +408,11 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return fmt.Errorf("provider type %q does not support JSON templates", providerType) } - if notifyMigratedProviderTypes[providerType] { - return s.testProviderViaNotify(provider) - } - - 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.sendJSONPayload(context.Background(), provider, data) + return s.testProviderViaNotify(provider) } -// testProviderViaNotify sends a test notification through a provider type -// that has been cut over from the legacy sendJSONPayload path to the -// extracted notify module (buildNotifySender, notify_provider_adapter.go). +// 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 { diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 578dd039f..8f44924d6 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -3,10 +3,6 @@ package services import ( "context" "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" "testing" "time" @@ -42,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) @@ -83,348 +53,6 @@ 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) @@ -492,256 +120,3 @@ func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { require.NoError(t, json.Unmarshal(body, &payload)) assert.NotNil(t, payload["content"]) } - -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") -} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 8848c8cfa..459db15ae 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -5,12 +5,10 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "net/http/httptest" "os" "path/filepath" - "strings" "sync" "sync/atomic" "testing" @@ -19,9 +17,7 @@ import ( "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" @@ -292,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) @@ -583,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) @@ -799,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) @@ -939,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) @@ -1144,73 +721,6 @@ 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) @@ -1342,231 +852,25 @@ 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 - } +func TestSendExternal_AllEventTypes(t *testing.T) { + eventTypes := []struct { + eventType string + providerField string + }{ + {"proxy_host", "NotifyProxyHosts"}, + {"remote_server", "NotifyRemoteServers"}, + {"domain", "NotifyDomains"}, + {"cert", "NotifyCerts"}, + {"uptime", "NotifyUptime"}, + {"test", ""}, // test always sends + {"unknown", ""}, // unknown defaults to false (security-first) + } - // 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 - providerField string - }{ - {"proxy_host", "NotifyProxyHosts"}, - {"remote_server", "NotifyRemoteServers"}, - {"domain", "NotifyDomains"}, - {"cert", "NotifyCerts"}, - {"uptime", "NotifyUptime"}, - {"test", ""}, // test always sends - {"unknown", ""}, // unknown defaults to false (security-first) - } - - for _, et := range eventTypes { - t.Run(et.eventType, func(t *testing.T) { - db := setupNotificationTestDB(t) - wrapper, rt := newCapturingWrapper() - svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) + for _, et := range eventTypes { + t.Run(et.eventType, func(t *testing.T) { + db := setupNotificationTestDB(t) + wrapper, rt := newCapturingWrapper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) provider := models.NotificationProvider{ Name: "event-test", @@ -1609,35 +913,6 @@ func TestSendExternal_AllEventTypes(t *testing.T) { } } -// ============================================ -// 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 @@ -1798,41 +1073,6 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { } } -// TestTestProvider_DiscordUsesNotifyPathInPR1 verifies Discord dispatches -// through the extracted notify module (buildNotifySender/transport.Wrapper) -// rather than the legacy sendJSONPayload path — webhookDoRequestFunc (the -// legacy path's HTTP hook) is deliberately left untouched here and must NOT -// be invoked. -func TestTestProvider_DiscordUsesNotifyPathInPR1(t *testing.T) { - db := setupNotificationTestDB(t) - wrapper, rt := newCapturingWrapper() - svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(wrapper)) - - legacyPathCalled := atomic.Bool{} - originalDo := webhookDoRequestFunc - webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { - legacyPathCalled.Store(true) - return client.Do(req) - } - defer func() { webhookDoRequestFunc = originalDo }() - - provider := models.NotificationProvider{ - Type: "discord", - URL: "https://discord.com/api/webhooks/123456789/token_abc", - Template: "minimal", - } - - err := svc.TestProvider(provider) - require.NoError(t, err) - assert.False(t, legacyPathCalled.Load(), "discord provider should no longer use the legacy sendJSONPayload path") - - req, body := rt.last() - require.NotNil(t, req, "discord provider should dispatch through the notify module") - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - var payload map[string]any - require.NoError(t, json.Unmarshal(body, &payload)) -} - func TestTestProvider_HTTPURLValidation(t *testing.T) { db := setupNotificationTestDB(t) @@ -1881,90 +1121,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) @@ -2054,44 +1210,6 @@ func TestSendExternal_JSONPayloadError(t *testing.T) { time.Sleep(100 * time.Millisecond) } -func TestSendJSONPayload_HTTPScheme(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) @@ -2290,7 +1408,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) { @@ -2307,7 +1425,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")) @@ -2316,7 +1434,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{}) @@ -2324,7 +1443,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) @@ -2541,7 +1660,7 @@ func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { } } -// --- mockMailService for dispatchEmail tests --- +// --- mockMailService for email dispatch/test-provider tests --- type mockMailService struct { mu sync.Mutex @@ -2586,64 +1705,6 @@ 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{})) @@ -2663,7 +1724,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) @@ -2687,7 +1748,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) @@ -2695,188 +1756,9 @@ func TestSendExternal_EmailProvider_FlagDisabled(t *testing.T) { assert.Zero(t, mock.callCount()) } -func TestDispatchEmail_InvalidRecipient(t *testing.T) { +func TestEmailProvider_MailServiceNil(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{})) - - // renderResult must be set — see TestSendExternal_EmailProvider_Dispatches's comment. - mock := &mockMailService{isConfigured: true, renderResult: "

rendered

"} - 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, "