All checks were successful
check / check (push) Successful in 2m43s
RetentionDays carried gorm:"default:30", so GORM substituted 30 for a zero value while building the insert. A webhook could therefore never be configured to keep its events indefinitely: the reaper's retain-forever branch existed but was unreachable from the normal create and edit flows. Introduce database.RetentionForeverDays = 365 * 1000 as the sentinel for "retain forever" and a Webhook.BeforeSave hook that rewrites any non-positive RetentionDays to it. The rewrite has to live in the hook rather than at the call sites: GORM applies the column default while converting the model to insert values, which happens after BeforeSave, so anything later loses that race. Putting it on the model also means a future call site, such as the planned REST API, cannot bypass it. The reaper now skips a webhook when Webhook.RetainsForever reports true, which recognises the sentinel and keeps honouring the old <= 0 values for rows written before it existed. Without this the sentinel, being positive, would have produced a cutoff a thousand years in the past and a DELETE matching nothing on every sweep. Bound the finite retention range, which was previously unbounded on the server. The reaper computes its cutoff as a time.Duration, an int64 nanosecond count, so a day count above 106751 overflows, wraps the span negative, and moves the cutoff into the far future — where it matches every row and the sweep deletes every event, delivery, and delivery result the webhook has, including ones created seconds ago. Nothing rejected such a value: parseRetention accepted any v > 0, and max="365" was a client-side attribute a direct POST ignored, so the wipe was already reachable on main and removing that attribute would have made it reachable by ordinary use. The bound is database.MaxFiniteRetentionDays, derived from the arithmetic itself as math.MaxInt64 / time.Hour / hoursPerDay rather than picked as a round number, and a finite value above it is now a 400 that names the ceiling. retentionCutoff additionally clamps the day count it is given and reports whether any cutoff applies at all, so a row written by an older version, a migration, or a future call site cannot reach the overflow either. A value at or above the retain-forever sentinel stays accepted, because that is what the edit form pre-fills for a retain-forever webhook. Form handling is shared by create and edit through parseRetentionDays so the two cannot drift: an empty field keeps the previous behaviour (default on create, unchanged on edit), 0 is honoured, and an unparseable, negative, or out-of-range value is a 400 that re-renders the form rather than a silently substituted default. The two rejection reasons are distinct sentinel errors so the message can name the ceiling, and the create form now carries the submitted name and description back into the re-rendered inputs, which the edit form already did. The retention inputs drop max="365". That cap was not cosmetic: the edit form pre-fills the stored value, so a retain-forever webhook rendered 365000 into an input capped at 365 and browser validation would have blocked saving any edit to it. min becomes 0 with a hint explaining what 0 does, and the list and detail views render a RetentionLabel of "forever" instead of a raw day count. All three Webhook methods take pointer receivers, so there is no receiver mix and no lint suppression: BeforeSave must take a pointer to mutate the record, and the handlers hand templates a *Webhook because html/template cannot call a pointer method on a value held in a map. The 30-day default is consolidated into database.DefaultRetentionDays, referenced from the handler and from the create form's pre-filled value, with a test asserting it agrees with the struct tag that cannot reference it.
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 rescued by the forever skip",
|
|
)
|
|
|
|
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)
|
|
}
|