Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
package notification
import (
"context"
"errors"
"fmt"
"time"
)
type Dispatcher struct {
Store Store
Channels map[string]ChannelSender
Limiter *RateLimiter
}
func (dispatcher Dispatcher) DispatchDue(ctx context.Context, now time.Time, limit int) (int, error) {
if dispatcher.Store == nil {
return 0, ErrUnavailable
}
items, err := dispatcher.Store.ClaimDue(ctx, now.UTC(), limit)
if err != nil {
return 0, err
}
delivered := 0
for _, item := range items {
sender, ok := dispatcher.Channels[item.ChannelID]
if !ok || sender == nil {
_, completeErr := dispatcher.Store.Complete(ctx, item.ID, item.Attempts, false, errors.New("notification channel sender unavailable"), now)
if completeErr != nil {
return delivered, completeErr
}
continue
}
delivery := Delivery{ID: item.ID, EventType: item.EventType, Subject: item.Subject, Body: item.Body, IdempotencyKey: item.IdempotencyKey, Attempt: item.Attempts}
sendErr := sender.Send(ctx, delivery)
if _, completeErr := dispatcher.Store.Complete(ctx, item.ID, item.Attempts, sendErr == nil, sendErr, now); completeErr != nil {
return delivered, completeErr
}
if sendErr == nil {
delivered++
}
}
return delivered, nil
}
// Test sends a bounded, in-memory test delivery. It deliberately does not
// enqueue or mutate an outbox record, and it uses the actor/channel pair as a
// rate-limit key so test actions cannot become a delivery amplification path.
func (dispatcher Dispatcher) Test(ctx context.Context, actor, channelID string, delivery Delivery, now time.Time) error {
if actor == "" || len(actor) > 160 || channelID == "" {
return fmt.Errorf("%w: test identity is invalid", ErrInvalid)
}
if dispatcher.Limiter == nil {
return ErrUnavailable
}
if !dispatcher.Limiter.Allow(actor+":"+channelID, now.UTC()) {
return fmt.Errorf("%w: notification test rate limit exceeded", ErrConflict)
}
sender, ok := dispatcher.Channels[channelID]
if !ok || sender == nil {
return ErrNotFound
}
if err := delivery.Validate(); err != nil {
return err
}
return sender.Send(ctx, delivery)
}
+60
View File
@@ -0,0 +1,60 @@
package notification
import (
"context"
"errors"
"testing"
"time"
)
type dispatcherStore struct {
items []Outbox
complete []bool
}
func (store *dispatcherStore) Enqueue(context.Context, Outbox) (Outbox, bool, error) {
return Outbox{}, false, nil
}
func (store *dispatcherStore) GetOutbox(context.Context, string) (Outbox, error) {
return Outbox{}, ErrNotFound
}
func (store *dispatcherStore) ClaimDue(context.Context, time.Time, int) ([]Outbox, error) {
items := store.items
store.items = nil
return items, nil
}
func (store *dispatcherStore) Complete(_ context.Context, _ string, _ int, success bool, _ error, _ time.Time) (Outbox, error) {
store.complete = append(store.complete, success)
return Outbox{Status: StatusDelivered}, nil
}
func TestDispatcherDispatchesRecoveryAndRecordsFailure(t *testing.T) {
store := &dispatcherStore{items: []Outbox{{ID: "delivery-1", IdempotencyKey: "key-1", ChannelID: "memory", EventType: EventRecovery, Subject: "Recovered", Body: "recovered", Attempts: 1}}}
channel := &MemoryChannel{}
dispatcher := Dispatcher{Store: store, Channels: map[string]ChannelSender{"memory": channel}}
delivered, err := dispatcher.DispatchDue(context.Background(), time.Unix(100, 0), 1)
if err != nil || delivered != 1 || len(channel.Deliveries) != 1 || channel.Deliveries[0].EventType != EventRecovery {
t.Fatalf("delivered=%d channel=%+v err=%v", delivered, channel.Deliveries, err)
}
if len(store.complete) != 1 || !store.complete[0] {
t.Fatalf("completion=%v", store.complete)
}
store.items = []Outbox{{ID: "delivery-2", IdempotencyKey: "key-2", ChannelID: "memory", EventType: EventFiring, Subject: "Firing", Body: "firing", Attempts: 1}}
channel.Failure = errors.New("channel unavailable")
delivered, err = dispatcher.DispatchDue(context.Background(), time.Unix(101, 0), 1)
if err != nil || delivered != 0 || len(store.complete) != 2 || store.complete[1] {
t.Fatalf("failed dispatch delivered=%d completion=%v err=%v", delivered, store.complete, err)
}
}
func TestDispatcherMissingSenderFailsSafely(t *testing.T) {
store := &dispatcherStore{items: []Outbox{{ID: "delivery-3", IdempotencyKey: "key-3", ChannelID: "missing", EventType: EventUnknown, Subject: "Unknown", Body: "unknown", Attempts: 1}}}
dispatcher := Dispatcher{Store: store, Channels: map[string]ChannelSender{}}
if _, err := dispatcher.DispatchDue(context.Background(), time.Unix(100, 0), 1); err != nil {
t.Fatal(err)
}
if len(store.complete) != 1 || store.complete[0] {
t.Fatalf("missing sender completion=%v", store.complete)
}
}
+463
View File
@@ -0,0 +1,463 @@
package notification
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const maxClaimLimit = 100
const (
StatusPending = "pending"
StatusDelivering = "delivering"
StatusRetry = "retry"
StatusDelivered = "delivered"
StatusFailed = "failed"
)
// Store is the deliberately small persistence boundary used by the dispatcher.
// Enqueue, claim and completion are the only operations that can advance an
// outbox item, which keeps retry and delivery audit changes in one transaction.
type Store interface {
Enqueue(context.Context, Outbox) (Outbox, bool, error)
GetOutbox(context.Context, string) (Outbox, error)
ClaimDue(context.Context, time.Time, int) ([]Outbox, error)
Complete(context.Context, string, int, bool, error, time.Time) (Outbox, error)
}
type ChannelStore interface {
CreateChannel(context.Context, Channel) (Channel, error)
GetChannel(context.Context, string) (Channel, error)
ListChannels(context.Context, int) ([]Channel, error)
UpdateChannel(context.Context, Channel, int64) (Channel, error)
DeleteChannel(context.Context, string) error
}
type Repository struct{ Pool *pgxpool.Pool }
func NewRepository(pool *pgxpool.Pool) (*Repository, error) {
if pool == nil {
return nil, ErrUnavailable
}
return &Repository{Pool: pool}, nil
}
func (r *Repository) Enqueue(ctx context.Context, item Outbox) (Outbox, bool, error) {
if r == nil || r.Pool == nil {
return Outbox{}, false, ErrUnavailable
}
if err := validateOutbox(item); err != nil {
return Outbox{}, false, err
}
if item.ID == "" {
item.ID = newID()
}
if item.NextAttemptAt.IsZero() {
item.NextAttemptAt = time.Now().UTC()
}
if item.Status == "" {
item.Status = StatusPending
}
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now().UTC()
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = item.CreatedAt
}
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return Outbox{}, false, fmt.Errorf("begin notification enqueue: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var inserted Outbox
err = scanOutbox(tx.QueryRow(ctx, `
INSERT INTO notification_outbox
(id,idempotency_key,channel_id,event_type,subject,body,status,attempts,next_attempt_at,created_at,updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body`,
item.ID, item.IdempotencyKey, item.ChannelID, item.EventType, item.Subject, item.Body, item.Status,
item.Attempts, item.NextAttemptAt.UTC(), item.CreatedAt.UTC(), item.UpdatedAt.UTC()), &inserted)
if errors.Is(err, pgx.ErrNoRows) {
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE idempotency_key=$1`, item.IdempotencyKey), &inserted); err != nil {
return Outbox{}, false, mapError("select existing notification", err)
}
if err := tx.Commit(ctx); err != nil {
return Outbox{}, false, fmt.Errorf("commit duplicate notification: %w", err)
}
return inserted, true, nil
}
if err != nil {
return Outbox{}, false, mapError("enqueue notification", err)
}
if err := tx.Commit(ctx); err != nil {
return Outbox{}, false, fmt.Errorf("commit notification enqueue: %w", err)
}
return inserted, false, nil
}
func (r *Repository) GetOutbox(ctx context.Context, id string) (Outbox, error) {
if r == nil || r.Pool == nil {
return Outbox{}, ErrUnavailable
}
if id == "" {
return Outbox{}, fmt.Errorf("%w: outbox id is required", ErrInvalid)
}
var item Outbox
err := scanOutbox(r.Pool.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1`, id), &item)
if errors.Is(err, pgx.ErrNoRows) {
return Outbox{}, ErrNotFound
}
if err != nil {
return Outbox{}, fmt.Errorf("get notification outbox: %w", err)
}
return item, nil
}
func (r *Repository) ClaimDue(ctx context.Context, now time.Time, limit int) ([]Outbox, error) {
if r == nil || r.Pool == nil {
return nil, ErrUnavailable
}
if limit < 1 || limit > maxClaimLimit {
return nil, fmt.Errorf("%w: claim limit must be between 1 and %d", ErrInvalid, maxClaimLimit)
}
now = now.UTC()
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, fmt.Errorf("begin notification claim: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
WITH due AS (
SELECT id
FROM notification_outbox
WHERE attempts < $2 AND (
(status IN ('pending','retry') AND next_attempt_at <= $1)
OR (status='delivering' AND locked_until IS NOT NULL AND locked_until <= $1)
)
ORDER BY id ASC
FOR UPDATE SKIP LOCKED
LIMIT $3
)
UPDATE notification_outbox AS item
SET status='delivering', attempts=item.attempts+1, locked_until=$1 + interval '5 minutes', updated_at=$1
FROM due
WHERE item.id=due.id
RETURNING item.id::text,item.idempotency_key,item.channel_id::text,item.event_type,item.status,item.attempts,item.next_attempt_at,COALESCE(item.last_error,''),item.created_at,item.updated_at,item.delivered_at,item.subject,item.body`, now, MaxAttempts, limit)
if err != nil {
return nil, fmt.Errorf("claim due notifications: %w", err)
}
claimed := make([]Outbox, 0, limit)
for rows.Next() {
var item Outbox
if err := scanOutbox(rows, &item); err != nil {
rows.Close()
return nil, fmt.Errorf("scan claimed notification: %w", err)
}
claimed = append(claimed, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("claim notification rows: %w", err)
}
rows.Close()
for _, item := range claimed {
if _, err := tx.Exec(ctx, `INSERT INTO notification_deliveries (id,outbox_id,attempt,status,occurred_at) VALUES ($1,$2,$3,'delivering',$4) ON CONFLICT (outbox_id,attempt) DO NOTHING`, newID(), item.ID, item.Attempts, now); err != nil {
return nil, mapError("create notification delivery audit", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit notification claim: %w", err)
}
return claimed, nil
}
func (r *Repository) Complete(ctx context.Context, id string, attempt int, success bool, deliveryErr error, now time.Time) (Outbox, error) {
if r == nil || r.Pool == nil {
return Outbox{}, ErrUnavailable
}
if id == "" || attempt < 1 || attempt > MaxAttempts {
return Outbox{}, fmt.Errorf("%w: completion identity is invalid", ErrInvalid)
}
now = now.UTC()
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return Outbox{}, fmt.Errorf("begin notification completion: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var current Outbox
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1 FOR UPDATE`, id), &current); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Outbox{}, ErrNotFound
}
return Outbox{}, err
}
if current.Status != StatusDelivering || current.Attempts != attempt {
if err := tx.Commit(ctx); err != nil {
return Outbox{}, fmt.Errorf("commit idempotent notification completion: %w", err)
}
return current, nil
}
if success {
if _, err := tx.Exec(ctx, `UPDATE notification_outbox SET status='delivered',locked_until=NULL,last_error=NULL,delivered_at=$1,updated_at=$1 WHERE id=$2`, now, id); err != nil {
return Outbox{}, fmt.Errorf("mark notification delivered: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE notification_deliveries SET status='delivered',error=NULL,occurred_at=$1 WHERE outbox_id=$2 AND attempt=$3`, now, id, attempt); err != nil {
return Outbox{}, fmt.Errorf("audit notification delivery: %w", err)
}
} else {
message := RedactError(deliveryErr)
status := StatusRetry
next := now.Add(RetryDelay(attempt))
if attempt >= MaxAttempts {
status = StatusFailed
next = now
}
if _, err := tx.Exec(ctx, `UPDATE notification_outbox SET status=$1,locked_until=NULL,last_error=$2,next_attempt_at=$3,updated_at=$4 WHERE id=$5`, status, nullableString(message), next, now, id); err != nil {
return Outbox{}, fmt.Errorf("record notification failure: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE notification_deliveries SET status='failed',error=$1,occurred_at=$2 WHERE outbox_id=$3 AND attempt=$4`, nullableString(message), now, id, attempt); err != nil {
return Outbox{}, fmt.Errorf("audit notification failure: %w", err)
}
}
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1`, id), &current); err != nil {
return Outbox{}, err
}
if err := tx.Commit(ctx); err != nil {
return Outbox{}, fmt.Errorf("commit notification completion: %w", err)
}
return current, nil
}
func (r *Repository) CreateChannel(ctx context.Context, channel Channel) (Channel, error) {
if r == nil || r.Pool == nil {
return Channel{}, ErrUnavailable
}
if channel.ID == "" {
channel.ID = newID()
}
if channel.Revision == 0 {
channel.Revision = 1
}
if err := validateChannel(channel); err != nil {
return Channel{}, err
}
if channel.CreatedAt.IsZero() {
channel.CreatedAt = time.Now().UTC()
}
if channel.UpdatedAt.IsZero() {
channel.UpdatedAt = channel.CreatedAt
}
config, err := safeConfiguration(channel.Configuration)
if err != nil {
return Channel{}, err
}
var created Channel
err = scanChannel(r.Pool.QueryRow(ctx, `INSERT INTO notification_channels (id,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) RETURNING id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at`, channel.ID, channel.Name, channel.Type, channel.Enabled, channel.SecretRef.ID, config, channel.Revision, channel.CreatedAt.UTC(), channel.UpdatedAt.UTC()), &created)
if err != nil {
return Channel{}, mapError("create notification channel", err)
}
return created, nil
}
func (r *Repository) GetChannel(ctx context.Context, id string) (Channel, error) {
if r == nil || r.Pool == nil {
return Channel{}, ErrUnavailable
}
var channel Channel
err := scanChannel(r.Pool.QueryRow(ctx, `SELECT id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at FROM notification_channels WHERE id=$1`, id), &channel)
if errors.Is(err, pgx.ErrNoRows) {
return Channel{}, ErrNotFound
}
if err != nil {
return Channel{}, fmt.Errorf("get notification channel: %w", err)
}
return channel, nil
}
func (r *Repository) ListChannels(ctx context.Context, limit int) ([]Channel, error) {
if r == nil || r.Pool == nil {
return nil, ErrUnavailable
}
if limit < 1 || limit > 100 {
return nil, fmt.Errorf("%w: channel limit must be between 1 and 100", ErrInvalid)
}
rows, err := r.Pool.Query(ctx, `SELECT id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at FROM notification_channels ORDER BY name ASC,id ASC LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("list notification channels: %w", err)
}
defer rows.Close()
channels := make([]Channel, 0, limit)
for rows.Next() {
var channel Channel
if err := scanChannel(rows, &channel); err != nil {
return nil, err
}
channels = append(channels, channel)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list notification channel rows: %w", err)
}
return channels, nil
}
func (r *Repository) UpdateChannel(ctx context.Context, channel Channel, expectedRevision int64) (Channel, error) {
if r == nil || r.Pool == nil {
return Channel{}, ErrUnavailable
}
if expectedRevision < 1 {
return Channel{}, fmt.Errorf("%w: channel revision is invalid", ErrInvalid)
}
if err := validateChannel(channel); err != nil {
return Channel{}, err
}
config, err := safeConfiguration(channel.Configuration)
if err != nil {
return Channel{}, err
}
var updated Channel
err = scanChannel(r.Pool.QueryRow(ctx, `UPDATE notification_channels SET name=$1,channel_type=$2,enabled=$3,secret_ref=$4,configuration=$5::jsonb,revision=revision+1,updated_at=now() WHERE id=$6 AND revision=$7 RETURNING id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at`, channel.Name, channel.Type, channel.Enabled, channel.SecretRef.ID, config, channel.ID, expectedRevision), &updated)
if errors.Is(err, pgx.ErrNoRows) {
if _, getErr := r.GetChannel(ctx, channel.ID); errors.Is(getErr, ErrNotFound) {
return Channel{}, ErrNotFound
}
return Channel{}, ErrConflict
}
if err != nil {
return Channel{}, mapError("update notification channel", err)
}
return updated, nil
}
func (r *Repository) DeleteChannel(ctx context.Context, id string) error {
if r == nil || r.Pool == nil {
return ErrUnavailable
}
command, err := r.Pool.Exec(ctx, `DELETE FROM notification_channels WHERE id=$1`, id)
if err != nil {
return mapError("delete notification channel", err)
}
if command.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func validateOutbox(item Outbox) error {
if item.ID != "" && len(item.ID) > 64 || item.IdempotencyKey == "" || len(item.IdempotencyKey) > 255 || item.ChannelID == "" || item.EventType == "" || (item.EventType != EventFiring && item.EventType != EventRecovery && item.EventType != EventUnknown) || len(item.Subject) < 1 || len(item.Subject) > MaxSubject || len(item.Body) < 1 || len(item.Body) > MaxBody || item.Attempts < 0 || item.Attempts > MaxAttempts {
return fmt.Errorf("%w: outbox fields are invalid", ErrInvalid)
}
if item.Status != "" && item.Status != StatusPending {
return fmt.Errorf("%w: new outbox status must be pending", ErrInvalid)
}
return nil
}
func validateChannel(channel Channel) error {
if err := channel.Validate(); err != nil {
return err
}
if channel.Revision < 1 {
return fmt.Errorf("%w: channel revision is invalid", ErrInvalid)
}
return nil
}
func safeConfiguration(config map[string]any) ([]byte, error) {
if config == nil {
config = map[string]any{}
}
if containsSecretKey(config) {
return nil, fmt.Errorf("%w: secret values must use secret references", ErrInvalid)
}
encoded, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("%w: invalid channel configuration", ErrInvalid)
}
if len(encoded) > 16<<10 {
return nil, fmt.Errorf("%w: channel configuration is too large", ErrInvalid)
}
return encoded, nil
}
func containsSecretKey(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
key = strings.ToLower(key)
if strings.Contains(key, "secret") || strings.Contains(key, "token") || strings.Contains(key, "password") || strings.Contains(key, "authorization") {
return true
}
if containsSecretKey(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsSecretKey(child) {
return true
}
}
}
return false
}
func scanOutbox(row interface{ Scan(...any) error }, item *Outbox) error {
return row.Scan(&item.ID, &item.IdempotencyKey, &item.ChannelID, &item.EventType, &item.Status, &item.Attempts, &item.NextAttemptAt, &item.LastError, &item.CreatedAt, &item.UpdatedAt, &item.DeliveredAt, &item.Subject, &item.Body)
}
func scanChannel(row interface{ Scan(...any) error }, channel *Channel) error {
var config []byte
if err := row.Scan(&channel.ID, &channel.Name, &channel.Type, &channel.Enabled, &channel.SecretRef.ID, &config, &channel.Revision, &channel.CreatedAt, &channel.UpdatedAt); err != nil {
return err
}
if err := json.Unmarshal(config, &channel.Configuration); err != nil {
return fmt.Errorf("decode notification channel configuration: %w", err)
}
return nil
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func mapError(operation string, err error) error {
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503", "23505":
return fmt.Errorf("%w: %s", ErrConflict, operation)
case "23514", "22P02":
return fmt.Errorf("%w: %s", ErrInvalid, operation)
}
}
return fmt.Errorf("%s: %w", operation, err)
}
func newID() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("00000000-0000-4000-8000-%012d", time.Now().UnixNano()%1_000_000_000_000)
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(bytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
}
@@ -0,0 +1,249 @@
package notification
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/itworx/pulse/internal/database"
)
func TestWebhookDeliveryPostgreSQLRetryAndAudit(t *testing.T) {
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("PULSE_TEST_DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
repository, err := NewRepository(pool)
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
calls := 0
keys := map[string]struct{}{}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mu.Lock()
defer mu.Unlock()
calls++
keys[request.Header.Get("Idempotency-Key")] = struct{}{}
if request.Header.Get("Authorization") != "Bearer integration-runtime-secret" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if calls == 1 {
writer.WriteHeader(http.StatusServiceUnavailable)
_, _ = writer.Write([]byte("authorization=integration-runtime-secret"))
return
}
writer.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
sender, err := NewWebhookSender(WebhookConfig{Endpoint: server.URL, BearerToken: "integration-runtime-secret", Timeout: time.Second, AllowHTTP: true}, nil)
if err != nil {
t.Fatal(err)
}
channelID := newID()
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "integration-webhook-" + channelID[:8], Type: "webhook", Enabled: true, SecretRef: SecretRef{ID: WebhookSecretReference}, Configuration: map[string]any{"url": server.URL, "timeoutSeconds": 1}})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
}()
now := time.Now().UTC().Truncate(time.Microsecond)
item, duplicate, err := repository.Enqueue(ctx, Outbox{IdempotencyKey: "webhook-integration-" + channelID, ChannelID: channel.ID, EventType: EventFiring, Subject: "Firing", Body: "real receiver delivery", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now})
if err != nil || duplicate {
t.Fatalf("enqueue duplicate=%v err=%v", duplicate, err)
}
dispatcher := Dispatcher{Store: repository, Channels: map[string]ChannelSender{channel.ID: sender}}
if delivered, err := dispatcher.DispatchDue(ctx, now, 10); err != nil || delivered != 0 {
t.Fatalf("first dispatch delivered=%d err=%v", delivered, err)
}
retried, err := repository.GetOutbox(ctx, item.ID)
if err != nil || retried.Status != StatusRetry || retried.Attempts != 1 || strings.Contains(retried.LastError, "integration-runtime-secret") {
t.Fatalf("retry=%+v err=%v", retried, err)
}
if delivered, err := dispatcher.DispatchDue(ctx, now.Add(2*time.Second), 10); err != nil || delivered != 1 {
t.Fatalf("second dispatch delivered=%d err=%v", delivered, err)
}
completed, err := repository.GetOutbox(ctx, item.ID)
if err != nil || completed.Status != StatusDelivered || completed.Attempts != 2 || completed.DeliveredAt == nil {
t.Fatalf("completed=%+v err=%v", completed, err)
}
var auditRows int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_deliveries WHERE outbox_id=$1`, item.ID).Scan(&auditRows); err != nil {
t.Fatal(err)
}
if calls != 2 || len(keys) != 1 || auditRows != 2 {
t.Fatalf("receiver calls=%d unique keys=%d audit rows=%d", calls, len(keys), auditRows)
}
var storedSecret bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM notification_channels WHERE id=$1 AND configuration::text LIKE '%' || $2 || '%')`, channel.ID, "integration-runtime-secret").Scan(&storedSecret); err != nil {
t.Fatal(err)
}
if storedSecret {
t.Fatal("runtime webhook credential was persisted")
}
}
func TestNotificationRepositoryPostgreSQL(t *testing.T) {
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("PULSE_TEST_DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 12, MinConns: 1})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := database.Ping(ctx, pool); err != nil {
t.Fatal(err)
}
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
repository, err := NewRepository(pool)
if err != nil {
t.Fatal(err)
}
channelID := newID()
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "integration-memory-" + channelID[:8], Type: "memory", Enabled: true, SecretRef: SecretRef{ID: "test-secret-ref"}})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
}()
now := time.Now().UTC().Truncate(time.Microsecond)
item := Outbox{IdempotencyKey: "integration-" + channelID, ChannelID: channel.ID, EventType: EventRecovery, Subject: "Recovered", Body: "entity recovered", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now}
first, duplicate, err := repository.Enqueue(ctx, item)
if err != nil || duplicate {
t.Fatalf("first enqueue item=%+v duplicate=%v err=%v", first, duplicate, err)
}
second, duplicate, err := repository.Enqueue(ctx, item)
if err != nil || !duplicate || second.ID != first.ID {
t.Fatalf("duplicate enqueue item=%+v duplicate=%v err=%v", second, duplicate, err)
}
claimed, err := repository.ClaimDue(ctx, now, 10)
if err != nil || len(claimed) != 1 || claimed[0].Attempts != 1 {
t.Fatalf("claimed=%+v err=%v", claimed, err)
}
retried, err := repository.Complete(ctx, first.ID, 1, false, errors.New("webhook token=should-not-leak"), now)
if err != nil || retried.Status != StatusRetry || retried.LastError == "" {
t.Fatalf("retry item=%+v err=%v", retried, err)
}
if retried.LastError == "webhook token=should-not-leak" {
t.Fatal("secret appeared in persisted delivery error")
}
claimed, err = repository.ClaimDue(ctx, now.Add(2*time.Second), 10)
if err != nil || len(claimed) != 1 || claimed[0].Attempts != 2 {
t.Fatalf("second claim=%+v err=%v", claimed, err)
}
completed, err := repository.Complete(ctx, first.ID, 2, true, nil, now.Add(2*time.Second))
if err != nil || completed.Status != StatusDelivered || completed.DeliveredAt == nil {
t.Fatalf("completed item=%+v err=%v", completed, err)
}
var deliveryCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_deliveries WHERE outbox_id=$1`, first.ID).Scan(&deliveryCount); err != nil {
t.Fatal(err)
}
if deliveryCount != 2 {
t.Fatalf("delivery audit rows=%d", deliveryCount)
}
if _, err := repository.Complete(ctx, first.ID, 2, true, nil, now.Add(3*time.Second)); err != nil {
t.Fatalf("idempotent completion error=%v", err)
}
if _, err := repository.UpdateChannel(ctx, channel, channel.Revision+1); !errors.Is(err, ErrConflict) {
t.Fatalf("stale channel update error=%v", err)
}
if _, err := repository.UpdateChannel(ctx, channel, channel.Revision); err != nil {
t.Fatalf("channel update error=%v", err)
}
}
func TestNotificationRepositoryConcurrentClaims(t *testing.T) {
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("PULSE_TEST_DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 16, MinConns: 1})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
repository, err := NewRepository(pool)
if err != nil {
t.Fatal(err)
}
channelID := newID()
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "claim-memory-" + channelID[:8], Type: "memory", Enabled: true, SecretRef: SecretRef{ID: "test-secret-ref"}})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
}()
now := time.Now().UTC().Truncate(time.Microsecond)
for i := 0; i < 8; i++ {
if _, _, err := repository.Enqueue(ctx, Outbox{IdempotencyKey: fmt.Sprintf("claim-%s-%d", channelID, i), ChannelID: channel.ID, EventType: EventFiring, Subject: "Firing", Body: "test", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
var mu sync.Mutex
claimedIDs := map[string]bool{}
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
items, err := repository.ClaimDue(ctx, now, 8)
if err != nil {
errs <- err
return
}
mu.Lock()
defer mu.Unlock()
for _, item := range items {
if claimedIDs[item.ID] {
errs <- fmt.Errorf("item %s claimed twice", item.ID)
}
claimedIDs[item.ID] = true
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if len(claimedIDs) != 8 {
t.Fatalf("claimed unique items=%d", len(claimedIDs))
}
}
+216
View File
@@ -0,0 +1,216 @@
package notification
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"text/template"
"time"
)
const (
MaxSubject = 240
MaxBody = 8000
MaxAttempts = 10
)
var (
ErrInvalid = errors.New("invalid notification")
ErrNotFound = errors.New("notification resource not found")
ErrConflict = errors.New("notification resource conflict")
ErrUnavailable = errors.New("notification repository unavailable")
secretValuePattern = regexp.MustCompile(`(?i)(secret|token|password)(\s*[:=]\s*)[^\s,;]+`)
authorizationPattern = regexp.MustCompile(`(?i)(authorization)(\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+`)
)
type EventType string
const (
EventFiring EventType = "firing"
EventRecovery EventType = "recovery"
EventUnknown EventType = "unknown"
)
type SecretRef struct {
ID string `json:"id"`
}
func (ref SecretRef) Validate() error {
if ref.ID == "" || len(ref.ID) > 255 || strings.ContainsAny(ref.ID, "\r\n\x00") {
return fmt.Errorf("%w: invalid secret reference", ErrInvalid)
}
return nil
}
type Channel struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
SecretRef SecretRef `json:"secretRef"`
Configuration map[string]any `json:"configuration,omitempty"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (channel Channel) Validate() error {
if channel.ID == "" || channel.Name == "" || len(channel.Name) > 160 || channel.Type == "" || (channel.Type != "memory" && channel.Type != "webhook" && channel.Type != "email") {
return fmt.Errorf("%w: invalid channel", ErrInvalid)
}
return channel.SecretRef.Validate()
}
type Delivery struct {
ID string
EventType EventType
Subject string
Body string
IdempotencyKey string
Attempt int
}
func (delivery Delivery) Validate() error {
if delivery.ID == "" || delivery.IdempotencyKey == "" || len(delivery.IdempotencyKey) > 255 || (delivery.EventType != EventFiring && delivery.EventType != EventRecovery && delivery.EventType != EventUnknown) || len(delivery.Subject) < 1 || len(delivery.Subject) > MaxSubject || len(delivery.Body) < 1 || len(delivery.Body) > MaxBody || delivery.Attempt < 1 {
return fmt.Errorf("%w: invalid delivery", ErrInvalid)
}
return nil
}
type Outbox struct {
ID string `json:"id"`
IdempotencyKey string `json:"idempotencyKey"`
ChannelID string `json:"channelId"`
EventType EventType `json:"eventType"`
Subject string `json:"subject"`
Body string `json:"body"`
Status string `json:"status"`
Attempts int `json:"attempts"`
NextAttemptAt time.Time `json:"nextAttemptAt"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
}
type ChannelSender interface {
Send(context.Context, Delivery) error
}
type MemoryChannel struct {
mu sync.Mutex
Deliveries []Delivery
Failure error
}
func (channel *MemoryChannel) Send(ctx context.Context, delivery Delivery) error {
if err := ctx.Err(); err != nil {
return err
}
if err := delivery.Validate(); err != nil {
return err
}
channel.mu.Lock()
defer channel.mu.Unlock()
if channel.Failure != nil {
return channel.Failure
}
channel.Deliveries = append(channel.Deliveries, delivery)
return nil
}
func RenderTemplate(name, source string, data map[string]string) (string, error) {
if name == "" || source == "" || len(source) > MaxBody {
return "", fmt.Errorf("%w: invalid template", ErrInvalid)
}
parsed, err := template.New(name).Option("missingkey=error").Parse(source)
if err != nil {
return "", fmt.Errorf("%w: parse template", ErrInvalid)
}
var output strings.Builder
if err := parsed.Execute(&output, data); err != nil {
return "", fmt.Errorf("%w: execute template", ErrInvalid)
}
if output.Len() < 1 || output.Len() > MaxBody {
return "", fmt.Errorf("%w: rendered body exceeds bounds", ErrInvalid)
}
return output.String(), nil
}
func RedactError(err error) string {
if err == nil {
return ""
}
value := authorizationPattern.ReplaceAllString(err.Error(), "$1=[redacted]")
value = secretValuePattern.ReplaceAllString(value, "$1=[redacted]")
value = strings.ReplaceAll(value, "\r", " ")
value = strings.ReplaceAll(value, "\n", " ")
if len(value) > 500 {
return value[:500]
}
return value
}
type RateLimiter struct {
mu sync.Mutex
entries map[string]rateEntry
Limit int
Window time.Duration
}
type rateEntry struct {
Started time.Time
Count int
}
func (limiter *RateLimiter) Allow(key string, now time.Time) bool {
if key == "" {
return false
}
limiter.mu.Lock()
defer limiter.mu.Unlock()
if limiter.entries == nil {
limiter.entries = map[string]rateEntry{}
}
window := limiter.Window
if window <= 0 {
window = time.Minute
}
limit := limiter.Limit
if limit <= 0 {
limit = 3
}
entry := limiter.entries[key]
if entry.Started.IsZero() || !now.Before(entry.Started.Add(window)) {
limiter.entries[key] = rateEntry{Started: now, Count: 1}
return true
}
if entry.Count >= limit {
return false
}
entry.Count++
limiter.entries[key] = entry
return true
}
func RetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > MaxAttempts {
attempt = MaxAttempts
}
delay := time.Second << min(attempt-1, 9)
if delay > 15*time.Minute {
return 15 * time.Minute
}
return delay
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+78
View File
@@ -0,0 +1,78 @@
package notification
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestNotificationTemplateAndSecretBoundaries(t *testing.T) {
rendered, err := RenderTemplate("recovery", "Alert {{.name}} recovered", map[string]string{"name": "disk-1"})
if err != nil || rendered != "Alert disk-1 recovered" {
t.Fatalf("rendered=%q err=%v", rendered, err)
}
if _, err := RenderTemplate("missing", "{{.missing}}", map[string]string{}); !errors.Is(err, ErrInvalid) {
t.Fatalf("missing template key error=%v", err)
}
if _, err := RenderTemplate("large", strings.Repeat("x", MaxBody+1), nil); !errors.Is(err, ErrInvalid) {
t.Fatalf("large template error=%v", err)
}
if err := (SecretRef{ID: "vault://notifications/webhook"}).Validate(); err != nil {
t.Fatal(err)
}
if err := (SecretRef{ID: "plain-secret-value"}).Validate(); err != nil {
t.Fatal(err)
}
redacted := RedactError(errors.New("request token=abc123 password=hunter2 authorization=Bearer xyz"))
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "hunter2") || strings.Contains(redacted, "xyz") {
t.Fatalf("secret leaked in redacted error: %q", redacted)
}
}
func TestNotificationRecoveryAndMemoryChannel(t *testing.T) {
channel := &MemoryChannel{}
delivery := Delivery{ID: "delivery-1", EventType: EventRecovery, Subject: "Recovered", Body: "disk-1 recovered", IdempotencyKey: "alert-1:recovery", Attempt: 1}
if err := channel.Send(context.Background(), delivery); err != nil {
t.Fatal(err)
}
if len(channel.Deliveries) != 1 || channel.Deliveries[0].EventType != EventRecovery {
t.Fatalf("memory channel deliveries=%+v", channel.Deliveries)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := channel.Send(ctx, delivery); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled send error=%v", err)
}
}
func TestNotificationRateLimiterAndRetry(t *testing.T) {
now := time.Unix(100, 0).UTC()
limiter := &RateLimiter{Limit: 2, Window: time.Minute}
if !limiter.Allow("actor:channel", now) || !limiter.Allow("actor:channel", now.Add(time.Second)) || limiter.Allow("actor:channel", now.Add(2*time.Second)) {
t.Fatal("rate limiter did not enforce the window")
}
if !limiter.Allow("actor:channel", now.Add(time.Minute)) {
t.Fatal("rate limiter did not reset")
}
if RetryDelay(1) != time.Second || RetryDelay(MaxAttempts) > 15*time.Minute {
t.Fatal("retry delay bounds are invalid")
}
}
func TestDispatcherTestActionIsRateLimitedAndRecoveryWorks(t *testing.T) {
channel := &MemoryChannel{}
dispatcher := Dispatcher{Channels: map[string]ChannelSender{"channel-1": channel}, Limiter: &RateLimiter{Limit: 1, Window: time.Minute}}
delivery := Delivery{ID: "test-1", EventType: EventRecovery, Subject: "Recovered", Body: "recovered", IdempotencyKey: "test-1", Attempt: 1}
now := time.Unix(100, 0).UTC()
if err := dispatcher.Test(context.Background(), "operator", "channel-1", delivery, now); err != nil {
t.Fatal(err)
}
if err := dispatcher.Test(context.Background(), "operator", "channel-1", delivery, now.Add(time.Second)); !errors.Is(err, ErrConflict) {
t.Fatalf("second test action error=%v", err)
}
if len(channel.Deliveries) != 1 || channel.Deliveries[0].EventType != EventRecovery {
t.Fatalf("recovery delivery=%+v", channel.Deliveries)
}
}
+182
View File
@@ -0,0 +1,182 @@
package notification
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
DefaultWebhookChannelID = "9bf6df3f-7a75-4f8c-91a0-b8d5a2548c21"
WebhookSecretReference = "runtime:webhook-token"
maxWebhookResponseBytes = 4 << 10
)
// SecretResolver resolves an opaque reference without ever putting the secret in
// channel configuration or PostgreSQL. Implementations must allowlist references;
// a database value must never become an arbitrary environment-variable reader.
type SecretResolver interface {
Resolve(context.Context, SecretRef) (string, error)
}
type SecretResolverFunc func(context.Context, SecretRef) (string, error)
func (resolve SecretResolverFunc) Resolve(ctx context.Context, ref SecretRef) (string, error) {
return resolve(ctx, ref)
}
type ChannelSenderFactory interface {
Sender(context.Context, Channel) (ChannelSender, error)
}
type ChannelSenderFactoryFunc func(context.Context, Channel) (ChannelSender, error)
func (factory ChannelSenderFactoryFunc) Sender(ctx context.Context, channel Channel) (ChannelSender, error) {
return factory(ctx, channel)
}
type WebhookFactory struct {
Secrets SecretResolver
Client *http.Client
AllowHTTP bool
}
func (factory WebhookFactory) Sender(ctx context.Context, channel Channel) (ChannelSender, error) {
if channel.Type != "webhook" || factory.Secrets == nil {
return nil, fmt.Errorf("%w: webhook factory is not configured", ErrInvalid)
}
endpoint, _ := channel.Configuration["url"].(string)
timeout := 10 * time.Second
if value, ok := number(channel.Configuration["timeoutSeconds"]); ok {
timeout = time.Duration(value) * time.Second
}
secret, err := factory.Secrets.Resolve(ctx, channel.SecretRef)
if err != nil {
return nil, fmt.Errorf("resolve webhook credential: %w", err)
}
return NewWebhookSender(WebhookConfig{Endpoint: endpoint, BearerToken: secret, Timeout: timeout, AllowHTTP: factory.AllowHTTP}, factory.Client)
}
type WebhookConfig struct {
Endpoint string
BearerToken string
Timeout time.Duration
AllowHTTP bool
}
type WebhookSender struct {
endpoint *url.URL
token string
client *http.Client
}
func NewWebhookSender(config WebhookConfig, client *http.Client) (*WebhookSender, error) {
endpoint, err := url.Parse(strings.TrimSpace(config.Endpoint))
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, fmt.Errorf("%w: webhook endpoint must be an absolute URL", ErrInvalid)
}
if endpoint.Scheme != "https" && !(config.AllowHTTP && endpoint.Scheme == "http") {
return nil, fmt.Errorf("%w: webhook endpoint must use https", ErrInvalid)
}
if endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawQuery != "" {
return nil, fmt.Errorf("%w: webhook endpoint may not contain credentials, query parameters, or a fragment", ErrInvalid)
}
if strings.ContainsAny(config.BearerToken, "\r\n\x00") || len(config.BearerToken) > 4096 {
return nil, fmt.Errorf("%w: webhook credential is invalid", ErrInvalid)
}
timeout := config.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
if timeout < time.Second || timeout > 30*time.Second {
return nil, fmt.Errorf("%w: webhook timeout must be between 1s and 30s", ErrInvalid)
}
if client == nil {
client = &http.Client{}
}
bounded := *client
bounded.Timeout = timeout
bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &WebhookSender{endpoint: endpoint, token: config.BearerToken, client: &bounded}, nil
}
type webhookPayload struct {
Version string `json:"version"`
DeliveryID string `json:"deliveryId"`
IdempotencyKey string `json:"idempotencyKey"`
EventType EventType `json:"eventType"`
Subject string `json:"subject"`
Body string `json:"body"`
Attempt int `json:"attempt"`
}
func (sender *WebhookSender) Send(ctx context.Context, delivery Delivery) error {
if sender == nil || sender.endpoint == nil || sender.client == nil {
return ErrUnavailable
}
if err := ctx.Err(); err != nil {
return err
}
if err := delivery.Validate(); err != nil {
return err
}
payload, err := json.Marshal(webhookPayload{
Version: "1", DeliveryID: delivery.ID, IdempotencyKey: delivery.IdempotencyKey,
EventType: delivery.EventType, Subject: delivery.Subject, Body: delivery.Body, Attempt: delivery.Attempt,
})
if err != nil {
return fmt.Errorf("encode webhook delivery: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sender.endpoint.String(), bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create webhook request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "ITWorx-Pulse/1 notification-webhook")
request.Header.Set("Idempotency-Key", delivery.IdempotencyKey)
request.Header.Set("X-Pulse-Event", string(delivery.EventType))
request.Header.Set("X-Pulse-Attempt", strconv.Itoa(delivery.Attempt))
if sender.token != "" {
request.Header.Set("Authorization", "Bearer "+sender.token)
}
response, err := sender.client.Do(request)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return errors.New("webhook receiver is unreachable")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxWebhookResponseBytes))
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("webhook receiver returned HTTP %d", response.StatusCode)
}
return nil
}
func number(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if typed != float64(int64(typed)) {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
+144
View File
@@ -0,0 +1,144 @@
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)
}
}