package notification import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) func validDelivery() Delivery { return Delivery{ID: "delivery-1", IdempotencyKey: "alert:disk-1:firing", EventType: EventFiring, Subject: "Schijfwaarschuwing", Body: "Disk 1 is bijna vol.", Attempt: 1} } func TestWebhookSenderDeliversBoundedAuthenticatedPayload(t *testing.T) { var received webhookPayload server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Header.Get("Authorization") != "Bearer runtime-secret" { t.Errorf("authorization = %q", request.Header.Get("Authorization")) } if request.Header.Get("Idempotency-Key") != "alert:disk-1:firing" { t.Errorf("idempotency key = %q", request.Header.Get("Idempotency-Key")) } if err := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 16<<10)).Decode(&received); err != nil { t.Errorf("decode payload: %v", err) } writer.WriteHeader(http.StatusNoContent) })) defer server.Close() sender, err := NewWebhookSender(WebhookConfig{Endpoint: server.URL, BearerToken: "runtime-secret", Timeout: time.Second, AllowHTTP: true}, nil) if err != nil { t.Fatal(err) } if err := sender.Send(context.Background(), validDelivery()); err != nil { t.Fatal(err) } if received.Version != "1" || received.DeliveryID != "delivery-1" || received.Attempt != 1 || received.EventType != EventFiring { t.Fatalf("payload = %#v", received) } } func TestWebhookSenderRetriesWithStableIdempotencyKey(t *testing.T) { var mu sync.Mutex calls := 0 unique := map[string]struct{}{} server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { mu.Lock() defer mu.Unlock() calls++ unique[request.Header.Get("Idempotency-Key")] = struct{}{} if calls == 1 { writer.WriteHeader(http.StatusServiceUnavailable) _, _ = writer.Write([]byte("token=must-not-leak")) return } writer.WriteHeader(http.StatusNoContent) })) defer server.Close() sender, err := NewWebhookSender(WebhookConfig{Endpoint: server.URL, Timeout: time.Second, AllowHTTP: true}, nil) if err != nil { t.Fatal(err) } delivery := validDelivery() if err := sender.Send(context.Background(), delivery); err == nil || strings.Contains(err.Error(), "must-not-leak") { t.Fatalf("first error = %v", err) } delivery.Attempt = 2 if err := sender.Send(context.Background(), delivery); err != nil { t.Fatal(err) } if calls != 2 || len(unique) != 1 { t.Fatalf("calls=%d unique idempotency keys=%d", calls, len(unique)) } } func TestWebhookSenderBlocksRedirectsAndUnsafeConfiguration(t *testing.T) { reached := false target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) defer target.Close() redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { http.Redirect(writer, request, target.URL, http.StatusTemporaryRedirect) })) defer redirect.Close() sender, err := NewWebhookSender(WebhookConfig{Endpoint: redirect.URL, Timeout: time.Second, AllowHTTP: true}, nil) if err != nil { t.Fatal(err) } if err := sender.Send(context.Background(), validDelivery()); err == nil || reached { t.Fatalf("redirect result=%v reached=%v", err, reached) } for _, endpoint := range []string{"http://example.test", "https://user:pass@example.test", "https://example.test/hook?token=value", "https://example.test/hook#fragment"} { if _, err := NewWebhookSender(WebhookConfig{Endpoint: endpoint, Timeout: time.Second}, nil); err == nil { t.Fatalf("unsafe endpoint accepted: %s", endpoint) } } } func TestWebhookFactoryUsesOnlyAllowlistedSecretReference(t *testing.T) { factory := WebhookFactory{Secrets: SecretResolverFunc(func(_ context.Context, ref SecretRef) (string, error) { if ref.ID != WebhookSecretReference { return "", ErrNotFound } return "runtime-secret", nil }), AllowHTTP: true} channel := Channel{Type: "webhook", SecretRef: SecretRef{ID: "env:arbitrary"}, Configuration: map[string]any{"url": "http://example.test", "timeoutSeconds": 1.0}} if _, err := factory.Sender(context.Background(), channel); !errors.Is(err, ErrNotFound) { t.Fatalf("unexpected error: %v", err) } channel.SecretRef.ID = WebhookSecretReference if _, err := factory.Sender(context.Background(), channel); err != nil { t.Fatal(err) } } func TestWebhookSenderHonorsCanceledContext(t *testing.T) { sender, err := NewWebhookSender(WebhookConfig{Endpoint: "https://example.test/hook", Timeout: time.Second}, nil) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := sender.Send(ctx, validDelivery()); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v", err) } } func TestWebhookSenderReportsUnreachableReceiverWithoutLeakingCredential(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) endpoint := server.URL server.Close() sender, err := NewWebhookSender(WebhookConfig{Endpoint: endpoint, BearerToken: "unreachable-secret", Timeout: time.Second, AllowHTTP: true}, nil) if err != nil { t.Fatal(err) } err = sender.Send(context.Background(), validDelivery()) if err == nil || !strings.Contains(err.Error(), "unreachable") || strings.Contains(err.Error(), "unreachable-secret") { t.Fatalf("error = %v", err) } }