package notifications import ( "encoding/json" "net/http" "net/url" "strings" "testing" ) func TestSendAppriseViaHTTPEmptyServerURL(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() err := nm.sendAppriseViaHTTP(AppriseConfig{TimeoutSeconds: 1}, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), "server URL is not configured") { t.Fatalf("expected missing server URL error, got %v", err) } } func TestSendAppriseViaHTTPInvalidScheme(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() err := nm.sendAppriseViaHTTP(AppriseConfig{ServerURL: "ftp://example.com", TimeoutSeconds: 1}, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), "http or https") { t.Fatalf("expected scheme validation error, got %v", err) } } func TestSendAppriseViaHTTPRejectsUserinfoServerURL(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() called := make(chan struct{}, 1) server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case called <- struct{}{}: default: } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } serverURL, err := url.Parse(server.URL) if err != nil { t.Fatalf("parse server URL: %v", err) } serverURL.User = url.UserPassword("user", "pass") err = nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: serverURL.String(), TimeoutSeconds: 2, }, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), "userinfo") { t.Fatalf("expected userinfo validation error, got %v", err) } select { case <-called: t.Fatal("expected userinfo-bearing Apprise URL to be rejected before outbound delivery") default: } } func TestSendAppriseViaHTTPRejectsQueryOrFragmentInServerURL(t *testing.T) { tests := []struct { name string serverURL func(string) string wantErr string }{ { name: "query", serverURL: func(base string) string { return base + "/apprise/api?token=secret" }, wantErr: "base URL must not include query or fragment", }, { name: "fragment", serverURL: func(base string) string { return base + "/apprise/api#notify" }, wantErr: "base URL must not include query or fragment", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() called := make(chan struct{}, 1) server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case called <- struct{}{}: default: } w.WriteHeader(http.StatusOK) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: tt.serverURL(server.URL), TimeoutSeconds: 2, }, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected %q validation error, got %v", tt.wantErr, err) } select { case <-called: t.Fatal("expected query/fragment-bearing Apprise base URL to be rejected before outbound delivery") default: } }) } } func TestSendTestWebhookRejectsUserinfoURLBeforeOutboundDelivery(t *testing.T) { nm := NewNotificationManager("https://pulse.local") defer nm.Stop() called := make(chan struct{}, 1) server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case called <- struct{}{}: default: } w.WriteHeader(http.StatusOK) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } serverURL, err := url.Parse(server.URL) if err != nil { t.Fatalf("parse server URL: %v", err) } serverURL.User = url.UserPassword("user", "pass") err = nm.SendTestWebhook(WebhookConfig{ Name: "Userinfo Webhook", URL: serverURL.String(), Enabled: true, }) if err == nil || !strings.Contains(err.Error(), "userinfo") { t.Fatalf("expected userinfo validation error, got %v", err) } select { case <-called: t.Fatal("expected userinfo-bearing webhook URL to be rejected before outbound delivery") default: } } func TestSendAppriseViaHTTPBlocksRedirectToLinkLocal(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://169.254.169.254/latest/meta-data", http.StatusFound) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: server.URL, TimeoutSeconds: 2, }, "title", "body", "info") if err == nil { t.Fatalf("expected redirect validation error") } if !strings.Contains(err.Error(), "link-local addresses are not allowed") { t.Fatalf("expected link-local redirect to be blocked, got %v", err) } } func TestSendAppriseViaHTTPSkipTLSVerifyAndDefaultHeader(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() server := newIPv4TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-API-KEY"); got != "secret" { t.Fatalf("expected default API key header, got %q", got) } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: server.URL, SkipTLSVerify: true, APIKey: "secret", TimeoutSeconds: 2, }, "title", "body", "info") if err != nil { t.Fatalf("expected HTTPS request to succeed, got %v", err) } } func TestSendAppriseViaHTTPIncludesTargetsAndCustomHeader(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() type payload struct { Body string `json:"body"` Title string `json:"title"` Type string `json:"type"` URLs []string `json:"urls"` } server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-Test-Key"); got != "secret" { t.Fatalf("expected custom API key header, got %q", got) } if got := r.URL.EscapedPath(); got != "/apprise/api/notify/config%20key" { t.Fatalf("expected base path to be preserved, got %q", got) } var p payload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { t.Fatalf("decode payload: %v", err) } if len(p.URLs) != 1 || p.URLs[0] != "discord://token" { t.Fatalf("expected urls in payload, got %#v", p.URLs) } if p.Type != "warning" { t.Fatalf("expected notify type warning, got %q", p.Type) } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: server.URL + "/apprise/api/", ConfigKey: "config key", APIKey: "secret", APIKeyHeader: "X-Test-Key", Targets: []string{"discord://token"}, TimeoutSeconds: 2, }, "title", "body", "warning") if err != nil { t.Fatalf("expected HTTP request to succeed, got %v", err) } } func TestSendAppriseViaHTTPStatusErrorWithBody(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("boom")) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: server.URL, TimeoutSeconds: 2, }, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), "HTTP 500") { t.Fatalf("expected status error with body, got %v", err) } } func TestSendAppriseViaHTTPStatusErrorWithoutBody(t *testing.T) { nm := NewNotificationManager("") defer nm.Stop() server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { t.Fatalf("allowlist: %v", err) } err := nm.sendAppriseViaHTTP(AppriseConfig{ ServerURL: server.URL, TimeoutSeconds: 2, }, "title", "body", "info") if err == nil || !strings.Contains(err.Error(), "HTTP 500") { t.Fatalf("expected status error, got %v", err) } }