Public source validation / validate (push) Failing after 3m8s
250 lines
9.4 KiB
Go
250 lines
9.4 KiB
Go
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))
|
|
}
|
|
}
|