diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 743c404..14385b2 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -15,6 +15,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/alert" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/notification" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" @@ -34,6 +35,9 @@ type Deps struct { // host-online events from the WS handler. Nil until G1 constructs // the engine at boot. AlertEngine *alert.Engine + // NotificationHub (optional, wired in G1) is used by the test-fire + // endpoint to dispatch a single synthetic payload through a channel. + NotificationHub *notification.Hub // Version is the binary's build version, surfaced in the chrome. // Empty falls back to "dev". Version string @@ -202,6 +206,10 @@ func (s *Server) routes(r chi.Router) { // Alert list (JSON variant). Same filter shape as the UI page. r.Get("/alerts", s.handleAPIAlerts) + + // Notification channel test-fire. Dispatches a synthetic payload + // through a single named channel; returns JSON result. + r.Post("/notifications/{id}/test", s.handleAPINotificationTest) }) // HTMX form variant of diff (mounted outside /api so HTMX forms @@ -309,6 +317,14 @@ func (s *Server) routes(r chi.Router) { r.Get("/alerts", s.handleUIAlerts) r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) + // Settings shell + Notifications sub-tab CRUD. + r.Get("/settings", s.handleUISettings) + r.Get("/settings/notifications", s.handleUINotificationsList) + r.Get("/settings/notifications/new", s.handleUINotificationNewGet) + r.Post("/settings/notifications/new", s.handleUINotificationNewPost) + r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet) + r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost) + r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete) } // Browser job-log stream (separate from /ws/agent so the auth diff --git a/internal/server/http/ui_notifications.go b/internal/server/http/ui_notifications.go new file mode 100644 index 0000000..8df3619 --- /dev/null +++ b/internal/server/http/ui_notifications.go @@ -0,0 +1,704 @@ +// ui_notifications.go — HTML form-driven handlers for the notification +// channel CRUD at /settings/notifications and the test-fire endpoint at +// POST /api/notifications/{id}/test. +// +// The settings shell currently has a single sub-tab (Notifications); +// the structure is designed to be extended with Users/Auth tabs later. +// +// Routes (wired in server.go): +// +// GET /settings → handleUISettings +// GET /settings/notifications → handleUINotificationsList +// GET /settings/notifications/new → handleUINotificationNewGet +// POST /settings/notifications/new → handleUINotificationNewPost +// GET /settings/notifications/{id}/edit → handleUINotificationEditGet +// POST /settings/notifications/{id}/edit → handleUINotificationEditPost +// POST /settings/notifications/{id}/delete → handleUINotificationDelete +// POST /api/notifications/{id}/test → handleAPINotificationTest +package http + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + stdhttp "net/http" + "net/mail" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/notification" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// ── page models ────────────────────────────────────────────────────────────── + +// settingsPage is the data fed to the settings shell template. The +// sub-tab body is embedded via the Channels slice so a single template +// layout works for both the list and the edit form. +type settingsPage struct { + // ActiveTab is the settings sub-tab currently visible. + ActiveTab string + // Channels is the full list (list sub-tab). + Channels []store.NotificationChannel + // Form is populated when the operator is creating or editing a channel. + Form *notificationForm + // FormError is an inline error message for the channel form. + FormError string + // DeleteError is an inline error shown on the confirm-delete form. + DeleteError string +} + +// notificationForm holds the round-trip values for the channel +// create/edit form. Separate per-kind sub-structs mirror the template +// field groups; all fields are strings so the template never has to +// handle nil. +type notificationForm struct { + // ID is the channel's ULID; empty for new. + ID string + Kind string // webhook | ntfy | smtp + Name string + // Enabled maps to the enabled checkbox. + Enabled bool + // DefaultPriority applies to ntfy channels. + DefaultPriority string + + // Webhook sub-fields. + WebhookURL string + WebhookBearerToken string + WebhookHeaderName string + WebhookHeaderValue string + + // Ntfy sub-fields. + NtfyServerURL string + NtfyTopic string + NtfyAccessToken string + + // SMTP sub-fields. + SMTPHost string + SMTPPort string // string for form round-trip; validated to int on save + SMTPEncryption string + SMTPUsername string + // SMTPPassword is a write-only field: shown as placeholder on edit; + // blank on submit means "keep the stored value". + SMTPPassword string + SMTPFrom string + SMTPTo string +} + +// ── internal helpers ────────────────────────────────────────────────────────── + +// loadSettingsPage fetches the channel list and returns the base page model. +func (s *Server) loadSettingsPage(r *stdhttp.Request) (*settingsPage, error) { + chans, err := s.deps.Store.ListNotificationChannels(r.Context()) + if err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + return &settingsPage{ + ActiveTab: "notifications", + Channels: chans, + }, nil +} + +// renderSettingsPage renders the settings shell, setting HTTP 422 on +// validation failure (pass status=0 for the normal 200). +func (s *Server) renderSettingsPage(w stdhttp.ResponseWriter, _ *stdhttp.Request, u *ui.User, page *settingsPage, status int) { + view := s.baseView(u) + view.Title = "Settings · restic-manager" + view.Active = "settings" + view.Page = *page + if status != 0 { + w.WriteHeader(status) + } + if err := s.deps.UI.Render(w, "settings", view); err != nil { + slog.Error("ui: render settings", "err", err) + } +} + +// encryptChannelConfig JSON-encodes cfg and AEAD-seals it with the +// channel-specific additional-data binding. +func (s *Server) encryptChannelConfig(id string, cfg any) ([]byte, error) { + plain, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + enc, err := s.deps.AEAD.Encrypt(plain, []byte("notification-channel:"+id)) + if err != nil { + return nil, fmt.Errorf("encrypt config: %w", err) + } + return []byte(enc), nil +} + +// decryptChannelConfig decrypts the AEAD blob and unmarshals it into dst. +func (s *Server) decryptChannelConfig(ch store.NotificationChannel, dst any) error { + plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+ch.ID)) + if err != nil { + return fmt.Errorf("decrypt: %w", err) + } + return json.Unmarshal(plain, dst) +} + +// formFromRequest parses the common + per-kind fields from a POST form. +// The caller must have already called r.ParseForm(). +func formFromRequest(r *stdhttp.Request) *notificationForm { + f := ¬ificationForm{ + Kind: strings.TrimSpace(r.PostForm.Get("kind")), + Name: strings.TrimSpace(r.PostForm.Get("name")), + Enabled: r.PostForm.Get("enabled") == "1", + DefaultPriority: strings.TrimSpace(r.PostForm.Get("default_priority")), + + WebhookURL: strings.TrimSpace(r.PostForm.Get("webhook_url")), + WebhookBearerToken: r.PostForm.Get("webhook_bearer_token"), + WebhookHeaderName: strings.TrimSpace(r.PostForm.Get("webhook_header_name")), + WebhookHeaderValue: r.PostForm.Get("webhook_header_value"), + + NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")), + NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")), + NtfyAccessToken: r.PostForm.Get("ntfy_access_token"), + + SMTPHost: strings.TrimSpace(r.PostForm.Get("smtp_host")), + SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")), + SMTPEncryption: strings.TrimSpace(r.PostForm.Get("smtp_encryption")), + SMTPUsername: strings.TrimSpace(r.PostForm.Get("smtp_username")), + SMTPPassword: r.PostForm.Get("smtp_password"), + SMTPFrom: strings.TrimSpace(r.PostForm.Get("smtp_from")), + SMTPTo: strings.TrimSpace(r.PostForm.Get("smtp_to")), + } + if f.Kind == "" { + f.Kind = "webhook" + } + return f +} + +// validateForm validates the common + per-kind fields. Returns a +// non-empty string on the first validation error found. +func validateForm(f *notificationForm) string { + if f.Name == "" { + return "Name is required." + } + if len(f.Name) > 100 { + return "Name must be 100 characters or fewer." + } + switch f.Kind { + case "webhook": + if f.WebhookURL == "" { + return "Webhook URL is required." + } + u, err := url.Parse(f.WebhookURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "Webhook URL must be a valid http(s) URL." + } + case "ntfy": + if f.NtfyServerURL != "" { + u, err := url.Parse(f.NtfyServerURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "Ntfy server URL must be a valid http(s) URL." + } + } + if f.NtfyTopic == "" { + return "Ntfy topic is required." + } + case "smtp": + if f.SMTPHost == "" { + return "SMTP host is required." + } + port, err := strconv.Atoi(f.SMTPPort) + if err != nil || port < 1 || port > 65535 { + return "SMTP port must be a number between 1 and 65535." + } + switch f.SMTPEncryption { + case "starttls", "tls", "none": + default: + return "SMTP encryption must be starttls, tls, or none." + } + if f.SMTPFrom == "" { + return "SMTP From address is required." + } + if _, err := mail.ParseAddress(f.SMTPFrom); err != nil { + return "SMTP From is not a valid email address." + } + if f.SMTPTo == "" { + return "SMTP To address is required." + } + if _, err := mail.ParseAddress(f.SMTPTo); err != nil { + return "SMTP To is not a valid email address." + } + default: + return "Kind must be webhook, ntfy, or smtp." + } + return "" +} + +// buildConfig constructs the per-kind notification config struct from f. +// For edit (existing != nil), blank password fields fall back to the +// stored value so the operator can save other fields without re-typing +// the credential. +func buildConfig(f *notificationForm, existing any) (any, error) { + switch f.Kind { + case "webhook": + cfg := notification.WebhookConfig{ + URL: f.WebhookURL, + BearerToken: f.WebhookBearerToken, + HeaderName: f.WebhookHeaderName, + HeaderValue: f.WebhookHeaderValue, + } + if existing != nil { + ex, ok := existing.(*notification.WebhookConfig) + if ok && cfg.BearerToken == "" { + cfg.BearerToken = ex.BearerToken + } + } + return cfg, nil + + case "ntfy": + cfg := notification.NtfyConfig{ + ServerURL: f.NtfyServerURL, + Topic: f.NtfyTopic, + AccessToken: f.NtfyAccessToken, + } + if existing != nil { + ex, ok := existing.(*notification.NtfyConfig) + if ok && cfg.AccessToken == "" { + cfg.AccessToken = ex.AccessToken + } + } + return cfg, nil + + case "smtp": + port, _ := strconv.Atoi(f.SMTPPort) + cfg := notification.SMTPConfig{ + Host: f.SMTPHost, + Port: port, + Encryption: f.SMTPEncryption, + Username: f.SMTPUsername, + Password: f.SMTPPassword, + From: f.SMTPFrom, + To: f.SMTPTo, + } + if existing != nil { + ex, ok := existing.(*notification.SMTPConfig) + if ok && cfg.Password == "" { + cfg.Password = ex.Password + } + } + return cfg, nil + } + return nil, fmt.Errorf("unknown kind %q", f.Kind) +} + +// ── UI handlers ─────────────────────────────────────────────────────────────── + +// handleUISettings renders the settings shell (defaults to the +// Notifications sub-tab in v1). +func (s *Server) handleUISettings(w stdhttp.ResponseWriter, r *stdhttp.Request) { + s.handleUINotificationsList(w, r) +} + +// handleUINotificationsList renders the channel list under the +// Notifications sub-tab. +func (s *Server) handleUINotificationsList(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + page, err := s.loadSettingsPage(r) + if err != nil { + slog.Error("ui settings: load", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + s.renderSettingsPage(w, r, u, page, 0) +} + +// handleUINotificationNewGet renders the kind picker + empty form. +// The ?kind= query param pre-selects the visible per-kind fields. +func (s *Server) handleUINotificationNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + page, err := s.loadSettingsPage(r) + if err != nil { + slog.Error("ui settings: load", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + kind := r.URL.Query().Get("kind") + if kind == "" { + kind = "webhook" + } + page.Form = ¬ificationForm{Kind: kind} + s.renderSettingsPage(w, r, u, page, 0) +} + +// handleUINotificationNewPost validates and creates a new channel, then +// redirects to the list. Re-renders the form with an error banner on +// validation failure. +func (s *Server) handleUINotificationNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + + f := formFromRequest(r) + if errMsg := validateForm(f); errMsg != "" { + page, _ := s.loadSettingsPage(r) + if page == nil { + page = &settingsPage{ActiveTab: "notifications"} + } + page.Form = f + page.FormError = errMsg + s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) + return + } + + id := ulid.Make().String() + cfg, err := buildConfig(f, nil) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + enc, err := s.encryptChannelConfig(id, cfg) + if err != nil { + slog.Error("ui notifications: encrypt", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + now := time.Now().UTC() + var dp *string + if f.DefaultPriority != "" { + dp = &f.DefaultPriority + } + ch := store.NotificationChannel{ + ID: id, + Kind: f.Kind, + Name: f.Name, + Enabled: f.Enabled, + Config: enc, + DefaultPriority: dp, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.deps.Store.CreateNotificationChannel(r.Context(), ch); err != nil { + slog.Error("ui notifications: create", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "notification_channel.created", + TargetKind: ptr("notification_channel"), + TargetID: &id, + TS: now, + }) + stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) +} + +// handleUINotificationEditGet fetches a channel, decrypts its config, +// and renders the edit form with values pre-filled. +func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + channelID := chi.URLParam(r, "id") + ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui notifications: get", "id", channelID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + f := ¬ificationForm{ + ID: ch.ID, + Kind: ch.Kind, + Name: ch.Name, + Enabled: ch.Enabled, + } + if ch.DefaultPriority != nil { + f.DefaultPriority = *ch.DefaultPriority + } + + switch ch.Kind { + case "webhook": + var cfg notification.WebhookConfig + if err := s.decryptChannelConfig(*ch, &cfg); err == nil { + f.WebhookURL = cfg.URL + // BearerToken and custom headers: don't echo plaintext — shown + // via placeholder text in the template. + f.WebhookHeaderName = cfg.HeaderName + // HeaderValue and BearerToken are write-only — left blank + // so the placeholder "stored, leave blank to keep" shows. + } + case "ntfy": + var cfg notification.NtfyConfig + if err := s.decryptChannelConfig(*ch, &cfg); err == nil { + f.NtfyServerURL = cfg.ServerURL + f.NtfyTopic = cfg.Topic + // AccessToken is write-only. + } + case "smtp": + var cfg notification.SMTPConfig + if err := s.decryptChannelConfig(*ch, &cfg); err == nil { + f.SMTPHost = cfg.Host + f.SMTPPort = strconv.Itoa(cfg.Port) + f.SMTPEncryption = cfg.Encryption + f.SMTPUsername = cfg.Username + // Password is write-only — left blank. + f.SMTPFrom = cfg.From + f.SMTPTo = cfg.To + } + } + + page, err := s.loadSettingsPage(r) + if err != nil { + slog.Error("ui settings: load", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + page.Form = f + s.renderSettingsPage(w, r, u, page, 0) +} + +// handleUINotificationEditPost validates the edit form, merges new +// values onto the existing config (preserving blanked-out secrets), +// re-encrypts, and updates the channel row. +func (s *Server) handleUINotificationEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + channelID := chi.URLParam(r, "id") + ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui notifications: get for edit", "id", channelID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + f := formFromRequest(r) + f.ID = ch.ID + + if errMsg := validateForm(f); errMsg != "" { + page, _ := s.loadSettingsPage(r) + if page == nil { + page = &settingsPage{ActiveTab: "notifications"} + } + page.Form = f + page.FormError = errMsg + s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) + return + } + + // Decrypt existing config so blank password fields can fall back + // to the stored values. + var existingCfg any + switch ch.Kind { + case "webhook": + var cfg notification.WebhookConfig + if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { + existingCfg = &cfg + } + case "ntfy": + var cfg notification.NtfyConfig + if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { + existingCfg = &cfg + } + case "smtp": + var cfg notification.SMTPConfig + if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { + existingCfg = &cfg + } + } + + newCfg, err := buildConfig(f, existingCfg) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + enc, err := s.encryptChannelConfig(ch.ID, newCfg) + if err != nil { + slog.Error("ui notifications: re-encrypt", "id", ch.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + now := time.Now().UTC() + var dp *string + if f.DefaultPriority != "" { + dp = &f.DefaultPriority + } + updated := store.NotificationChannel{ + ID: ch.ID, + Kind: f.Kind, + Name: f.Name, + Enabled: f.Enabled, + Config: enc, + DefaultPriority: dp, + CreatedAt: ch.CreatedAt, + UpdatedAt: now, + } + if err := s.deps.Store.UpdateNotificationChannel(r.Context(), updated); err != nil { + slog.Error("ui notifications: update", "id", ch.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "notification_channel.updated", + TargetKind: ptr("notification_channel"), + TargetID: &ch.ID, + TS: now, + }) + stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) +} + +// handleUINotificationDelete implements the typed-confirm pattern: +// the operator must type the channel name to proceed. On match, +// DeleteNotificationChannel + audit row + redirect. On mismatch, +// re-render with an error. +func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + channelID := chi.URLParam(r, "id") + ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + slog.Error("ui notifications: get for delete", "id", channelID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + confirm := strings.TrimSpace(r.PostForm.Get("confirm_name")) + if confirm != ch.Name { + page, _ := s.loadSettingsPage(r) + if page == nil { + page = &settingsPage{ActiveTab: "notifications"} + } + page.Form = ¬ificationForm{ID: ch.ID, Kind: ch.Kind, Name: ch.Name} + page.DeleteError = "Typed name did not match — deletion aborted." + s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) + return + } + + if err := s.deps.Store.DeleteNotificationChannel(r.Context(), ch.ID); err != nil { + slog.Error("ui notifications: delete", "id", ch.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "notification_channel.deleted", + TargetKind: ptr("notification_channel"), + TargetID: &ch.ID, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) +} + +// ── API handler ─────────────────────────────────────────────────────────────── + +// testResultFragment is the JSON body returned by handleAPINotificationTest. +type testResultFragment struct { + OK bool `json:"ok"` + LatencyMS int `json:"latency_ms"` + StatusCode *int `json:"status_code,omitempty"` + Error *string `json:"error,omitempty"` +} + +// handleAPINotificationTest fires a single synthetic test payload +// through the named channel via Hub.DispatchOne and returns a JSON +// result. The test button in the UI posts here and renders the +// green/red pill from the response. +func (s *Server) handleAPINotificationTest(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + if s.deps.NotificationHub == nil { + writeJSONError(w, stdhttp.StatusServiceUnavailable, "hub_not_ready", + "notification hub not initialised") + return + } + channelID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "not_found", "channel not found") + return + } + slog.Error("api: notification test: get channel", "id", channelID, "err", err) + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + + // AlertID is intentionally left empty for test notifications: the + // notification_log.alert_id column has a FK to alerts.id, and no + // real alert exists for a synthetic test fire. The hub leaves the + // column NULL when AlertID is empty. + payload := notification.Payload{ + Event: notification.EventTest, + Severity: "info", + Kind: "test_notification", + HostName: "(test)", + Message: "Test from restic-manager — channel is working.", + RaisedAt: time.Now().UTC(), + } + + entry, err := s.deps.NotificationHub.DispatchOne(r.Context(), channelID, payload) + if err != nil { + slog.Error("api: notification test: dispatch", "id", channelID, "err", err) + errStr := err.Error() + writeJSON(w, stdhttp.StatusOK, testResultFragment{ + OK: false, + Error: &errStr, + }) + return + } + + res := testResultFragment{OK: entry.OK, StatusCode: entry.StatusCode} + if entry.LatencyMS != nil { + res.LatencyMS = *entry.LatencyMS + } + if entry.Error != nil { + res.Error = entry.Error + } + writeJSON(w, stdhttp.StatusOK, res) +} diff --git a/internal/server/http/ui_notifications_test.go b/internal/server/http/ui_notifications_test.go new file mode 100644 index 0000000..85d84d7 --- /dev/null +++ b/internal/server/http/ui_notifications_test.go @@ -0,0 +1,289 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "io" + stdhttp "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/notification" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// newNotificationTestServer builds a test server wired with a real +// NotificationHub backed by a temporary store. It also inserts a session +// so HTTP calls are authenticated. +func newNotificationTestServer(t *testing.T) (*Server, string, *store.Store, string) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + keyPath := filepath.Join(dir, "secret.key") + _ = crypto.GenerateKeyFile(keyPath) + key, _ := crypto.LoadKeyFromFile(keyPath) + aead, _ := crypto.NewAEAD(key) + + hub := notification.NewHub(st, aead, "http://localhost") + + deps := Deps{ + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, + Store: st, + AEAD: aead, + Hub: ws.NewHub(), + NotificationHub: hub, + BootstrapToken: "test-token", + } + s := New(deps) + ts := httptest.NewServer(s.srv.Handler) + t.Cleanup(ts.Close) + + // Mint a user + session so authenticated routes work. + rawToken, _ := auth.NewToken() + userID := ulid.Make().String() + hash, _ := auth.HashPassword("test-password-long") + _ = st.CreateUser(context.Background(), store.User{ + ID: userID, + Username: "testadmin", + PasswordHash: hash, + Role: store.RoleAdmin, + CreatedAt: time.Now().UTC(), + }) + _ = st.CreateSession(context.Background(), store.Session{ + UserID: userID, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Hour).UTC(), + }, auth.HashToken(rawToken)) + + return s, ts.URL, st, rawToken +} + +// authedClient returns a client + cookie jar that sends the test session cookie. +func authedClient(t *testing.T, rawToken string, baseURL string) *stdhttp.Client { + t.Helper() + jar := &simpleCookieJar{token: rawToken, baseURL: baseURL} + return &stdhttp.Client{Jar: jar} +} + +// simpleCookieJar injects the session cookie on every request to baseURL. +type simpleCookieJar struct { + token string + baseURL string +} + +func (j *simpleCookieJar) SetCookies(_ *url.URL, _ []*stdhttp.Cookie) {} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*stdhttp.Cookie { + if !strings.HasPrefix(u.String(), j.baseURL) { + return nil + } + return []*stdhttp.Cookie{{Name: sessionCookieName, Value: j.token}} +} + +// createTestWebhookChannel inserts a webhook channel into the store +// for the given server's AEAD, targeting sink. +func createTestWebhookChannel(t *testing.T, s *Server, st *store.Store, sink string) string { + t.Helper() + id := "ch-test-" + strings.ReplaceAll(t.Name(), "/", "-") + cfg, _ := json.Marshal(notification.WebhookConfig{URL: sink}) + enc, err := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id)) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + err = st.CreateNotificationChannel(context.Background(), store.NotificationChannel{ + ID: id, + Kind: "webhook", + Name: "test-webhook", + Enabled: true, + Config: []byte(enc), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + return id +} + +// TestAPINotificationTestEndToEnd is the primary plan test: +// configure a webhook channel pointing at an httptest sink, POST the +// test endpoint, assert the synthetic event landed at the sink and a +// notification_log row with event="alert.test" ok=1 was persisted. +func TestAPINotificationTestEndToEnd(t *testing.T) { + t.Parallel() + + // Sink — records incoming request bodies. + var received [][]byte + sink := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + body, _ := io.ReadAll(r.Body) + received = append(received, body) + w.WriteHeader(stdhttp.StatusOK) + })) + defer sink.Close() + + s, baseURL, st, rawToken := newNotificationTestServer(t) + channelID := createTestWebhookChannel(t, s, st, sink.URL) + client := authedClient(t, rawToken, baseURL) + + res, err := client.Post(baseURL+"/api/notifications/"+channelID+"/test", + "application/json", bytes.NewReader(nil)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Fatalf("status %d: %s", res.StatusCode, body) + } + + var result testResultFragment + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + t.Fatalf("decode: %v", err) + } + if !result.OK { + errStr := "" + if result.Error != nil { + errStr = *result.Error + } + t.Fatalf("expected ok=true, got false; error=%s", errStr) + } + + // The sink should have received exactly one request. + if len(received) != 1 { + t.Fatalf("sink: expected 1 request, got %d", len(received)) + } + + // Decode the webhook body and check the event field. + var body map[string]any + if err := json.Unmarshal(received[0], &body); err != nil { + t.Fatalf("decode sink body: %v", err) + } + if body["event"] != string(notification.EventTest) { + t.Errorf("event: got %v, want %s", body["event"], notification.EventTest) + } + + // notification_log should have one row with event=alert.test and ok=1. + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM notification_log + WHERE channel_id = ? AND event = 'alert.test' AND ok = 1`, + channelID, + ).Scan(&n); err != nil { + t.Fatalf("query log: %v", err) + } + if n != 1 { + t.Fatalf("notification_log: expected 1 row, got %d", n) + } +} + +// TestAPINotificationTestNotFound confirms a 404 for an unknown channel. +func TestAPINotificationTestNotFound(t *testing.T) { + t.Parallel() + _, baseURL, _, rawToken := newNotificationTestServer(t) + client := authedClient(t, rawToken, baseURL) + + res, err := client.Post(baseURL+"/api/notifications/no-such-channel/test", + "application/json", bytes.NewReader(nil)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != stdhttp.StatusNotFound { + t.Errorf("expected 404, got %d", res.StatusCode) + } +} + +// TestAPINotificationTestUnauthed confirms a redirect (or 4xx) when +// there is no session cookie. +func TestAPINotificationTestUnauthed(t *testing.T) { + t.Parallel() + _, baseURL, _, _ := newNotificationTestServer(t) + + // Use a client that does NOT follow redirects and has no cookie. + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := client.Post(baseURL+"/api/notifications/any-id/test", + "application/json", bytes.NewReader(nil)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = res.Body.Close() }() + + // requireUIUser redirects to /login for unauthenticated requests. + if res.StatusCode != stdhttp.StatusSeeOther && res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("expected 303 or 401, got %d", res.StatusCode) + } +} + +// TestNotificationCreateAndDelete is a CRUD round-trip exercising +// the store methods. The handler layer would return template errors +// (no templates in tests), so we exercise just the store-level API +// that the handlers call, confirming the plumbing compiles and works. +func TestNotificationCreateAndDelete(t *testing.T) { + t.Parallel() + s, _, st, _ := newNotificationTestServer(t) + + id := "ch-crud-test" + cfg, _ := json.Marshal(notification.WebhookConfig{URL: "https://example.com/hook"}) + enc, _ := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id)) + + now := time.Now().UTC() + err := st.CreateNotificationChannel(context.Background(), store.NotificationChannel{ + ID: id, + Kind: "webhook", + Name: "crud-test", + Enabled: true, + Config: []byte(enc), + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Read it back and decrypt. + ch, err := st.GetNotificationChannel(context.Background(), id) + if err != nil { + t.Fatalf("get: %v", err) + } + var got notification.WebhookConfig + plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+id)) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if err := json.Unmarshal(plain, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.URL != "https://example.com/hook" { + t.Errorf("URL: got %q, want %q", got.URL, "https://example.com/hook") + } + + // Delete. + if err := st.DeleteNotificationChannel(context.Background(), id); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := st.GetNotificationChannel(context.Background(), id); err == nil { + t.Error("expected ErrNotFound after delete") + } +}