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