All checks were successful
check / check (push) Successful in 5s
Closes [issue #50](#50) ## Summary Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage. ## Changes ### Dockerfile - **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage - **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes - **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256) - **golangci-lint bumped** from v1.64.8 to v2.11.3 - All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest - Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t) ### Linter Config (.golangci.yml) - Migrated from v1 to v2 format (`version: "2"` added) - Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2) - Same set of linters enabled — no rules weakened ### Code Fixes (all lint issues from v2 upgrade) - Added package comments to all packages - Added doc comments to all exported types, functions, and methods - Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint) - Fixed unused parameters flagged by `revive` (renamed to `_`) - Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls - Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf` - Fixed `staticcheck` QF1003: converted if/else chain to tagged switch - Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`) - Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt` - Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores) ### README.md - Updated version requirements: Go 1.26+, golangci-lint v2.11+ - Updated Dockerfile description in project structure ## Verification `docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed. Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #55 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
1677 lines
30 KiB
Go
1677 lines
30 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
_ "modernc.org/sqlite"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
func testWebhookDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(
|
|
t.TempDir(), "events-test.db",
|
|
)
|
|
|
|
dsn := fmt.Sprintf(
|
|
"file:%s?cache=shared&mode=rwc", dbPath,
|
|
)
|
|
|
|
sqlDB, err := sql.Open("sqlite", dsn)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
db, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, db.AutoMigrate(
|
|
&database.Event{},
|
|
&database.Delivery{},
|
|
&database.DeliveryResult{},
|
|
))
|
|
|
|
return db
|
|
}
|
|
|
|
func testEngine(
|
|
t *testing.T, workers int,
|
|
) *delivery.Engine {
|
|
t.Helper()
|
|
|
|
return delivery.NewTestEngine(
|
|
slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
)),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
workers,
|
|
)
|
|
}
|
|
|
|
func newHTTPTargetConfig(url string) string {
|
|
cfg := delivery.HTTPTargetConfig{URL: url}
|
|
|
|
data, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
panic(
|
|
"failed to marshal HTTPTargetConfig: " +
|
|
err.Error(),
|
|
)
|
|
}
|
|
|
|
return string(data)
|
|
}
|
|
|
|
func seedEvent(
|
|
t *testing.T, db *gorm.DB, body string,
|
|
) database.Event {
|
|
t.Helper()
|
|
|
|
event := database.Event{
|
|
WebhookID: uuid.New().String(),
|
|
EntrypointID: uuid.New().String(),
|
|
Method: "POST",
|
|
Headers: `{"Content-Type":["application/json"]}`,
|
|
Body: body,
|
|
ContentType: "application/json",
|
|
}
|
|
|
|
require.NoError(t, db.Create(&event).Error)
|
|
|
|
return event
|
|
}
|
|
|
|
func seedDelivery(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
eventID, targetID string,
|
|
status database.DeliveryStatus,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
d := database.Delivery{
|
|
EventID: eventID,
|
|
TargetID: targetID,
|
|
Status: status,
|
|
}
|
|
|
|
require.NoError(t, db.Create(&d).Error)
|
|
|
|
return d
|
|
}
|
|
|
|
// httpDeliveryFixture holds the task and delivery needed
|
|
// for HTTP delivery tests.
|
|
type httpDeliveryFixture struct {
|
|
Task *delivery.Task
|
|
Delivery *database.Delivery
|
|
}
|
|
|
|
// buildHTTPFixture creates a task and delivery pair for
|
|
// HTTP delivery tests.
|
|
func buildHTTPFixture(
|
|
dlv database.Delivery,
|
|
event database.Event,
|
|
targetID, name, config string,
|
|
maxRetries, attemptNum int,
|
|
) httpDeliveryFixture {
|
|
task := &delivery.Task{
|
|
DeliveryID: dlv.ID,
|
|
EventID: event.ID,
|
|
WebhookID: event.WebhookID,
|
|
TargetID: targetID,
|
|
TargetName: name,
|
|
TargetType: database.TargetTypeHTTP,
|
|
TargetConfig: config,
|
|
MaxRetries: maxRetries,
|
|
AttemptNum: attemptNum,
|
|
}
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: dlv.Status,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: name,
|
|
Type: database.TargetTypeHTTP,
|
|
Config: config,
|
|
MaxRetries: maxRetries,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
return httpDeliveryFixture{
|
|
Task: task, Delivery: d,
|
|
}
|
|
}
|
|
|
|
// assertDeliveryStatus checks that the delivery has the
|
|
// expected status in the database.
|
|
func assertDeliveryStatus(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
deliveryID string,
|
|
expected database.DeliveryStatus,
|
|
) {
|
|
t.Helper()
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, db.First(
|
|
&updated, "id = ?", deliveryID,
|
|
).Error)
|
|
|
|
assert.Equal(t, expected, updated.Status)
|
|
}
|
|
|
|
// assertDeliveryResult checks that a delivery result
|
|
// exists with the expected success and status code.
|
|
func assertDeliveryResult(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
deliveryID string,
|
|
success bool,
|
|
statusCode int,
|
|
) {
|
|
t.Helper()
|
|
|
|
var result database.DeliveryResult
|
|
|
|
require.NoError(t, db.Where(
|
|
"delivery_id = ?", deliveryID,
|
|
).First(&result).Error)
|
|
|
|
assert.Equal(t, success, result.Success)
|
|
|
|
assert.Equal(t, statusCode, result.StatusCode)
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestNotify_NonBlocking(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
for range delivery.ExportDeliveryChannelSize {
|
|
e.ExportDeliveryCh() <- delivery.Task{
|
|
DeliveryID: "fill",
|
|
}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
e.Notify([]delivery.Task{
|
|
{DeliveryID: "overflow-1"},
|
|
{DeliveryID: "overflow-2"},
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal(
|
|
"Notify blocked when delivery channel was full",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestDeliverHTTP_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
var received atomic.Bool
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
received.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, `{"ok":true}`)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
event := seedEvent(t, db, `{"hello":"world"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID, "test-http", cfg, 0, 1,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assert.True(t, received.Load(),
|
|
"HTTP target should have received request",
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
|
|
assertDeliveryResult(
|
|
t, db, dlv.ID, true, http.StatusOK,
|
|
)
|
|
}
|
|
|
|
func TestDeliverHTTP_Failure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = fmt.Fprint(w, "internal error")
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
event := seedEvent(t, db, `{"test":true}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID,
|
|
"test-http-fail", cfg, 0, 1,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
|
|
assertDeliveryResult(
|
|
t, db, dlv.ID, false,
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
func TestDeliverDatabase_ImmediateSuccess(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
|
|
event := seedEvent(t, db, `{"db":"target"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: "test-db",
|
|
Type: database.TargetTypeDatabase,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
e.ExportDeliverDatabase(db, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, db.First(
|
|
&updated, "id = ?", dlv.ID,
|
|
).Error)
|
|
|
|
assert.Equal(t,
|
|
database.DeliveryStatusDelivered, updated.Status,
|
|
"database target should immediately succeed",
|
|
)
|
|
|
|
var result database.DeliveryResult
|
|
|
|
require.NoError(t, db.Where(
|
|
"delivery_id = ?", dlv.ID,
|
|
).First(&result).Error)
|
|
|
|
assert.True(t, result.Success)
|
|
|
|
assert.Equal(t, 0, result.StatusCode,
|
|
"database target should not have an HTTP status",
|
|
)
|
|
}
|
|
|
|
func TestDeliverLog_ImmediateSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
|
|
event := seedEvent(t, db, `{"log":"target"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: "test-log",
|
|
Type: database.TargetTypeLog,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
e.ExportDeliverLog(db, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, db.First(
|
|
&updated, "id = ?", dlv.ID,
|
|
).Error)
|
|
|
|
assert.Equal(t,
|
|
database.DeliveryStatusDelivered, updated.Status,
|
|
"log target should immediately succeed",
|
|
)
|
|
|
|
var result database.DeliveryResult
|
|
|
|
require.NoError(t, db.Where(
|
|
"delivery_id = ?", dlv.ID,
|
|
).First(&result).Error)
|
|
|
|
assert.True(t, result.Success)
|
|
}
|
|
|
|
func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
event := seedEvent(t, db, `{"retry":"ok"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID,
|
|
"test-http-retry", cfg, 5, 1,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
|
|
cb := e.ExportGetCircuitBreaker(targetID)
|
|
|
|
assert.Equal(t,
|
|
delivery.CircuitClosed, cb.State(),
|
|
)
|
|
}
|
|
|
|
func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
event := seedEvent(t, db, `{"retry":"exhaust"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusRetrying,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID,
|
|
"test-http-exhaust", cfg, 3, 3,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
}
|
|
|
|
func TestDeliverHTTP_SchedulesRetryOnFailure(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(
|
|
http.StatusServiceUnavailable,
|
|
)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
event := seedEvent(t, db, `{"retry":"schedule"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID,
|
|
"test-http-schedule", cfg, 5, 1,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusRetrying,
|
|
)
|
|
|
|
assertDeliveryResult(
|
|
t, db, dlv.ID, false,
|
|
http.StatusServiceUnavailable,
|
|
)
|
|
}
|
|
|
|
func TestExponentialBackoff_Durations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expected := []time.Duration{
|
|
1 * time.Second,
|
|
2 * time.Second,
|
|
4 * time.Second,
|
|
8 * time.Second,
|
|
16 * time.Second,
|
|
}
|
|
|
|
for attemptNum := 1; attemptNum <= 5; attemptNum++ {
|
|
shift := attemptNum - 1
|
|
|
|
shift = min(shift, 30)
|
|
|
|
backoff := time.Duration(
|
|
1<<uint(shift),
|
|
) * time.Second
|
|
|
|
assert.Equal(t,
|
|
expected[attemptNum-1], backoff,
|
|
"backoff for attempt %d should be %v",
|
|
attemptNum,
|
|
expected[attemptNum-1],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestExponentialBackoff_CappedAt30(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
attemptNum := 50
|
|
shift := attemptNum - 1
|
|
|
|
shift = min(shift, 30)
|
|
|
|
backoff := time.Duration(
|
|
1<<uint(shift),
|
|
) * time.Second
|
|
|
|
assert.Equal(t,
|
|
time.Duration(1<<30)*time.Second, backoff,
|
|
"backoff shift should be capped at 30",
|
|
)
|
|
}
|
|
|
|
func TestBodyPointer_SmallBodyInline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
smallBody := `{"small": true}`
|
|
|
|
assert.Less(t,
|
|
len(smallBody), delivery.MaxInlineBodySize,
|
|
)
|
|
|
|
var bodyPtr *string
|
|
if len(smallBody) < delivery.MaxInlineBodySize {
|
|
bodyPtr = &smallBody
|
|
}
|
|
|
|
require.NotNil(t, bodyPtr,
|
|
"small body should be inline (non-nil)",
|
|
)
|
|
|
|
assert.Equal(t, smallBody, *bodyPtr)
|
|
}
|
|
|
|
func TestBodyPointer_LargeBodyNil(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
largeBody := strings.Repeat(
|
|
"x", delivery.MaxInlineBodySize,
|
|
)
|
|
|
|
assert.GreaterOrEqual(t,
|
|
len(largeBody), delivery.MaxInlineBodySize,
|
|
)
|
|
|
|
var bodyPtr *string
|
|
if len(largeBody) < delivery.MaxInlineBodySize {
|
|
bodyPtr = &largeBody
|
|
}
|
|
|
|
assert.Nil(t, bodyPtr,
|
|
"large body should be nil",
|
|
)
|
|
}
|
|
|
|
func TestBodyPointer_ExactBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
exactBody := strings.Repeat(
|
|
"y", delivery.MaxInlineBodySize,
|
|
)
|
|
|
|
assert.Len(t, exactBody, delivery.MaxInlineBodySize)
|
|
|
|
var bodyPtr *string
|
|
if len(exactBody) < delivery.MaxInlineBodySize {
|
|
bodyPtr = &exactBody
|
|
}
|
|
|
|
assert.Nil(t, bodyPtr,
|
|
"body at exactly MaxInlineBodySize should be nil",
|
|
)
|
|
}
|
|
|
|
// concurrencyTracker tracks concurrent request count.
|
|
type concurrencyTracker struct {
|
|
mu sync.Mutex
|
|
current int
|
|
maxSeen int
|
|
}
|
|
|
|
func (ct *concurrencyTracker) enter() {
|
|
ct.mu.Lock()
|
|
ct.current++
|
|
|
|
if ct.current > ct.maxSeen {
|
|
ct.maxSeen = ct.current
|
|
}
|
|
|
|
ct.mu.Unlock()
|
|
}
|
|
|
|
func (ct *concurrencyTracker) leave() {
|
|
ct.mu.Lock()
|
|
ct.current--
|
|
ct.mu.Unlock()
|
|
}
|
|
|
|
func (ct *concurrencyTracker) max() int {
|
|
ct.mu.Lock()
|
|
defer ct.mu.Unlock()
|
|
|
|
return ct.maxSeen
|
|
}
|
|
|
|
// seedConcurrencyTasks creates tasks and deliveries
|
|
// for the concurrency test.
|
|
func seedConcurrencyTasks(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
n int,
|
|
targetCfg string,
|
|
) ([]database.Delivery, []delivery.Task) {
|
|
t.Helper()
|
|
|
|
tasks := make([]database.Delivery, n)
|
|
dtasks := make([]delivery.Task, n)
|
|
|
|
for i := range n {
|
|
event := seedEvent(
|
|
t, db, fmt.Sprintf(`{"task":%d}`, i),
|
|
)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID,
|
|
uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
tasks[i] = database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: fmt.Sprintf("task-%d", i),
|
|
Type: database.TargetTypeHTTP,
|
|
Config: targetCfg,
|
|
},
|
|
}
|
|
tasks[i].ID = dlv.ID
|
|
|
|
dtasks[i] = delivery.Task{
|
|
DeliveryID: tasks[i].ID,
|
|
EventID: tasks[i].EventID,
|
|
TargetID: tasks[i].TargetID,
|
|
TargetName: tasks[i].Target.Name,
|
|
TargetType: tasks[i].Target.Type,
|
|
TargetConfig: tasks[i].Target.Config,
|
|
MaxRetries: 0,
|
|
AttemptNum: 1,
|
|
}
|
|
}
|
|
|
|
return tasks, dtasks
|
|
}
|
|
|
|
func TestWorkerPool_BoundedConcurrency(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping concurrency test in short mode")
|
|
}
|
|
|
|
t.Parallel()
|
|
|
|
const (
|
|
numWorkers = 3
|
|
numTasks = 10
|
|
)
|
|
|
|
db := testWebhookDB(t)
|
|
ct := &concurrencyTracker{}
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
ct.enter()
|
|
time.Sleep(100 * time.Millisecond)
|
|
ct.leave()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, numWorkers)
|
|
cfg := newHTTPTargetConfig(ts.URL)
|
|
tasks, dtasks := seedConcurrencyTasks(
|
|
t, db, numTasks, cfg,
|
|
)
|
|
|
|
runConcurrencyWorkers(
|
|
e, db, tasks, dtasks, numWorkers,
|
|
)
|
|
|
|
assert.LessOrEqual(t, ct.max(), numWorkers)
|
|
|
|
assertAllDelivered(t, db, tasks)
|
|
}
|
|
|
|
func runConcurrencyWorkers(
|
|
e *delivery.Engine,
|
|
db *gorm.DB,
|
|
tasks []database.Delivery,
|
|
dtasks []delivery.Task,
|
|
workers int,
|
|
) {
|
|
var wg sync.WaitGroup
|
|
|
|
taskCh := make(chan int, len(tasks))
|
|
|
|
for i := range tasks {
|
|
taskCh <- i
|
|
}
|
|
|
|
close(taskCh)
|
|
|
|
for range workers {
|
|
wg.Go(func() {
|
|
for idx := range taskCh {
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db,
|
|
&tasks[idx],
|
|
&dtasks[idx],
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func assertAllDelivered(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
tasks []database.Delivery,
|
|
) {
|
|
t.Helper()
|
|
|
|
for i := range tasks {
|
|
assertDeliveryStatus(
|
|
t, db, tasks[i].ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
|
|
cb := e.ExportGetCircuitBreaker(targetID)
|
|
|
|
for range delivery.ExportDefaultFailureThreshold {
|
|
cb.RecordFailure()
|
|
}
|
|
|
|
require.Equal(t, delivery.CircuitOpen, cb.State())
|
|
|
|
event := seedEvent(t, db, `{"cb":"blocked"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
cfg := newHTTPTargetConfig(
|
|
"http://will-not-be-called.invalid",
|
|
)
|
|
fix := buildHTTPFixture(
|
|
dlv, event, targetID,
|
|
"test-cb-block", cfg, 5, 1,
|
|
)
|
|
|
|
e.ExportDeliverHTTP(
|
|
context.TODO(), db, fix.Delivery, fix.Task,
|
|
)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusRetrying,
|
|
)
|
|
|
|
var resultCount int64
|
|
|
|
db.Model(&database.DeliveryResult{}).
|
|
Where("delivery_id = ?", dlv.ID).
|
|
Count(&resultCount)
|
|
|
|
assert.Equal(t, int64(0), resultCount,
|
|
"no delivery result when circuit is open",
|
|
)
|
|
}
|
|
|
|
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
targetID := uuid.New().String()
|
|
cb1 := e.ExportGetCircuitBreaker(targetID)
|
|
|
|
require.NotNil(t, cb1)
|
|
|
|
assert.Equal(t,
|
|
delivery.CircuitClosed, cb1.State(),
|
|
)
|
|
|
|
cb2 := e.ExportGetCircuitBreaker(targetID)
|
|
|
|
assert.Same(t, cb1, cb2,
|
|
"same target ID should return the "+
|
|
"same circuit breaker",
|
|
)
|
|
|
|
otherID := uuid.New().String()
|
|
cb3 := e.ExportGetCircuitBreaker(otherID)
|
|
|
|
assert.NotSame(t, cb1, cb3,
|
|
"different target ID should return a "+
|
|
"different circuit breaker",
|
|
)
|
|
}
|
|
|
|
func TestParseHTTPConfig_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
cfg, err := e.ExportParseHTTPConfig(
|
|
`{"url":"https://example.com/hook",` +
|
|
`"headers":{"X-Token":"secret"}}`,
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://example.com/hook", cfg.URL)
|
|
assert.Equal(t, "secret", cfg.Headers["X-Token"])
|
|
}
|
|
|
|
func TestParseHTTPConfig_Empty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
_, err := e.ExportParseHTTPConfig("")
|
|
|
|
assert.Error(t, err,
|
|
"empty config should return error",
|
|
)
|
|
}
|
|
|
|
func TestParseHTTPConfig_MissingURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
_, err := e.ExportParseHTTPConfig(
|
|
`{"headers":{"X-Token":"secret"}}`,
|
|
)
|
|
|
|
assert.Error(t, err,
|
|
"config without URL should return error",
|
|
)
|
|
}
|
|
|
|
func TestScheduleRetry_SendsToRetryChannel(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
task := delivery.Task{
|
|
DeliveryID: uuid.New().String(),
|
|
EventID: uuid.New().String(),
|
|
WebhookID: uuid.New().String(),
|
|
TargetID: uuid.New().String(),
|
|
AttemptNum: 2,
|
|
}
|
|
|
|
e.ExportScheduleRetry(task, 10*time.Millisecond)
|
|
|
|
select {
|
|
case received := <-e.ExportRetryCh():
|
|
assert.Equal(t,
|
|
task.DeliveryID, received.DeliveryID,
|
|
)
|
|
|
|
assert.Equal(t,
|
|
task.AttemptNum, received.AttemptNum,
|
|
)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal(
|
|
"retry task was not sent to retry " +
|
|
"channel within timeout",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestScheduleRetry_DropsWhenChannelFull(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
e := delivery.NewTestEngineSmallRetry(
|
|
slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
)),
|
|
)
|
|
|
|
e.ExportRetryCh() <- delivery.Task{DeliveryID: "fill"}
|
|
|
|
task := delivery.Task{
|
|
DeliveryID: "overflow",
|
|
AttemptNum: 2,
|
|
}
|
|
|
|
e.ExportScheduleRetry(task, 0)
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
received := <-e.ExportRetryCh()
|
|
|
|
assert.Equal(t, "fill", received.DeliveryID,
|
|
"only the original task should be in the "+
|
|
"channel (overflow was dropped)",
|
|
)
|
|
}
|
|
|
|
func TestIsForwardableHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.True(t,
|
|
delivery.ExportIsForwardableHeader("X-Custom-Header"),
|
|
)
|
|
|
|
assert.True(t,
|
|
delivery.ExportIsForwardableHeader("Authorization"),
|
|
)
|
|
|
|
assert.True(t,
|
|
delivery.ExportIsForwardableHeader("Accept"),
|
|
)
|
|
|
|
assert.True(t,
|
|
delivery.ExportIsForwardableHeader("X-GitHub-Event"),
|
|
)
|
|
|
|
assert.False(t,
|
|
delivery.ExportIsForwardableHeader("Host"),
|
|
)
|
|
|
|
assert.False(t,
|
|
delivery.ExportIsForwardableHeader("Connection"),
|
|
)
|
|
|
|
assert.False(t,
|
|
delivery.ExportIsForwardableHeader("Keep-Alive"),
|
|
)
|
|
|
|
assert.False(t,
|
|
delivery.ExportIsForwardableHeader(
|
|
"Transfer-Encoding",
|
|
),
|
|
)
|
|
|
|
assert.False(t,
|
|
delivery.ExportIsForwardableHeader("Content-Length"),
|
|
)
|
|
}
|
|
|
|
func TestTruncate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t,
|
|
"hello", delivery.ExportTruncate("hello", 10),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"hello", delivery.ExportTruncate("hello", 5),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"hel", delivery.ExportTruncate("hello", 3),
|
|
)
|
|
|
|
assert.Empty(t, delivery.ExportTruncate("", 5))
|
|
}
|
|
|
|
func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var receivedHeaders http.Header
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
receivedHeaders = r.Header.Clone()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
cfg := &delivery.HTTPTargetConfig{
|
|
URL: ts.URL,
|
|
Headers: map[string]string{
|
|
"X-Target-Auth": "bearer xyz",
|
|
},
|
|
}
|
|
|
|
event := &database.Event{
|
|
Method: "POST",
|
|
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
|
|
Body: `{"test":true}`,
|
|
ContentType: "application/json",
|
|
}
|
|
|
|
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
|
context.TODO(), cfg, event,
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, http.StatusOK, statusCode)
|
|
|
|
assert.Equal(t,
|
|
"value1",
|
|
receivedHeaders.Get("X-Custom"),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"bearer xyz",
|
|
receivedHeaders.Get("X-Target-Auth"),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"application/json",
|
|
receivedHeaders.Get("Content-Type"),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"webhooker/1.0",
|
|
receivedHeaders.Get("User-Agent"),
|
|
)
|
|
}
|
|
|
|
func TestProcessDelivery_RoutesToCorrectHandler(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
|
|
tests := []struct {
|
|
name string
|
|
targetType database.TargetType
|
|
wantStatus database.DeliveryStatus
|
|
}{
|
|
{
|
|
"database target",
|
|
database.TargetTypeDatabase,
|
|
database.DeliveryStatusDelivered,
|
|
},
|
|
{
|
|
"log target",
|
|
database.TargetTypeLog,
|
|
database.DeliveryStatusDelivered,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runRoutingSubtest(
|
|
t, db, e, tt.targetType,
|
|
tt.wantStatus,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func runRoutingSubtest(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
e *delivery.Engine,
|
|
targetType database.TargetType,
|
|
wantStatus database.DeliveryStatus,
|
|
) {
|
|
t.Helper()
|
|
|
|
event := seedEvent(t, db, `{"routing":"test"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID,
|
|
uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: "test-" + string(targetType),
|
|
Type: targetType,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
task := &delivery.Task{
|
|
DeliveryID: dlv.ID,
|
|
TargetType: targetType,
|
|
}
|
|
|
|
e.ExportProcessDelivery(
|
|
context.TODO(), db, d, task,
|
|
)
|
|
|
|
assertDeliveryStatus(
|
|
t, db, dlv.ID, wantStatus,
|
|
)
|
|
}
|
|
|
|
func TestMaxInlineBodySize_Constant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, 16*1024, delivery.MaxInlineBodySize,
|
|
"MaxInlineBodySize should be 16KB",
|
|
)
|
|
}
|
|
|
|
func TestParseSlackConfig_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
cfg, err := e.ExportParseSlackConfig(
|
|
`{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}`,
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t,
|
|
"https://hooks.slack.com/services/T00/B00/xxx",
|
|
cfg.WebhookURL,
|
|
)
|
|
}
|
|
|
|
func TestParseSlackConfig_Empty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
_, err := e.ExportParseSlackConfig("")
|
|
|
|
assert.Error(t, err,
|
|
"empty config should return error",
|
|
)
|
|
}
|
|
|
|
func TestParseSlackConfig_MissingWebhookURL(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
_, err := e.ExportParseSlackConfig(
|
|
`{"other":"field"}`,
|
|
)
|
|
|
|
assert.Error(t, err,
|
|
"config without webhook_url should return error",
|
|
)
|
|
}
|
|
|
|
func TestFormatSlackMessage_JSONBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
event := &database.Event{
|
|
Method: "POST",
|
|
ContentType: "application/json",
|
|
Body: `{"action":"push",` +
|
|
`"repo":"test/repo",` +
|
|
`"ref":"refs/heads/main"}`,
|
|
}
|
|
event.CreatedAt = time.Date(
|
|
2025, 1, 15, 10, 30, 0, 0, time.UTC,
|
|
)
|
|
|
|
msg := delivery.FormatSlackMessage(event)
|
|
|
|
assert.Contains(t, msg, "*Webhook Event Received*")
|
|
assert.Contains(t, msg, "`POST`")
|
|
assert.Contains(t, msg, "`application/json`")
|
|
assert.Contains(t, msg, "```")
|
|
assert.NotContains(t, msg, "```json")
|
|
|
|
assert.Contains(t, msg, ` "action": "push"`)
|
|
assert.Contains(t, msg, ` "repo": "test/repo"`)
|
|
}
|
|
|
|
func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
event := &database.Event{
|
|
Method: "POST",
|
|
ContentType: "text/plain",
|
|
Body: "hello world plain text",
|
|
}
|
|
event.CreatedAt = time.Date(
|
|
2025, 1, 15, 10, 30, 0, 0, time.UTC,
|
|
)
|
|
|
|
msg := delivery.FormatSlackMessage(event)
|
|
|
|
assert.Contains(t, msg, "*Webhook Event Received*")
|
|
|
|
assert.Contains(t, msg,
|
|
"```\nhello world plain text\n```",
|
|
)
|
|
|
|
assert.NotContains(t, msg, "```json")
|
|
}
|
|
|
|
func TestFormatSlackMessage_EmptyBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
event := &database.Event{
|
|
Method: "POST",
|
|
ContentType: "application/json",
|
|
Body: "",
|
|
}
|
|
event.CreatedAt = time.Date(
|
|
2025, 1, 15, 10, 30, 0, 0, time.UTC,
|
|
)
|
|
|
|
msg := delivery.FormatSlackMessage(event)
|
|
|
|
assert.Contains(t, msg, "_(empty body)_")
|
|
assert.NotContains(t, msg, "```")
|
|
}
|
|
|
|
func TestFormatSlackMessage_LargeJSONTruncated(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
largeObj := make(map[string]string)
|
|
|
|
for i := range 200 {
|
|
largeObj[fmt.Sprintf("key_%03d", i)] = strings.Repeat("v", 20)
|
|
}
|
|
|
|
largeJSON, err := json.Marshal(largeObj)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
Method: "POST",
|
|
ContentType: "application/json",
|
|
Body: string(largeJSON),
|
|
}
|
|
event.CreatedAt = time.Date(
|
|
2025, 1, 15, 10, 30, 0, 0, time.UTC,
|
|
)
|
|
|
|
msg := delivery.FormatSlackMessage(event)
|
|
|
|
assert.Contains(t, msg, "... (truncated)")
|
|
}
|
|
|
|
// buildSlackDelivery creates a database.Delivery
|
|
// with a Slack target config for testing.
|
|
func buildSlackDelivery(
|
|
dlv database.Delivery,
|
|
event database.Event,
|
|
targetID, name, slackCfg string,
|
|
) *database.Delivery {
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: dlv.Status,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: name,
|
|
Type: database.TargetTypeSlack,
|
|
Config: slackCfg,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
return d
|
|
}
|
|
|
|
func TestDeliverSlack_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
var receivedBody string
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
bodyBytes, readErr := readAll(r.Body)
|
|
if readErr != nil {
|
|
http.Error(
|
|
w, "read error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
receivedBody = string(bodyBytes)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, "ok")
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e, targetID, slackCfg, event, dlv :=
|
|
setupSlackTest(t, db, ts.URL)
|
|
|
|
d := buildSlackDelivery(
|
|
dlv, event, targetID, "test-slack", slackCfg,
|
|
)
|
|
|
|
e.ExportDeliverSlack(context.TODO(), db, d)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
|
|
assertDeliveryResult(
|
|
t, db, dlv.ID, true, http.StatusOK,
|
|
)
|
|
|
|
assertSlackPayload(t, receivedBody)
|
|
}
|
|
|
|
func setupSlackTest(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
serverURL string,
|
|
) (
|
|
*delivery.Engine, string, string,
|
|
database.Event, database.Delivery,
|
|
) {
|
|
t.Helper()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
|
|
cfgBytes, err := json.Marshal(
|
|
delivery.SlackTargetConfig{
|
|
WebhookURL: serverURL,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
event := seedEvent(
|
|
t, db, `{"action":"test","data":"value"}`,
|
|
)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
return e, targetID, string(cfgBytes), event, dlv
|
|
}
|
|
|
|
func assertSlackPayload(
|
|
t *testing.T, receivedBody string,
|
|
) {
|
|
t.Helper()
|
|
|
|
var slackPayload map[string]string
|
|
|
|
require.NoError(t, json.Unmarshal(
|
|
[]byte(receivedBody), &slackPayload,
|
|
))
|
|
|
|
assert.Contains(t,
|
|
slackPayload["text"],
|
|
"*Webhook Event Received*",
|
|
)
|
|
|
|
assert.NotContains(t,
|
|
slackPayload["text"],
|
|
"**Webhook Event Received**",
|
|
)
|
|
|
|
assert.Contains(t, slackPayload["text"], "```")
|
|
|
|
assert.NotContains(t,
|
|
slackPayload["text"], "```json",
|
|
)
|
|
}
|
|
|
|
func TestDeliverSlack_Failure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = fmt.Fprint(w, "invalid_token")
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
|
|
slackCfg, err := json.Marshal(
|
|
delivery.SlackTargetConfig{
|
|
WebhookURL: ts.URL,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
event := seedEvent(t, db, `{"test":true}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := buildSlackDelivery(
|
|
dlv, event, targetID,
|
|
"test-slack-fail", string(slackCfg),
|
|
)
|
|
|
|
e.ExportDeliverSlack(context.TODO(), db, d)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
|
|
assertDeliveryResult(
|
|
t, db, dlv.ID, false, http.StatusForbidden,
|
|
)
|
|
}
|
|
|
|
func TestDeliverSlack_InvalidConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
|
|
event := seedEvent(t, db, `{"test":true}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: "test-slack-bad",
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{"not_webhook_url":"missing"}`,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
e.ExportDeliverSlack(context.TODO(), db, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, db.First(
|
|
&updated, "id = ?", dlv.ID,
|
|
).Error)
|
|
|
|
assert.Equal(t,
|
|
database.DeliveryStatusFailed, updated.Status,
|
|
)
|
|
}
|
|
|
|
func TestProcessDelivery_RoutesToSlack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := testWebhookDB(t)
|
|
|
|
var received atomic.Bool
|
|
|
|
ts := httptest.NewServer(
|
|
http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
received.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
),
|
|
)
|
|
defer ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
slackCfg, err := json.Marshal(
|
|
delivery.SlackTargetConfig{
|
|
WebhookURL: ts.URL,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
event := seedEvent(t, db, `{"route":"slack"}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := buildSlackDelivery(
|
|
dlv, event, dlv.TargetID,
|
|
"test-slack-route", string(slackCfg),
|
|
)
|
|
|
|
task := &delivery.Task{
|
|
DeliveryID: dlv.ID,
|
|
TargetType: database.TargetTypeSlack,
|
|
}
|
|
|
|
e.ExportProcessDelivery(
|
|
context.TODO(), db, d, task,
|
|
)
|
|
|
|
assert.True(t, received.Load())
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// readAll is a small helper to avoid importing io in
|
|
// a test handler inline.
|
|
func readAll(r interface {
|
|
Read(p []byte) (n int, err error)
|
|
}) ([]byte, error) {
|
|
var buf []byte
|
|
|
|
tmp := make([]byte, 1024)
|
|
|
|
for {
|
|
n, err := r.Read(tmp)
|
|
buf = append(buf, tmp[:n]...)
|
|
|
|
if err != nil {
|
|
if err.Error() == "EOF" {
|
|
return buf, nil
|
|
}
|
|
|
|
return buf, err
|
|
}
|
|
}
|
|
}
|