mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.
- POST/GET/DELETE /notifications/channels + POST .../:id/test which
pushes a real probe so misconfiguration surfaces before alerts depend
on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
the mobile notifications tab (add/test/remove).
Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81 lines
2.3 KiB
Go
81 lines
2.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"containr/internal/database/sqlcdb"
|
|
)
|
|
|
|
func TestDeliverPushNtfy(t *testing.T) {
|
|
var gotAuth, gotTitle, gotTags, gotBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotTitle = r.Header.Get("Title")
|
|
gotTags = r.Header.Get("Tags")
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ch := sqlcdb.NotificationChannel{
|
|
Kind: "ntfy",
|
|
Endpoint: srv.URL + "/alerts",
|
|
Token: sql.NullString{String: "tk_abc", Valid: true},
|
|
}
|
|
if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil {
|
|
t.Fatalf("deliverPush: %v", err)
|
|
}
|
|
if gotAuth != "Bearer tk_abc" {
|
|
t.Fatalf("auth header = %q", gotAuth)
|
|
}
|
|
if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" {
|
|
t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody)
|
|
}
|
|
}
|
|
|
|
func TestDeliverPushGotify(t *testing.T) {
|
|
var gotPath, gotToken string
|
|
var payload map[string]interface{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotToken = r.URL.Query().Get("token")
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ch := sqlcdb.NotificationChannel{
|
|
Kind: "gotify",
|
|
Endpoint: srv.URL,
|
|
Token: sql.NullString{String: "apptoken", Valid: true},
|
|
}
|
|
if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil {
|
|
t.Fatalf("deliverPush: %v", err)
|
|
}
|
|
if gotPath != "/message" || gotToken != "apptoken" {
|
|
t.Fatalf("path=%q token=%q", gotPath, gotToken)
|
|
}
|
|
if payload["title"] != "Backup done" || payload["message"] != "db backup ok" {
|
|
t.Fatalf("payload = %v", payload)
|
|
}
|
|
}
|
|
|
|
func TestDeliverPushFailure(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL}
|
|
if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil {
|
|
t.Fatal("expected error on 500")
|
|
}
|
|
}
|