http: /settings/notifications CRUD + test endpoint
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
|
"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/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/config"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
"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
|
// host-online events from the WS handler. Nil until G1 constructs
|
||||||
// the engine at boot.
|
// the engine at boot.
|
||||||
AlertEngine *alert.Engine
|
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.
|
// Version is the binary's build version, surfaced in the chrome.
|
||||||
// Empty falls back to "dev".
|
// Empty falls back to "dev".
|
||||||
Version string
|
Version string
|
||||||
@@ -202,6 +206,10 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
|
|
||||||
// Alert list (JSON variant). Same filter shape as the UI page.
|
// Alert list (JSON variant). Same filter shape as the UI page.
|
||||||
r.Get("/alerts", s.handleAPIAlerts)
|
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
|
// 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.Get("/alerts", s.handleUIAlerts)
|
||||||
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
|
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
|
||||||
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
|
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
|
// Browser job-log stream (separate from /ws/agent so the auth
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 := "<nil>"
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user