This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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), ¤t); 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), ¤t); 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user