Files
notify-gateway/internal/httpserver/outbound_test.go
T
2026-09-16 06:26:16 +02:00

107 lines
3.9 KiB
Go

package httpserver
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/example/notify-gateway/internal/auth"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/divera"
"github.com/example/notify-gateway/internal/gateway"
)
func TestOutboundAdminRoundTripPreviewAndIngress(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
store, err := config.Open(configPath)
if err != nil {
t.Fatal(err)
}
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
h := New(store, gateway.New(store, client), client, log.New(io.Discard, "", 0)).Handler()
c := store.Get()
cookie := &http.Cookie{Name: "ng_session", Value: auth.SignSession(c.Server.SessionSecret, c.Server.AdminUsername, time.Now().Add(time.Hour))}
edit := makeEditable(c)
edit.Outbounds = []config.OutboundConfig{{ID: "discord-ops", Name: "Ops", Provider: "discord", URL: "https://discord.com/api/webhooks/123/secret"}}
edit.Ingress.NtfyTokens = map[string]string{"ops": "ingress-token"}
edit.Mappings = []config.Mapping{{ID: "route", Name: "Route", Enabled: true, Source: "ntfy", Target: "discord", OutboundID: "discord-ops", TitleTemplate: "{{.Title}}", TextTemplate: "{{.Message}}"}}
body, _ := json.Marshal(edit)
request := func(method, path string, body []byte, admin bool) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if admin {
r.AddCookie(cookie)
r.Header.Set("X-CSRF-Token", csrfToken(c.Server.SessionSecret, cookie.Value))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
if w := request("PUT", "/ui/api/config", body, false); w.Code != 303 {
t.Fatalf("unprotected admin API: %d", w.Code)
}
if w := request("PUT", "/ui/api/config", body, true); w.Code != 200 {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
if store.Get().Server.SessionSecret != c.Server.SessionSecret {
t.Fatal("admin secret changed")
}
reopened, err := config.Open(configPath)
if err != nil {
t.Fatal(err)
}
if len(reopened.Get().Outbounds) != 1 || reopened.Get().Outbounds[0].ID != "discord-ops" {
t.Fatal("outbound was not persisted")
}
w := request("GET", "/ui/api/config", nil, true)
var loaded editableConfig
if err := json.Unmarshal(w.Body.Bytes(), &loaded); err != nil {
t.Fatal(err)
}
if len(loaded.Outbounds) != 1 || loaded.Outbounds[0].Live || loaded.Mappings[0].OutboundID != "discord-ops" {
t.Fatalf("round trip: %+v", loaded)
}
preview, _ := json.Marshal(map[string]any{"mapping": edit.Mappings[0], "message": map[string]any{"source": "ntfy", "title": "Hello", "message": "World"}})
w = request("POST", "/ui/api/preview", preview, true)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"content":"Hello\nWorld"`) {
t.Fatalf("preview: %d %s", w.Code, w.Body)
}
for _, authorized := range []bool{false, true} {
r := httptest.NewRequest("POST", "/ntfy/ops", strings.NewReader("World"))
r.Header.Set("Title", "Hello")
if authorized {
r.Header.Set("Authorization", "Bearer ingress-token")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !authorized {
if w.Code != 401 {
t.Fatalf("ingress auth: %d", w.Code)
}
continue
}
if w.Code != 200 || !strings.Contains(w.Body.String(), `"outbound_id":"discord-ops"`) || !strings.Contains(w.Body.String(), `dry_run`) {
t.Fatalf("delivery: %d %s", w.Code, w.Body)
}
if strings.Contains(w.Body.String(), "secret") {
t.Fatal("destination token leaked")
}
}
// A destination still referenced by a mapping cannot be removed.
edit.Outbounds = nil
body, _ = json.Marshal(edit)
if w := request("PUT", "/ui/api/config", body, true); w.Code != 400 {
t.Fatalf("invalid reference: %d %s", w.Code, w.Body)
}
if len(store.Get().Outbounds) != 1 {
t.Fatal("invalid config was persisted")
}
}