All checks were successful
check / check (push) Successful in 2m39s
- README: rewrite the database-target documentation (target-types
bullet and the per-webhook databases section) to describe the
shipped archiving semantics -- separate archive-{webhookID}.db,
debounced close/reopen for offline archiving, auto-recreate,
creation-validated optional expiry with prune-on-open, and
fail-loud delivery on archive write errors -- replacing the
stale always-successful stub description.
- parseArchiveExpiry now returns an error for set-but-non-positive
durations ("0s", "-5h") instead of silently defaulting to
keep-forever, matching ValidateArchiveExpiry at creation time;
the delivery then fails loudly like any other archive error.
TestParseArchiveExpiry extended with zero and negative cases.
- databaseTarget type comment: "fire-and-forget" -> "no-retry",
matching the fail-loud behaviour.
396 lines
9.4 KiB
Go
396 lines
9.4 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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" // Pure Go SQLite driver.
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
func archiveTestLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
}
|
|
|
|
// openArchiveDBForRead opens an archive file read-only so a
|
|
// test can inspect the rows the writer persisted.
|
|
func openArchiveDBForRead(
|
|
t *testing.T, path string,
|
|
) *gorm.DB {
|
|
t.Helper()
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite",
|
|
fmt.Sprintf("file:%s?mode=ro", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return gdb
|
|
}
|
|
|
|
// removeArchiveFiles simulates an operator moving the archive
|
|
// away by deleting the SQLite file and its sidecar files.
|
|
func removeArchiveFiles(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
for _, suffix := range []string{
|
|
"", "-wal", "-shm", "-journal",
|
|
} {
|
|
err := os.Remove(path + suffix)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
t.Fatalf("removing %s%s: %v", path, suffix, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
|
// a database target marks the delivery delivered and archives
|
|
// the full event into a separate per-webhook archive file.
|
|
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := t.TempDir()
|
|
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
e := delivery.NewTestEngineWithDB(
|
|
nil, dbMgr,
|
|
archiveTestLogger(),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
1,
|
|
)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
|
|
e.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusDelivered, updated.Status,
|
|
"database target should mark the delivery delivered",
|
|
)
|
|
|
|
archivePath := filepath.Join(
|
|
dataDir,
|
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
)
|
|
assert.FileExists(t, archivePath)
|
|
|
|
rdb := openArchiveDBForRead(t, archivePath)
|
|
|
|
var rows []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, rdb.Find(&rows).Error)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, event.ID, rows[0].EventID)
|
|
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
|
assert.Equal(t, event.Method, rows[0].Method)
|
|
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
|
}
|
|
|
|
func TestArchiveWriter_WritesRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
row := delivery.ExportArchivedEvent{
|
|
EventID: "ev-1",
|
|
WebhookID: "wh-1",
|
|
EntrypointID: "ep-1",
|
|
Method: "POST",
|
|
Headers: `{"X":"Y"}`,
|
|
Body: `{"hello":"world"}`,
|
|
ContentType: "application/json",
|
|
}
|
|
|
|
require.NoError(t, w.Write(row, 0))
|
|
assert.FileExists(t, path)
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "ev-1", got[0].EventID)
|
|
assert.Equal(t, "wh-1", got[0].WebhookID)
|
|
assert.Equal(t, "ep-1", got[0].EntrypointID)
|
|
assert.Equal(t, row.Method, got[0].Method)
|
|
assert.Equal(t, row.ContentType, got[0].ContentType)
|
|
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
|
assert.False(t, got[0].ArchivedAt.IsZero())
|
|
}
|
|
|
|
func TestArchiveWriter_RecreatesAfterRemoval(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
|
))
|
|
assert.FileExists(t, path)
|
|
|
|
// The operator moves the archive away while the handle is
|
|
// still open.
|
|
removeArchiveFiles(t, path)
|
|
require.NoFileExists(t, path)
|
|
|
|
// The next write recreates the file with a fresh schema and
|
|
// only the new row.
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
|
))
|
|
assert.FileExists(t, path)
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "b", got[0].EventID)
|
|
}
|
|
|
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A generous debounce keeps the two rapid writes inside
|
|
// the window even on a heavily loaded test machine.
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 2*time.Second,
|
|
)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
|
))
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
|
))
|
|
|
|
// Two writes inside the debounce window trigger only the
|
|
// initial open — no extra close/reopen.
|
|
assert.Equal(t, 1, w.Reopens())
|
|
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
|
))
|
|
|
|
// A write after the window elapses closes and reopens once.
|
|
assert.Equal(t, 2, w.Reopens())
|
|
}
|
|
|
|
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
require.NoError(t, w.Open(0))
|
|
|
|
old := delivery.ExportArchivedEvent{
|
|
EventID: "old",
|
|
ArchivedAt: time.Now().Add(-2 * time.Hour),
|
|
}
|
|
fresh := delivery.ExportArchivedEvent{
|
|
EventID: "fresh",
|
|
ArchivedAt: time.Now(),
|
|
}
|
|
|
|
require.NoError(t, w.DB().Create(&old).Error)
|
|
require.NoError(t, w.DB().Create(&fresh).Error)
|
|
|
|
// Reopening with a one-hour expiry prunes the old row.
|
|
require.NoError(t, w.Reopen(time.Hour))
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "fresh", got[0].EventID)
|
|
}
|
|
|
|
func TestParseArchiveExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want time.Duration
|
|
wantErr bool
|
|
}{
|
|
{"empty config", "", 0, false},
|
|
{"explicit never", `{"expiry":"never"}`, 0, false},
|
|
{"empty expiry", `{"expiry":""}`, 0, false},
|
|
{"duration", `{"expiry":"1h"}`, time.Hour, false},
|
|
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
|
|
{"zero duration", `{"expiry":"0s"}`, 0, true},
|
|
{"negative duration", `{"expiry":"-5h"}`, 0, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := delivery.ExportParseArchiveExpiry(tc.in)
|
|
if tc.wantErr {
|
|
require.Error(t, err)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
|
// database target with the given config JSON and returns the
|
|
// in-memory delivery the target handler is invoked with.
|
|
func seedDatabaseTargetDelivery(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
event database.Event,
|
|
config string,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
dlv := seedDelivery(
|
|
t, webhookDB, 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,
|
|
Config: config,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
return d
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
|
|
// an archive error (here: an unparseable expiry in the target
|
|
// config) fails the delivery loudly: the attempt is recorded as
|
|
// failed with the error and the delivery is marked failed, not
|
|
// delivered.
|
|
func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
e := delivery.NewTestEngineWithDB(
|
|
nil, database.NewTestWebhookDBManager(dataDir),
|
|
archiveTestLogger(),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
1,
|
|
)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
|
d := seedDatabaseTargetDelivery(
|
|
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
|
)
|
|
|
|
e.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusFailed, updated.Status,
|
|
"archive failure must mark the delivery failed",
|
|
)
|
|
|
|
var results []database.DeliveryResult
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"delivery_id = ?", d.ID,
|
|
).Find(&results).Error)
|
|
require.Len(t, results, 1)
|
|
assert.False(t,
|
|
results[0].Success,
|
|
"the attempt must be recorded as failed",
|
|
)
|
|
assert.Contains(t,
|
|
results[0].Error, "nonsense",
|
|
"the archive error must be recorded on the attempt",
|
|
)
|
|
|
|
assert.NoFileExists(t,
|
|
filepath.Join(
|
|
dataDir,
|
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
),
|
|
"no archive file should exist for a failed config",
|
|
)
|
|
}
|
|
|
|
func TestValidateArchiveExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{"", "never", "1h", "720h", "30m"}
|
|
for _, in := range valid {
|
|
require.NoError(t,
|
|
delivery.ValidateArchiveExpiry(in),
|
|
"expiry %q should be accepted", in,
|
|
)
|
|
}
|
|
|
|
invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
|
|
for _, in := range invalid {
|
|
require.Error(t,
|
|
delivery.ValidateArchiveExpiry(in),
|
|
"expiry %q should be rejected", in,
|
|
)
|
|
}
|
|
}
|