check / check (push) Waiting to run
Follow-ups from an August review of the retention bounds, each checked against the current tree. A test now pins that a retention value above the keep-forever sentinel is stored as the sentinel. The form's retention parser returns its message directly, so the two error values that were never compared, and the function that mapped them to messages, are gone. The sweep's own keep-forever skip, which duplicated the check in retentionCutoff, is removed; the cutoff is now asked before the webhook's database is opened. The create-form refill test uses HTML-special characters and checks they come back escaped. The README item was already settled; handling for rows made by hand is declined. Model: opus-5-5
403 lines
9.2 KiB
Go
403 lines
9.2 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx/fxtest"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
// retentionTestEnv bundles the pieces a retention test drives.
|
|
type retentionTestEnv struct {
|
|
reaper *database.RetentionReaper
|
|
mainDB *database.Database
|
|
mgr *database.WebhookDBManager
|
|
}
|
|
|
|
func setupRetentionTest(t *testing.T) *retentionTestEnv {
|
|
t.Helper()
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
|
|
g := &globals.Globals{
|
|
Appname: testAppname,
|
|
Version: testVersion,
|
|
}
|
|
|
|
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: "dev",
|
|
}
|
|
|
|
mainDB, err := database.New(lc, database.DatabaseParams{
|
|
Config: cfg,
|
|
Logger: l,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
mgr, err := database.NewWebhookDBManager(
|
|
lc,
|
|
database.WebhookDBManagerParams{Config: cfg, Logger: l},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, lc.Start(ctx))
|
|
t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) })
|
|
|
|
return &retentionTestEnv{
|
|
reaper: database.NewTestRetentionReaper(mainDB, mgr),
|
|
mainDB: mainDB,
|
|
mgr: mgr,
|
|
}
|
|
}
|
|
|
|
// createWebhook inserts a webhook row into the main database with the
|
|
// given retention policy and returns its ID.
|
|
func createWebhook(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
retentionDays int,
|
|
) string {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{
|
|
UserID: uuid.New().String(),
|
|
Name: testWebhookName,
|
|
RetentionDays: retentionDays,
|
|
}
|
|
require.NoError(
|
|
t,
|
|
db.Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
// Webhook.BeforeSave rewrites a non-positive RetentionDays to the
|
|
// retain-forever sentinel, and the column's GORM default would
|
|
// otherwise substitute 30. Force the requested value with a
|
|
// column-level update so tests can plant legacy rows that predate
|
|
// the sentinel and still carry a literal 0 or negative value.
|
|
require.NoError(
|
|
t,
|
|
db.Model(wh).
|
|
Update("retention_days", retentionDays).Error,
|
|
)
|
|
|
|
return wh.ID
|
|
}
|
|
|
|
// createWebhookNormally inserts a webhook through the ordinary Create
|
|
// path, with no column-level forcing, so Webhook.BeforeSave applies
|
|
// exactly as it does in production. Passing 0 therefore yields a row
|
|
// holding the RetentionForeverDays sentinel.
|
|
func createWebhookNormally(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
retentionDays int,
|
|
) string {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{
|
|
UserID: uuid.New().String(),
|
|
Name: testWebhookName,
|
|
RetentionDays: retentionDays,
|
|
}
|
|
require.NoError(
|
|
t,
|
|
db.Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
return wh.ID
|
|
}
|
|
|
|
// eventChain is the set of row IDs seeded for a single event.
|
|
type eventChain struct {
|
|
eventID string
|
|
deliveryID string
|
|
resultID string
|
|
}
|
|
|
|
// seedEventChain creates an event with one delivery and one delivery
|
|
// result, all stamped with createdAt, and returns their IDs.
|
|
func seedEventChain(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
webhookID string,
|
|
createdAt time.Time,
|
|
) eventChain {
|
|
t.Helper()
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Body: `{"seed": true}`,
|
|
ContentType: testContentType,
|
|
}
|
|
event.CreatedAt = createdAt
|
|
require.NoError(t, db.Create(event).Error)
|
|
|
|
delivery := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: uuid.New().String(),
|
|
Status: database.DeliveryStatusDelivered,
|
|
}
|
|
delivery.CreatedAt = createdAt
|
|
require.NoError(t, db.Create(delivery).Error)
|
|
|
|
result := &database.DeliveryResult{
|
|
DeliveryID: delivery.ID,
|
|
AttemptNum: 1,
|
|
Success: true,
|
|
StatusCode: 200,
|
|
Duration: 10,
|
|
}
|
|
result.CreatedAt = createdAt
|
|
require.NoError(t, db.Create(result).Error)
|
|
|
|
return eventChain{
|
|
eventID: event.ID,
|
|
deliveryID: delivery.ID,
|
|
resultID: result.ID,
|
|
}
|
|
}
|
|
|
|
// countByID returns how many rows of model match the given id,
|
|
// counting even hard-deletable rows via Unscoped.
|
|
func countByID(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
model any,
|
|
id string,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
var n int64
|
|
|
|
require.NoError(
|
|
t,
|
|
db.Unscoped().Model(model).
|
|
Where("id = ?", id).Count(&n).Error,
|
|
)
|
|
|
|
return n
|
|
}
|
|
|
|
func assertChainGone(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
chain eventChain,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Zero(
|
|
t,
|
|
countByID(t, db, &database.Event{}, chain.eventID),
|
|
"expired event should be removed",
|
|
)
|
|
assert.Zero(
|
|
t,
|
|
countByID(t, db, &database.Delivery{}, chain.deliveryID),
|
|
"expired delivery should be removed",
|
|
)
|
|
assert.Zero(
|
|
t,
|
|
countByID(
|
|
t, db, &database.DeliveryResult{}, chain.resultID,
|
|
),
|
|
"expired delivery result should be removed",
|
|
)
|
|
}
|
|
|
|
func assertChainPresent(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
chain eventChain,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(
|
|
t,
|
|
int64(1),
|
|
countByID(t, db, &database.Event{}, chain.eventID),
|
|
"recent event should be retained",
|
|
)
|
|
assert.Equal(
|
|
t,
|
|
int64(1),
|
|
countByID(t, db, &database.Delivery{}, chain.deliveryID),
|
|
"recent delivery should be retained",
|
|
)
|
|
assert.Equal(
|
|
t,
|
|
int64(1),
|
|
countByID(
|
|
t, db, &database.DeliveryResult{}, chain.resultID,
|
|
),
|
|
"recent delivery result should be retained",
|
|
)
|
|
}
|
|
|
|
func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupRetentionTest(t)
|
|
|
|
const retentionDays = 30
|
|
|
|
webhookID := createWebhook(
|
|
t, env.mainDB.DB(), retentionDays,
|
|
)
|
|
|
|
db, err := env.mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
old := seedEventChain(
|
|
t, db, webhookID,
|
|
now.Add(-40*24*time.Hour),
|
|
)
|
|
recent := seedEventChain(
|
|
t, db, webhookID,
|
|
now.Add(-1*24*time.Hour),
|
|
)
|
|
|
|
env.reaper.ExportSweep(context.Background())
|
|
|
|
assertChainGone(t, db, old)
|
|
assertChainPresent(t, db, recent)
|
|
}
|
|
|
|
// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
|
|
// end-to-end retain-forever path: a webhook created the normal way with
|
|
// a requested retention of 0 lands on the RetentionForeverDays
|
|
// sentinel, and the reaper leaves its ancient events alone while still
|
|
// reaping a finite-retention webhook in the very same sweep.
|
|
func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := setupRetentionTest(t)
|
|
|
|
foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
|
|
|
|
var stored database.Webhook
|
|
|
|
require.NoError(
|
|
t,
|
|
env.mainDB.DB().Where("id = ?", foreverID).
|
|
First(&stored).Error,
|
|
)
|
|
require.Equal(
|
|
t,
|
|
database.RetentionForeverDays,
|
|
stored.RetentionDays,
|
|
"a requested retention of 0 must persist as the sentinel",
|
|
)
|
|
|
|
finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
|
|
|
|
foreverDB, err := env.mgr.GetDB(foreverID)
|
|
require.NoError(t, err)
|
|
|
|
finiteDB, err := env.mgr.GetDB(finiteID)
|
|
require.NoError(t, err)
|
|
|
|
ancient := time.Now().Add(-365 * 24 * time.Hour)
|
|
kept := seedEventChain(t, foreverDB, foreverID, ancient)
|
|
doomed := seedEventChain(t, finiteDB, finiteID, ancient)
|
|
|
|
env.reaper.ExportSweep(context.Background())
|
|
|
|
assertChainPresent(t, foreverDB, kept)
|
|
assertChainGone(t, finiteDB, doomed)
|
|
}
|
|
|
|
// TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents pins the
|
|
// overflow that made a large finite retention destroy everything.
|
|
//
|
|
// The cutoff is a time.Duration, an int64 nanosecond count. A day
|
|
// count above MaxFiniteRetentionDays multiplied out unclamped wraps
|
|
// negative, so subtracting it moves the cutoff into the far future,
|
|
// where "created_at < cutoff" matches every row: an event created a
|
|
// moment ago, and its delivery and delivery result, were all deleted
|
|
// on the first sweep. 200000 is inside that band and below the
|
|
// retain-forever sentinel, so it is treated as a finite policy and
|
|
// really does reach the arithmetic.
|
|
//
|
|
// The row is planted at the column level because such a value can no
|
|
// longer be submitted through the form; the point of the test is that
|
|
// a row from an older version, or a future call site, still cannot
|
|
// trigger the wipe.
|
|
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := setupRetentionTest(t)
|
|
|
|
const overflowingRetentionDays = 200000
|
|
|
|
require.Greater(
|
|
t,
|
|
overflowingRetentionDays,
|
|
database.MaxFiniteRetentionDays,
|
|
"the test value must exceed what the cutoff can represent",
|
|
)
|
|
require.Less(
|
|
t,
|
|
overflowingRetentionDays,
|
|
database.RetentionForeverDays,
|
|
"the test value must not be treated as retain-forever",
|
|
)
|
|
|
|
webhookID := createWebhook(
|
|
t, env.mainDB.DB(), overflowingRetentionDays,
|
|
)
|
|
|
|
db, err := env.mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := seedEventChain(t, db, webhookID, time.Now())
|
|
|
|
env.reaper.ExportSweep(context.Background())
|
|
|
|
assertChainPresent(t, db, fresh)
|
|
}
|
|
|
|
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupRetentionTest(t)
|
|
|
|
// A legacy row written before the sentinel existed still carries a
|
|
// literal 0; the <= 0 guard must keep honouring it.
|
|
webhookID := createWebhook(t, env.mainDB.DB(), 0)
|
|
|
|
db, err := env.mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
ancient := seedEventChain(
|
|
t, db, webhookID,
|
|
time.Now().Add(-365*24*time.Hour),
|
|
)
|
|
|
|
env.reaper.ExportSweep(context.Background())
|
|
|
|
assertChainPresent(t, db, ancient)
|
|
}
|