Allow retention_days of 0 to mean retain forever (closes #79)
All checks were successful
check / check (push) Successful in 3m4s
All checks were successful
check / check (push) Successful in 3m4s
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. 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 or negative value is a 400 that re-renders the form rather than a silently substituted default. 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. 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.
This commit is contained in:
@@ -77,7 +77,7 @@ func createWebhook(
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: uuid.New().String(),
|
||||
Name: "test-webhook",
|
||||
Name: testWebhookName,
|
||||
RetentionDays: retentionDays,
|
||||
}
|
||||
require.NoError(
|
||||
@@ -85,10 +85,11 @@ func createWebhook(
|
||||
db.Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
|
||||
// The RetentionDays column carries a GORM default of 30, so a
|
||||
// zero (or negative) value passed to Create is replaced by that
|
||||
// default. Force the requested value explicitly so the
|
||||
// retain-forever (<= 0) path can be exercised.
|
||||
// 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).
|
||||
@@ -98,6 +99,30 @@ func createWebhook(
|
||||
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
|
||||
@@ -256,12 +281,59 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
// RetentionDays of zero means retain forever.
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user