Some checks failed
check / check (push) Has been cancelled
Rewrites retention_days=0 to the RetentionForeverDays sentinel (365 * 1000) in Webhook.BeforeSave, so the GORM column default cannot win the race. The reaper skips retain-forever webhooks before building any query. Also bounds the reaper's cutoff arithmetic: a time.Duration is int64 nanoseconds, so day counts above MaxFiniteRetentionDays (106751) overflowed and wrapped the cutoff into the future, where created_at < cutoff matched every row and the sweep deleted everything. parseRetentionDays now rejects finite values above the ceiling, and retentionCutoff saturates so rows written by older versions cannot reach it either. Views render RetentionLabel() rather than the raw sentinel.
223 lines
5.3 KiB
Go
223 lines
5.3 KiB
Go
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())
|
|
})
|
|
}
|
|
}
|