Allow retention_days of 0 to mean retain forever (closes #79)
All checks were successful
check / check (push) Successful in 2m43s
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.
This commit is contained in:
@@ -18,6 +18,11 @@ const (
|
||||
testVersion = "test"
|
||||
// testContentType is the event content type used in tests.
|
||||
testContentType = "application/json"
|
||||
// testWebhookName is the Webhook.Name used in tests.
|
||||
testWebhookName = "test-webhook"
|
||||
// testForeverLabel is Webhook.RetentionLabel for a retain-forever
|
||||
// webhook.
|
||||
testForeverLabel = "forever"
|
||||
)
|
||||
|
||||
func setupTestDB(
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetentionDays is the event retention period applied to a
|
||||
// webhook created without an explicit retention value. It is the
|
||||
// single source of truth for that policy and must stay in sync
|
||||
// with the `gorm:"default:30"` column default on
|
||||
// Webhook.RetentionDays below; a struct tag cannot reference a
|
||||
// constant, so a test asserts the two agree.
|
||||
DefaultRetentionDays = 30
|
||||
|
||||
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
||||
// "retain events forever". Users express that intent as 0, which
|
||||
// Webhook.BeforeSave rewrites to this value: the column default
|
||||
// substitutes DefaultRetentionDays for a zero value at insert
|
||||
// time, so a zero can never survive a round trip to the database.
|
||||
// Nothing outside this file may hardcode the number.
|
||||
RetentionForeverDays = 365 * 1000
|
||||
|
||||
// MaxFiniteRetentionDays is the largest finite retention period the
|
||||
// reaper's cutoff arithmetic can represent, and therefore the
|
||||
// largest one a caller may request. It is derived from that
|
||||
// arithmetic rather than picked: retentionCutoff computes
|
||||
// retentionDays * hoursPerDay * time.Hour, and a time.Duration is
|
||||
// an int64 nanosecond count, so math.MaxInt64 nanoseconds divided
|
||||
// by an hour and then by a day is the exact ceiling — 106751 days,
|
||||
// a little over 292 years.
|
||||
//
|
||||
// One day more overflows int64, wraps the product negative, and
|
||||
// turns the cutoff into a timestamp in the far future that matches
|
||||
// every row in the webhook's database. That is why this bound is
|
||||
// enforced on input and why retentionCutoff saturates underneath
|
||||
// it. Note that RetentionForeverDays deliberately sits above this
|
||||
// ceiling: such webhooks are skipped before any cutoff is
|
||||
// computed, and never reach the arithmetic at all.
|
||||
MaxFiniteRetentionDays = int(
|
||||
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
||||
)
|
||||
)
|
||||
|
||||
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
||||
//
|
||||
// Every method below takes a pointer receiver. BeforeSave has to,
|
||||
// because it mutates the record and GORM only invokes hooks declared
|
||||
// that way; the display helpers follow suit so the receiver kinds do
|
||||
// not mix. Handlers therefore put a *Webhook into template data:
|
||||
// html/template cannot call a pointer method on a value held in a map,
|
||||
// because a map element is not addressable.
|
||||
type Webhook struct {
|
||||
BaseModel
|
||||
|
||||
@@ -8,7 +61,9 @@ type Webhook struct {
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// RetentionDays is the number of days to retain events.
|
||||
// RetentionDays is the number of days to retain events. A value of
|
||||
// RetentionForeverDays means retain forever. The column default
|
||||
// must equal DefaultRetentionDays.
|
||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||
|
||||
// Relations
|
||||
@@ -16,3 +71,55 @@ type Webhook struct {
|
||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeSave normalises RetentionDays on every insert and update. A
|
||||
// non-positive value is the user's way of asking for "retain forever",
|
||||
// which is stored as the RetentionForeverDays sentinel.
|
||||
//
|
||||
// This has to happen in a hook rather than at the call sites. GORM
|
||||
// substitutes the column default (DefaultRetentionDays) for a zero
|
||||
// value while building the insert statement, which runs after
|
||||
// BeforeSave; rewriting any later than this loses that race and the
|
||||
// row lands at 30 days. Living on the model also means a future call
|
||||
// site — a REST API, a fixture, a migration — cannot bypass it.
|
||||
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||
if w.RetentionDays <= 0 {
|
||||
w.RetentionDays = RetentionForeverDays
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retainsForever reports whether a stored RetentionDays value means
|
||||
// "keep events indefinitely". It is the single definition of that
|
||||
// question, shared by Webhook.RetainsForever and by the reaper's
|
||||
// cutoff computation so the two cannot disagree about which webhooks
|
||||
// are exempt from reaping.
|
||||
//
|
||||
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||
// and, defensively, the non-positive values that rows written before
|
||||
// the sentinel existed may still carry.
|
||||
func retainsForever(retentionDays int) bool {
|
||||
return retentionDays <= 0 ||
|
||||
retentionDays >= RetentionForeverDays
|
||||
}
|
||||
|
||||
// RetainsForever reports whether this webhook's events are kept
|
||||
// indefinitely.
|
||||
func (w *Webhook) RetainsForever() bool {
|
||||
return retainsForever(w.RetentionDays)
|
||||
}
|
||||
|
||||
// RetentionLabel returns the webhook's retention policy as display
|
||||
// text, so that no template has to know about the sentinel value.
|
||||
func (w *Webhook) RetentionLabel() string {
|
||||
if w.RetainsForever() {
|
||||
return "forever"
|
||||
}
|
||||
|
||||
if w.RetentionDays == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
|
||||
return strconv.Itoa(w.RetentionDays) + " days"
|
||||
}
|
||||
|
||||
222
internal/database/model_webhook_test.go
Normal file
222
internal/database/model_webhook_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// startedTestDB returns a started main database for model-level tests.
|
||||
func startedTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, lc := setupTestDB(t)
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) })
|
||||
|
||||
return db.DB()
|
||||
}
|
||||
|
||||
// storedRetention reads the retention_days column straight out of the
|
||||
// row, so the assertion is about what was persisted rather than about
|
||||
// whatever the in-memory struct happens to hold.
|
||||
func storedRetention(t *testing.T, db *gorm.DB, id string) int {
|
||||
t.Helper()
|
||||
|
||||
var got int
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Model(&database.Webhook{}).
|
||||
Where("id = ?", id).
|
||||
Pluck("retention_days", &got).Error,
|
||||
)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// newWebhookWithRetention creates a webhook through the ordinary Create
|
||||
// path, so the BeforeSave hook and the GORM column default both apply
|
||||
// exactly as they do in production.
|
||||
func newWebhookWithRetention(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
wh *database.Webhook,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
wh.UserID = uuid.New().String()
|
||||
wh.Name = testWebhookName
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: 0}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
"a zero retention must be stored as the sentinel, "+
|
||||
"not replaced by the column default",
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: -5}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: 7}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(t, 7, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
|
||||
// fires on update as well as insert, via the same Save call the edit
|
||||
// handler makes.
|
||||
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: 30}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
require.Equal(t, 30, storedRetention(t, db, id))
|
||||
|
||||
wh.RetentionDays = 0
|
||||
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place
|
||||
// the default lives twice: a struct tag cannot reference a constant, so
|
||||
// this asserts the tag and DefaultRetentionDays agree.
|
||||
func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
field, ok := reflect.TypeFor[database.Webhook]().
|
||||
FieldByName("RetentionDays")
|
||||
require.True(t, ok, "Webhook.RetentionDays must exist")
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"default:"+strconv.Itoa(database.DefaultRetentionDays),
|
||||
field.Tag.Get("gorm"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
|
||||
// constant is exactly where the cutoff arithmetic stops working, which
|
||||
// is what makes it a derived bound rather than a round number someone
|
||||
// liked. One day more wraps the int64 nanosecond count negative, and a
|
||||
// negative span is precisely what turned a cutoff into a future
|
||||
// timestamp that matched — and deleted — every row.
|
||||
//
|
||||
// The multiplications are done through variables on purpose: as
|
||||
// constant expressions the overflowing one would not compile.
|
||||
func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const hoursPerDay = 24
|
||||
|
||||
atCeiling := database.MaxFiniteRetentionDays
|
||||
overCeiling := database.MaxFiniteRetentionDays + 1
|
||||
|
||||
assert.Positive(
|
||||
t,
|
||||
time.Duration(atCeiling*hoursPerDay)*time.Hour,
|
||||
"the ceiling itself must still be representable",
|
||||
)
|
||||
assert.Negative(
|
||||
t,
|
||||
time.Duration(overCeiling*hoursPerDay)*time.Hour,
|
||||
"one day past the ceiling must overflow",
|
||||
)
|
||||
|
||||
assert.Less(
|
||||
t,
|
||||
database.MaxFiniteRetentionDays,
|
||||
database.RetentionForeverDays,
|
||||
"the sentinel sits above the ceiling and is only safe "+
|
||||
"because retain-forever webhooks skip the arithmetic",
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
days int
|
||||
forever bool
|
||||
label string
|
||||
}{
|
||||
{
|
||||
"sentinel",
|
||||
database.RetentionForeverDays, true, testForeverLabel,
|
||||
},
|
||||
{
|
||||
"above sentinel",
|
||||
database.RetentionForeverDays + 1, true, testForeverLabel,
|
||||
},
|
||||
{"legacy zero", 0, true, testForeverLabel},
|
||||
{"legacy negative", -1, true, testForeverLabel},
|
||||
{"default", database.DefaultRetentionDays, false, "30 days"},
|
||||
{"one day", 1, false, "1 day"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wh := database.Webhook{RetentionDays: tc.days}
|
||||
|
||||
assert.Equal(t, tc.forever, wh.RetainsForever())
|
||||
assert.Equal(t, tc.label, wh.RetentionLabel())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,8 @@ func (r *RetentionReaper) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// sweep lists every webhook from the main database and reaps expired
|
||||
// rows from each per-webhook database whose RetentionDays is positive.
|
||||
// rows from each per-webhook database that has a finite retention
|
||||
// policy. Webhooks set to retain forever are skipped entirely.
|
||||
func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
var webhooks []Webhook
|
||||
|
||||
@@ -139,8 +140,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
|
||||
wh := webhooks[i]
|
||||
|
||||
// RetentionDays of zero or less means retain forever.
|
||||
if wh.RetentionDays <= 0 {
|
||||
// Skip retain-forever webhooks before building any query.
|
||||
// RetainsForever covers both the RetentionForeverDays
|
||||
// sentinel and the non-positive values that predate it: the
|
||||
// sentinel is a positive number, so without this the reaper
|
||||
// would compute a cutoff a thousand years in the past and
|
||||
// issue a DELETE matching nothing on every single sweep.
|
||||
if wh.RetainsForever() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -171,9 +177,10 @@ func (r *RetentionReaper) reapWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(
|
||||
-time.Duration(retentionDays*hoursPerDay) * time.Hour,
|
||||
)
|
||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := reapExpired(db, cutoff)
|
||||
if err != nil {
|
||||
@@ -196,6 +203,37 @@ func (r *RetentionReaper) reapWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
// retentionCutoff returns the timestamp before which a webhook's
|
||||
// events have expired, and whether any cutoff applies at all. It
|
||||
// reports false for a retain-forever policy, so no DELETE is issued.
|
||||
//
|
||||
// The day count is clamped to MaxFiniteRetentionDays first. This is
|
||||
// defense in depth rather than decoration: a time.Duration is an int64
|
||||
// nanosecond count, so an unclamped multiplication overflows above
|
||||
// that ceiling and wraps the span negative. Subtracting a negative
|
||||
// span moves the cutoff into the far future, where it matches every
|
||||
// row in the database: the sweep then deletes every event, delivery,
|
||||
// and delivery result, including ones created seconds ago. Rejecting
|
||||
// out-of-range input at the form is the primary guard; saturating here
|
||||
// means an old row, a migration, or a future call site cannot turn a
|
||||
// too-large retention into total data loss.
|
||||
func retentionCutoff(
|
||||
now time.Time,
|
||||
retentionDays int,
|
||||
) (time.Time, bool) {
|
||||
if retainsForever(retentionDays) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
if retentionDays > MaxFiniteRetentionDays {
|
||||
retentionDays = MaxFiniteRetentionDays
|
||||
}
|
||||
|
||||
return now.Add(
|
||||
-time.Duration(retentionDays*hoursPerDay) * time.Hour,
|
||||
), true
|
||||
}
|
||||
|
||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||
// results, deliveries, and events associated with events older than
|
||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||
|
||||
@@ -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,111 @@ 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -26,8 +26,6 @@ const (
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
recentEventLimit = 20
|
||||
// defaultRetentionDays is the default event retention period.
|
||||
defaultRetentionDays = 30
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
|
||||
@@ -25,6 +25,73 @@ type WebhookListItem struct {
|
||||
// errMissingURL signals that a required URL was not provided.
|
||||
var errMissingURL = errors.New("missing URL")
|
||||
|
||||
// errInvalidRetention signals a retention_days form value that is not
|
||||
// a non-negative whole number.
|
||||
var errInvalidRetention = errors.New("invalid retention days")
|
||||
|
||||
// errRetentionTooLarge signals a retention_days form value that is a
|
||||
// whole number but larger than the reaper's cutoff arithmetic can
|
||||
// represent. It is distinguished from errInvalidRetention so the form
|
||||
// can tell the user the actual ceiling instead of implying their input
|
||||
// was not a number.
|
||||
var errRetentionTooLarge = errors.New("retention days out of range")
|
||||
|
||||
// retentionErrorMessage returns the message the create and edit forms
|
||||
// show the user for a rejected retention_days value. Any error other
|
||||
// than errRetentionTooLarge falls back to the generic wording, so an
|
||||
// unrecognised parse failure still produces a sensible 400 rather than
|
||||
// an empty alert.
|
||||
func retentionErrorMessage(err error) string {
|
||||
if errors.Is(err, errRetentionTooLarge) {
|
||||
return "Retention must be at most " +
|
||||
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
||||
" days, or 0 to retain events forever."
|
||||
}
|
||||
|
||||
return "Retention must be a whole number of days, or 0 to " +
|
||||
"retain events forever."
|
||||
}
|
||||
|
||||
// parseRetentionDays interprets a retention_days form value.
|
||||
//
|
||||
// An empty value yields fallback, which lets the create path apply the
|
||||
// default and the edit path leave the stored value unchanged. A value
|
||||
// of 0 is returned as 0 and is rewritten to the retain-forever
|
||||
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
||||
// or negative is an error rather than a silently substituted default.
|
||||
//
|
||||
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
||||
// time.Duration, an int64 nanosecond count, so a day count above
|
||||
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
||||
// future, and deletes every event the webhook has. A finite value
|
||||
// above that ceiling is therefore a 400.
|
||||
//
|
||||
// A value at or above the retain-forever sentinel is not out of range:
|
||||
// it is what the edit form pre-fills for a retain-forever webhook, so
|
||||
// submitting the form back unchanged has to keep meaning "forever"
|
||||
// rather than being rejected.
|
||||
func parseRetentionDays(raw string, fallback int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0, errInvalidRetention
|
||||
}
|
||||
|
||||
if v >= database.RetentionForeverDays {
|
||||
return database.RetentionForeverDays, nil
|
||||
}
|
||||
|
||||
if v > database.MaxFiniteRetentionDays {
|
||||
return 0, errRetentionTooLarge
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// EventWithDeliveries holds an event and its deliveries.
|
||||
type EventWithDeliveries struct {
|
||||
database.Event
|
||||
@@ -106,11 +173,30 @@ func (h *Handlers) buildWebhookListItems(
|
||||
// HandleSourceCreate shows the form to create a new webhook.
|
||||
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
tmplKeyError: "",
|
||||
}
|
||||
h.renderTemplate(
|
||||
w, r, "sources_new.html",
|
||||
newSourceFormData("", "", ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "sources_new.html", data)
|
||||
// newSourceFormData builds the template data for the webhook creation
|
||||
// form.
|
||||
//
|
||||
// It carries the retention default so the pre-filled value comes from
|
||||
// database.DefaultRetentionDays rather than being a third hardcoded
|
||||
// copy of the same policy, and it carries the submitted name and
|
||||
// description so that re-rendering the form after a validation failure
|
||||
// gives the user their input back instead of a blank form. The edit
|
||||
// form already behaves that way; create now matches it.
|
||||
func newSourceFormData(
|
||||
errMsg, name, description string,
|
||||
) map[string]any {
|
||||
return map[string]any{
|
||||
tmplKeyError: errMsg,
|
||||
"Name": name,
|
||||
"Description": description,
|
||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,23 +231,31 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||
retentionStr := r.FormValue("retention_days")
|
||||
|
||||
if name == "" {
|
||||
data := map[string]any{
|
||||
tmplKeyError: "Name is required",
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(w, r, "sources_new.html", data)
|
||||
h.renderTemplate(
|
||||
w, r, "sources_new.html",
|
||||
newSourceFormData(
|
||||
"Name is required", name, description,
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
retentionDays := defaultRetentionDays
|
||||
retentionDays, retErr := parseRetentionDays(
|
||||
retentionStr, database.DefaultRetentionDays,
|
||||
)
|
||||
if retErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(
|
||||
w, r, "sources_new.html",
|
||||
newSourceFormData(
|
||||
retentionErrorMessage(retErr),
|
||||
name, description,
|
||||
),
|
||||
)
|
||||
|
||||
if retentionStr != "" {
|
||||
v, convErr := strconv.Atoi(retentionStr)
|
||||
if convErr == nil && v > 0 {
|
||||
retentionDays = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.createWebhookWithEntrypoint(
|
||||
@@ -315,8 +409,10 @@ func (h *Handlers) renderSourceDetail(
|
||||
scheme = fwdProto
|
||||
}
|
||||
|
||||
// The template calls Webhook methods, which take pointer
|
||||
// receivers; html/template cannot address a value stored in a map.
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Entrypoints": entrypoints,
|
||||
"Targets": targets,
|
||||
"Events": events,
|
||||
@@ -352,7 +448,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyWebhook: &webhook,
|
||||
tmplKeyError: "",
|
||||
}
|
||||
|
||||
@@ -416,7 +512,7 @@ func (h *Handlers) applyWebhookEdit(
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: *webhook,
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: "Name is required",
|
||||
}
|
||||
|
||||
@@ -428,7 +524,25 @@ func (h *Handlers) applyWebhookEdit(
|
||||
|
||||
webhook.Name = name
|
||||
webhook.Description = r.FormValue("description")
|
||||
h.parseRetention(r, webhook)
|
||||
|
||||
// An empty field falls back to the stored value, so submitting the
|
||||
// form without touching retention leaves the policy alone.
|
||||
retentionDays, retErr := parseRetentionDays(
|
||||
r.FormValue("retention_days"), webhook.RetentionDays,
|
||||
)
|
||||
if retErr != nil {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: retentionErrorMessage(retErr),
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(w, r, "source_edit.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhook.RetentionDays = retentionDays
|
||||
|
||||
err := h.db.DB().Save(webhook).Error
|
||||
if err != nil {
|
||||
@@ -442,23 +556,6 @@ func (h *Handlers) applyWebhookEdit(
|
||||
)
|
||||
}
|
||||
|
||||
// parseRetention parses and applies retention_days from the
|
||||
// form.
|
||||
func (h *Handlers) parseRetention(
|
||||
r *http.Request,
|
||||
webhook *database.Webhook,
|
||||
) {
|
||||
retStr := r.FormValue("retention_days")
|
||||
if retStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(retStr)
|
||||
if err == nil && v > 0 {
|
||||
webhook.RetentionDays = v
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceDelete handles webhook deletion.
|
||||
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -590,7 +687,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
|
||||
581
internal/handlers/source_management_test.go
Normal file
581
internal/handlers/source_management_test.go
Normal file
@@ -0,0 +1,581 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
// sourceTestUserID is the session user id used by the webhook
|
||||
// management tests.
|
||||
sourceTestUserID = "source-test-user"
|
||||
// sourceIDParam is the chi URL parameter naming a webhook.
|
||||
sourceIDParam = "sourceID"
|
||||
)
|
||||
|
||||
// formRequest builds an urlencoded POST to path carrying the given
|
||||
// cookies, plus any chi URL parameters the handler reads.
|
||||
func formRequest(
|
||||
path string,
|
||||
cookies []*http.Cookie,
|
||||
form url.Values,
|
||||
urlParams map[string]string,
|
||||
) *http.Request {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
path,
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
req.Header.Set(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range urlParams {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
|
||||
return req.WithContext(
|
||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||
)
|
||||
}
|
||||
|
||||
// getRequest builds a GET to path carrying the given cookies, plus any
|
||||
// chi URL parameters the handler reads.
|
||||
func getRequest(
|
||||
t *testing.T,
|
||||
path string,
|
||||
cookies []*http.Cookie,
|
||||
urlParams map[string]string,
|
||||
) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, path, nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range urlParams {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
|
||||
return req.WithContext(
|
||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||
)
|
||||
}
|
||||
|
||||
// submitCreate posts the webhook creation form with the given
|
||||
// retention_days value (omitted entirely when retention is nil) and
|
||||
// returns the recorder.
|
||||
func submitCreate(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
cookies []*http.Cookie,
|
||||
name string,
|
||||
retention *string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", name)
|
||||
|
||||
if retention != nil {
|
||||
form.Set("retention_days", *retention)
|
||||
}
|
||||
|
||||
req := formRequest("/sources/new", cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// onlyWebhook loads the single webhook belonging to the test user.
|
||||
func onlyWebhook(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
) database.Webhook {
|
||||
t.Helper()
|
||||
|
||||
var webhooks []database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Where("user_id = ?", sourceTestUserID).
|
||||
Find(&webhooks).Error,
|
||||
)
|
||||
require.Len(t, webhooks, 1)
|
||||
|
||||
return webhooks[0]
|
||||
}
|
||||
|
||||
// seedWebhook inserts a webhook owned by the test user with an exact
|
||||
// stored retention value, bypassing Webhook.BeforeSave via a
|
||||
// column-level update so that legacy rows can be planted too.
|
||||
func seedWebhook(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
retentionDays int,
|
||||
) database.Webhook {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: sourceTestUserID,
|
||||
Name: "seeded",
|
||||
RetentionDays: retentionDays,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Model(wh).
|
||||
Update("retention_days", retentionDays).Error,
|
||||
)
|
||||
|
||||
wh.RetentionDays = retentionDays
|
||||
|
||||
return *wh
|
||||
}
|
||||
|
||||
// storedRetentionDays reads the retention_days column for a webhook.
|
||||
func storedRetentionDays(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
id string,
|
||||
) int {
|
||||
t.Helper()
|
||||
|
||||
var got int
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Model(&database.Webhook{}).
|
||||
Where("id = ?", id).
|
||||
Pluck("retention_days", &got).Error,
|
||||
)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// sourceTestEnv bundles the handler, session, and database a webhook
|
||||
// management test drives.
|
||||
type sourceTestEnv struct {
|
||||
handlers *handlers.Handlers
|
||||
db *database.Database
|
||||
cookies []*http.Cookie
|
||||
}
|
||||
|
||||
func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
var db *database.Database
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
return &sourceTestEnv{
|
||||
handlers: h,
|
||||
db: db,
|
||||
cookies: authenticatedCookies(
|
||||
t, sess, sourceTestUserID, "sourceuser",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core
|
||||
// regression test for the bug: the create form's 0 must reach the
|
||||
// database as the retain-forever sentinel rather than being replaced by
|
||||
// the column's default of 30.
|
||||
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
zero := "0"
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "forever", &zero)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
assert.True(t, wh.RetainsForever())
|
||||
}
|
||||
|
||||
func TestHandleSourceCreateSubmit_OmittedRetentionUsesDefault(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.DefaultRetentionDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create
|
||||
// form's pre-filled retention from becoming a third hardcoded copy of
|
||||
// the 30-day policy.
|
||||
func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceCreate().ServeHTTP(
|
||||
w, getRequest(t, "/sources/new", env.cookies, nil),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`value="`+strconv.Itoa(database.DefaultRetentionDays)+`"`,
|
||||
)
|
||||
assert.NotContains(
|
||||
t, body, `max="365"`,
|
||||
"a max below the sentinel would block retain-forever",
|
||||
)
|
||||
assert.Contains(t, body, `min="0"`)
|
||||
}
|
||||
|
||||
func TestHandleSourceCreateSubmit_InvalidRetentionIsRejected(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, raw := range []string{"abc", "-1", "3.5"} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
w := submitCreate(
|
||||
t, env.handlers, env.cookies, "bad", &raw,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(
|
||||
t, w.Body.String(), "Retention must be",
|
||||
)
|
||||
|
||||
var count int64
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
env.db.DB().Model(&database.Webhook{}).
|
||||
Where("user_id = ?", sourceTestUserID).
|
||||
Count(&count).Error,
|
||||
)
|
||||
assert.Zero(
|
||||
t, count,
|
||||
"no webhook may be created from a rejected form",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected covers
|
||||
// the data-loss path directly: a finite retention above the largest one
|
||||
// the reaper's cutoff arithmetic can represent must never reach the
|
||||
// database, because the sweep would compute a future cutoff and delete
|
||||
// every event the webhook has.
|
||||
func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
tooBig := strconv.Itoa(database.MaxFiniteRetentionDays + 1)
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "huge", &tooBig)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(
|
||||
t, w.Body.String(),
|
||||
strconv.Itoa(database.MaxFiniteRetentionDays),
|
||||
"the form tells the user the actual ceiling",
|
||||
)
|
||||
|
||||
var count int64
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
env.db.DB().Model(&database.Webhook{}).
|
||||
Where("user_id = ?", sourceTestUserID).
|
||||
Count(&count).Error,
|
||||
)
|
||||
assert.Zero(
|
||||
t, count,
|
||||
"no webhook may be created from a rejected form",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever guards the
|
||||
// boundary between "too large to represent" and "retain forever": the
|
||||
// sentinel is above MaxFiniteRetentionDays, but it is the value the
|
||||
// edit form pre-fills, so it must be accepted rather than rejected as
|
||||
// out of range.
|
||||
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
||||
// validation failure hands the user's typing back, matching what the
|
||||
// edit form already does. Losing a long description to a mistyped
|
||||
// retention value is the kind of thing that makes people give up on a
|
||||
// form.
|
||||
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
const (
|
||||
name = "kept-name"
|
||||
description = "a description worth not losing"
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", name)
|
||||
form.Set("description", description)
|
||||
form.Set("retention_days", "nonsense")
|
||||
|
||||
req := formRequest("/sources/new", env.cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(t, body, `value="`+name+`"`)
|
||||
assert.Contains(t, body, description)
|
||||
}
|
||||
|
||||
// submitEdit posts the webhook edit form for the given webhook.
|
||||
func submitEdit(
|
||||
t *testing.T,
|
||||
env *sourceTestEnv,
|
||||
wh database.Webhook,
|
||||
retention string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", wh.Name)
|
||||
form.Set("description", wh.Description)
|
||||
form.Set("retention_days", retention)
|
||||
|
||||
req := formRequest(
|
||||
"/source/"+wh.ID+"/edit",
|
||||
env.cookies,
|
||||
form,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleSourceEditSubmit().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHandleSourceEditSubmit_ZeroRetentionPersistsForever(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhook(t, env.db, database.DefaultRetentionDays)
|
||||
|
||||
w := submitEdit(t, env, wh, "0")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhook(t, env.db, database.DefaultRetentionDays)
|
||||
|
||||
w := submitEdit(t, env, wh, "not-a-number")
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Retention must be")
|
||||
assert.Equal(
|
||||
t,
|
||||
database.DefaultRetentionDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
"a rejected form must not change the stored retention",
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhook(t, env.db, 7)
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
|
||||
}
|
||||
|
||||
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
||||
// the removed max="365" cap used to break: render the edit form for a
|
||||
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
||||
// by browser validation, then submit that pre-filled value straight
|
||||
// back and confirm the retention policy survives untouched.
|
||||
func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhook(t, env.db, database.RetentionForeverDays)
|
||||
|
||||
req := getRequest(
|
||||
t, "/source/"+wh.ID+"/edit", env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceEdit().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(
|
||||
t, body, `value="`+sentinel+`"`,
|
||||
"the edit form pre-fills the stored retention",
|
||||
)
|
||||
assert.NotContains(
|
||||
t, body, `max="365"`,
|
||||
"a max below the sentinel would block saving any edit",
|
||||
)
|
||||
// "Currently forever." is the rendered RetentionLabel, not the
|
||||
// static hint below the input, which says "Enter 0 to retain events
|
||||
// forever." A bare Contains of "forever" would pass for any
|
||||
// webhook and would assert nothing about this one.
|
||||
assert.Contains(
|
||||
t, body, "Currently forever.",
|
||||
"the form reports this webhook's policy as forever",
|
||||
)
|
||||
|
||||
// Submit the pre-filled value back, exactly as a browser would.
|
||||
post := submitEdit(t, env, wh, sentinel)
|
||||
require.Equal(t, http.StatusSeeOther, post.Code)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that
|
||||
// the retain-forever value is never rendered to the user as a raw day
|
||||
// count on either read-only view.
|
||||
func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhook(t, env.db, database.RetentionForeverDays)
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
|
||||
listW := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceList().ServeHTTP(
|
||||
listW, getRequest(t, "/sources", env.cookies, nil),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, listW.Code)
|
||||
assert.Contains(t, listW.Body.String(), "Retention: forever")
|
||||
assert.NotContains(t, listW.Body.String(), sentinel)
|
||||
|
||||
detailW := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||
detailW,
|
||||
getRequest(
|
||||
t, "/source/"+wh.ID, env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, detailW.Code)
|
||||
assert.Contains(t, detailW.Body.String(), "Retention: forever")
|
||||
assert.NotContains(t, detailW.Body.String(), sentinel)
|
||||
}
|
||||
Reference in New Issue
Block a user