Files
Charon/backend/internal/notifications/http_wrapper_test.go
T

500 lines
16 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
neturl "net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestHTTPWrapperRejectsOversizedRequestBody(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
payload := make([]byte, MaxNotifyRequestBodyBytes+1)
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: "http://example.com/hook",
Body: payload,
})
if err == nil || !strings.Contains(err.Error(), "request payload exceeds") {
t.Fatalf("expected oversized request body error, got: %v", err)
}
}
func TestHTTPWrapperRejectsTokenizedQueryURL(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: "http://example.com/hook?token=secret",
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
t.Fatalf("expected query token rejection, got: %v", err)
}
}
func TestHTTPWrapperRejectsQueryAuthCaseVariants(t *testing.T) {
testCases := []string{
"http://example.com/hook?Token=secret",
"http://example.com/hook?AUTH=secret",
"http://example.com/hook?apiKey=secret",
}
for _, testURL := range testCases {
t.Run(testURL, func(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: testURL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
t.Fatalf("expected query auth rejection for %q, got: %v", testURL, err)
}
})
}
}
func TestHTTPWrapperSendRejectsRedirectTargetWithDisallowedScheme(t *testing.T) {
var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
http.Redirect(w, r, "ftp://example.com/redirected", http.StatusFound)
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
wrapper.maxRedirects = 3
wrapper.retryPolicy.MaxAttempts = 1
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
t.Fatalf("expected outbound failure due to redirect target validation, got: %v", err)
}
if got := atomic.LoadInt32(&attempts); got != 1 {
t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
}
}
func TestHTTPWrapperSendRejectsRedirectTargetWithMixedCaseQueryAuth(t *testing.T) {
var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
http.Redirect(w, r, "https://example.com/redirected?Token=secret", http.StatusFound)
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
wrapper.maxRedirects = 3
wrapper.retryPolicy.MaxAttempts = 1
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
t.Fatalf("expected outbound failure due to redirect query auth validation, got: %v", err)
}
if got := atomic.LoadInt32(&attempts); got != 1 {
t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
}
}
func TestHTTPWrapperRetriesOn429ThenSucceeds(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := atomic.AddInt32(&calls, 1)
if current == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
wrapper.sleep = func(time.Duration) {}
wrapper.jitterNanos = func(int64) int64 { return 0 }
result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err != nil {
t.Fatalf("expected success after retry, got error: %v", err)
}
if result.Attempts != 2 {
t.Fatalf("expected 2 attempts, got %d", result.Attempts)
}
}
func TestHTTPWrapperSendSuccessWithValidatedDestination(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("expected default content-type, got %q", got)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
wrapper.retryPolicy.MaxAttempts = 1
wrapper.httpClientFactory = func(bool, int) *http.Client {
return server.Client()
}
result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err != nil {
t.Fatalf("expected successful send, got error: %v", err)
}
if result.Attempts != 1 {
t.Fatalf("expected 1 attempt, got %d", result.Attempts)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, result.StatusCode)
}
}
func TestHTTPWrapperSendRejectsUserInfoInDestinationURL(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: "https://user:pass@example.com/hook",
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected destination validation failure, got: %v", err)
}
}
func TestHTTPWrapperSendRejectsFragmentInDestinationURL(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: "https://example.com/hook#fragment",
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected destination validation failure, got: %v", err)
}
}
func TestHTTPWrapperDoesNotRetryOn400(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
wrapper.sleep = func(time.Duration) {}
wrapper.jitterNanos = func(int64) int64 { return 0 }
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "status 400") {
t.Fatalf("expected non-retryable 400 error, got: %v", err)
}
if atomic.LoadInt32(&calls) != 1 {
t.Fatalf("expected exactly one request attempt, got %d", calls)
}
}
func TestHTTPWrapperResponseBodyCap(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, strings.Repeat("x", MaxNotifyResponseBodyBytes+8))
}))
defer server.Close()
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil || !strings.Contains(err.Error(), "response payload exceeds") {
t.Fatalf("expected capped response body error, got: %v", err)
}
}
func TestSanitizeOutboundHeadersAllowlist(t *testing.T) {
headers := sanitizeOutboundHeaders(map[string]string{
"Content-Type": "application/json",
"User-Agent": "Charon",
"X-Request-ID": "abc",
"X-Gotify-Key": "secret",
"Authorization": "Bearer token",
"Cookie": "sid=1",
})
if len(headers) != 4 {
t.Fatalf("expected 4 allowed headers, got %d", len(headers))
}
if _, ok := headers["Authorization"]; ok {
t.Fatalf("authorization header must be stripped")
}
if _, ok := headers["Cookie"]; ok {
t.Fatalf("cookie header must be stripped")
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsNilRequest(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
err := wrapper.guardOutboundRequestURL(nil)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected validation failure for nil request, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsQueryAuth(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "token=secret"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
t.Fatalf("expected query auth rejection, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsMixedCaseQueryAuth(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "apiKey=secret"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
t.Fatalf("expected query auth rejection, got: %v", err)
}
}
func TestHTTPWrapperApplyRedirectGuardPreservesOriginalBehavior(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
baseErr := fmt.Errorf("base redirect policy")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return baseErr
}}
wrapper.applyRedirectGuard(client)
err := client.CheckRedirect(&http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com"}}, nil)
if !errors.Is(err, baseErr) {
t.Fatalf("expected original redirect policy error, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsUnsafeDestination(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = false
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected destination validation failure, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLAllowsValidatedDestination(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err != nil {
t.Fatalf("expected validated destination to pass guard, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsUserInfo(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "127.0.0.1", User: neturl.UserPassword("user", "pass"), Path: "/hook"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected userinfo rejection, got: %v", err)
}
}
func TestHTTPWrapperGuardOutboundRequestURLRejectsFragment(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook", Fragment: "frag"}}
err := wrapper.guardOutboundRequestURL(httpReq)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected fragment rejection, got: %v", err)
}
}
func TestSanitizeTransportErrorReason(t *testing.T) {
tests := []struct {
name string
err error
expected string
}{
{name: "nil error", err: nil, expected: "connection failed"},
{name: "dns error", err: errors.New("dial tcp: lookup gotify.example: no such host"), expected: "dns lookup failed"},
{name: "connection refused", err: errors.New("connect: connection refused"), expected: "connection refused"},
{name: "network unreachable", err: errors.New("connect: no route to host"), expected: "network unreachable"},
{name: "timeout", err: errors.New("context deadline exceeded"), expected: "request timed out"},
{name: "tls failure", err: errors.New("tls: handshake failure"), expected: "tls handshake failed"},
{name: "fallback", err: errors.New("some unexpected transport error"), expected: "connection failed"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
actual := sanitizeTransportErrorReason(testCase.err)
if actual != testCase.expected {
t.Fatalf("expected %q, got %q", testCase.expected, actual)
}
})
}
}
func TestBuildSafeRequestURLPreservesHostnameForTLS(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
destinationURL := &neturl.URL{
Scheme: "https",
Host: "example.com",
Path: "/webhook",
}
safeURL, hostHeader, err := wrapper.buildSafeRequestURL(destinationURL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if safeURL.Hostname() != "example.com" {
t.Fatalf("expected hostname 'example.com' preserved in URL for TLS SNI, got %q", safeURL.Hostname())
}
if hostHeader != "example.com" {
t.Fatalf("expected host header 'example.com', got %q", hostHeader)
}
if safeURL.Scheme != "https" {
t.Fatalf("expected scheme 'https', got %q", safeURL.Scheme)
}
if safeURL.Path != "/webhook" {
t.Fatalf("expected path '/webhook', got %q", safeURL.Path)
}
}
func TestBuildSafeRequestURLDefaultsEmptyPathToSlash(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
destinationURL := &neturl.URL{
Scheme: "http",
Host: "localhost",
}
safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if safeURL.Path != "/" {
t.Fatalf("expected default path '/', got %q", safeURL.Path)
}
}
func TestBuildSafeRequestURLPreservesQueryString(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
destinationURL := &neturl.URL{
Scheme: "https",
Host: "example.com",
Path: "/hook",
RawQuery: "key=value",
}
safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if safeURL.RawQuery != "key=value" {
t.Fatalf("expected query 'key=value', got %q", safeURL.RawQuery)
}
}
func TestBuildSafeRequestURLRejectsNilDestination(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
_, _, err := wrapper.buildSafeRequestURL(nil)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected validation failure for nil URL, got: %v", err)
}
}
func TestBuildSafeRequestURLRejectsEmptyHostname(t *testing.T) {
wrapper := NewNotifyHTTPWrapper()
destinationURL := &neturl.URL{
Scheme: "https",
Host: "",
Path: "/hook",
}
_, _, err := wrapper.buildSafeRequestURL(destinationURL)
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
t.Fatalf("expected validation failure for empty hostname, got: %v", err)
}
}
func TestBuildSafeRequestURLWithTLSServer(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
serverURL, _ := neturl.Parse(server.URL)
wrapper := NewNotifyHTTPWrapper()
wrapper.allowHTTP = true
safeURL, hostHeader, err := wrapper.buildSafeRequestURL(serverURL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if safeURL.Host != serverURL.Host {
t.Fatalf("expected host %q preserved for TLS, got %q", serverURL.Host, safeURL.Host)
}
if hostHeader != serverURL.Host {
t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader)
}
}