1 Commits
Author SHA1 Message Date
sneak 766f33a956 Milestone: next into main (#380)
check / check (push) Canceled after 0s
Reviewed-on: #380
2026-10-15 11:36:37 +02:00
14 changed files with 366 additions and 368 deletions
+39 -40
View File
@@ -1317,22 +1317,20 @@ webhooker solves this by acting as a durable intermediary:
nothing is lost if downstream targets are unavailable. nothing is lost if downstream targets are unavailable.
2. **Guaranteed delivery** — Events are queued for delivery to each configured 2. **Guaranteed delivery** — Events are queued for delivery to each configured
target. Failed deliveries are retried with configurable backoff. Delivery target. Failed deliveries are retried with configurable backoff. Every
attempts are logged with status codes, response bodies, and timing. delivery attempt is logged with status codes, response bodies, and timing.
**That guarantee is at-least-once, not exactly-once.** When a send reaches **That guarantee is at-least-once, not exactly-once.** When a send reaches
its target but the write recording that outcome fails, the delivery is its target but the write recording that outcome fails, the delivery is
deliberately left in a recoverable state rather than marked done — losing a deliberately left in a recoverable state rather than marked done — losing a
delivery is the worse failure — so the pending sweep picks it up about delivery is the worse failure — so the pending sweep picks it up about
fifteen minutes later, or the next restart does, and the target receives a fifteen minutes later, or the next restart does, and the target receives a
payload it already got. A send still in flight when the service stops is payload it already got. webhooker adds no delivery identifier of its own to
left the same way: its attempt is not recorded, and the next start sends it an outbound request, so **make your receiver idempotent** against whatever
again (see Shutdown). webhooker adds no delivery identifier of its own to an the payload itself carries.
outbound request, so **make your receiver idempotent** against whatever the
payload itself carries.
3. **Observability** — Full request/response logging for every webhook received 3. **Observability** — Full request/response logging for every webhook received
and for delivery attempts. Prometheus metrics expose volume, latency, and and every delivery attempted. Prometheus metrics expose volume, latency, and
error rates. The web UI provides real-time visibility into event flow. error rates. The web UI provides real-time visibility into event flow.
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets 4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
@@ -1497,34 +1495,46 @@ validated as on the add target form, and the webhook and its targets are created
together or not at all. together or not at all.
| Field | Type | Description | | Field | Type | Description |
| ---------------- | ------- | --------------------------------------------- | | ---------------- | ------- | ----------------------------------------------------------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `user_id` | UUID | Foreign key → User | | `user_id` | UUID | Foreign key → User |
| `name` | string | Human-readable name | | `name` | string | Human-readable name |
| `description` | string | Optional description | | `description` | string | Optional description |
| `retention_days` | integer | Days to retain events; 0 means retain forever | | `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) |
**Relations:** Belongs to User. Has many Entrypoints. Has many Targets. **Relations:** Belongs to User. Has many Entrypoints. Has many Targets.
The `retention_days` field controls how long event data is kept in the webhook's The `retention_days` field controls how long event data is kept in the webhook's
dedicated database before automatic cleanup. dedicated database before automatic cleanup.
The value is stored as entered. Setting `retention_days` to `0` means "retain Setting `retention_days` to `0` means "retain events forever". Because the
events forever": the retention reaper skips the webhook entirely, and the web UI column carries a default of 30, a literal zero cannot survive an insert, so a
displays its retention as "forever" rather than as a day count. zero is rewritten on save to a sentinel of `365 * 1000` days
(`database.RetentionForeverDays`). The retention reaper recognises that sentinel
and skips the webhook entirely, and the web UI displays such a webhook's
retention as "forever" rather than as a day count.
The new webhook and edit forms accept `0`, or a whole number of days from `1` up Submitted `retention_days` values therefore fall into three bands, not two:
to `database.MaxFiniteRetentionDays` (106751 days, about 292 years). Anything
else, a negative value or one above that cap included, is rejected with a 400. A - `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is
blank field gives the default of 30 days (`database.DefaultRetentionDays`) on accepted as a finite retention.
the new webhook form, and leaves the stored value unchanged on the edit form. - Above that ceiling but below the retain-forever sentinel of 365000
(`database.RetentionForeverDays`) is rejected with a 400. This is the band the
cap exists for.
- `0`, and `365000` or above, are accepted and mean retain forever, collapsing
to the sentinel — `0` in `Webhook.BeforeSave`, the large values in
`parseRetentionDays`. The large values are not out of range: the edit form
pre-fills the sentinel for a retain-forever webhook, so submitting that form
back unchanged has to keep meaning "forever".
A negative value is in none of the three: `parseRetentionDays` rejects it with a
400 before `BeforeSave` ever sees it.
The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`,
an int64 nanosecond count, and a longer period overflows it. An overflowed an int64 nanosecond count, and a longer period overflows it. An overflowed
cutoff lands in the future, where it matches every row, so the sweep would cutoff lands in the future, where it matches every row, so the sweep would
delete every event the webhook has instead of none. The reaper also clamps the delete every event the webhook has instead of none. The reaper also clamps the
value it reads to the cap, and reads a negative value as forever, so no stored value it reads to the cap, so no stored value can trigger that either.
value can trigger that either.
#### Entrypoint #### Entrypoint
@@ -1758,7 +1768,7 @@ is what bounds it.
#### DeliveryResult #### DeliveryResult
The result of a single delivery attempt. Every attempt (including retries) is The result of a single delivery attempt. Every attempt (including retries) is
individually logged, except one cut off by a stop (see Shutdown). individually logged for full observability.
| Field | Type | Description | | Field | Type | Description |
| --------------- | ------- | ----------------------------------------- | | --------------- | ------- | ----------------------------------------- |
@@ -2196,16 +2206,12 @@ This means:
buffer rather than spawning more goroutines. buffer rather than spawning more goroutines.
- **Independent results** — each worker records its own delivery result in the - **Independent results** — each worker records its own delivery result in the
per-webhook database without coordination. per-webhook database without coordination.
- **Graceful shutdown** — the stop hook cancels the workers' context. A worker - **Graceful shutdown** — cancel the context, workers finish their current task
may still take tasks already queued before it exits. An `http` or `slack` and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`,
request in flight is cut off, and one started after the cancel fails at once. which bounds that wait by fx's stop timeout rather than blocking forever on a
Neither is recorded or counted in the metrics, and the next start sends the wedged worker. On timeout it logs at `ERROR` and returns an error, and the
delivery again (see Shutdown). A `database` or `log` task runs as normal. The goroutines that did not finish are still running — an unclean shutdown is
stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds reported rather than hidden.
that wait by fx's stop timeout rather than blocking forever on a wedged
worker. On timeout it logs at `ERROR` and returns an error, and the goroutines
that did not finish are still running — an unclean shutdown is reported rather
than hidden.
**Recovery paths:** **Recovery paths:**
@@ -2311,7 +2317,7 @@ getting anywhere.
| Metric | Type | Meaning | | Metric | Type | Meaning |
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | | `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) | | `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` |
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | | `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | | `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | | `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
@@ -3278,8 +3284,7 @@ stop-hook log:
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
seconds**) and by what the hooks before it left, then a Sentry flush if seconds**) and by what the hooks before it left, then a Sentry flush if
`SENTRY_DSN` is set `SENTRY_DSN` is set
4. `delivery.Engine` — cuts off any `http` or `slack` request in flight, waits 4. `delivery.Engine` — waits for its workers, then closes the archive databases
for its workers, then closes the archive databases
5. `healthcheck` 5. `healthcheck`
6. `WebhookDBManager` 6. `WebhookDBManager`
7. the database close 7. the database close
@@ -3290,12 +3295,6 @@ by the stop context, so a wedge there consumes the 5 seconds before the HTTP
server hook is ever entered. The hooks after the server are microsecond-scale in server hook is ever entered. The hooks after the server are microsecond-scale in
normal operation. normal operation.
The delivery engine cancels a delivery request in flight. That attempt is not
recorded or counted in the metrics, and the delivery keeps its `pending` or
`retrying` status, so startup recovery sends it again on the next start (see
Recovery paths under Bounded Worker Pool). If the receiver already had it, it
receives it twice: the at-least-once duplicate described under Rationale.
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
the two equal, a drain that used its whole budget would exhaust the sequence the two equal, a drain that used its whole budget would exhaust the sequence
budget at the instant it finished, and every later hook — the delivery engine, budget at the instant it finished, and every later hook — the delivery engine,
+56 -19
View File
@@ -4,13 +4,27 @@ import (
"math" "math"
"strconv" "strconv"
"time" "time"
"gorm.io/gorm"
) )
const ( const (
// DefaultRetentionDays is the event retention period the new // DefaultRetentionDays is the event retention period applied to a
// webhook form applies when its retention field is left blank. // 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 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 // MaxFiniteRetentionDays is the largest finite retention period the
// reaper's cutoff arithmetic can represent, and therefore the // reaper's cutoff arithmetic can represent, and therefore the
// largest one a caller may request. It is derived from that // largest one a caller may request. It is derived from that
@@ -24,7 +38,9 @@ const (
// turns the cutoff into a timestamp in the far future that matches // 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 // every row in the webhook's database. That is why this bound is
// enforced on input and why retentionCutoff saturates underneath // enforced on input and why retentionCutoff saturates underneath
// it. // 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( MaxFiniteRetentionDays = int(
math.MaxInt64 / int64(time.Hour) / hoursPerDay, math.MaxInt64 / int64(time.Hour) / hoursPerDay,
) )
@@ -32,10 +48,12 @@ const (
// Webhook represents a webhook processing unit that groups entrypoints and targets // Webhook represents a webhook processing unit that groups entrypoints and targets
// //
// Every method below takes a pointer receiver, so handlers put a // Every method below takes a pointer receiver. BeforeSave has to,
// *Webhook into template data: html/template cannot call a pointer // because it mutates the record and GORM only invokes hooks declared
// method on a value held in a map, because a map element is not // that way; the display helpers follow suit so the receiver kinds do
// addressable. // 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 { type Webhook struct {
BaseModel BaseModel
@@ -43,9 +61,10 @@ type Webhook struct {
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
// RetentionDays is the number of days to retain events, and 0 // RetentionDays is the number of days to retain events. A value of
// means retain them forever. // RetentionForeverDays means retain forever. The column default
RetentionDays int `json:"retentionDays"` // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// User.Webhooks leads back here, and the JSON could loop. // User.Webhooks leads back here, and the JSON could loop.
@@ -54,17 +73,36 @@ type Webhook struct {
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
// retainsForever reports whether a RetentionDays value means "keep // BeforeSave normalises RetentionDays on every insert and update. A
// events indefinitely", which 0 does. It is the single definition of // non-positive value is the user's way of asking for "retain forever",
// that question, shared by Webhook.RetainsForever and by the reaper's // 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, such as a REST API or a fixture, 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 // cutoff computation so the two cannot disagree about which webhooks
// are exempt from reaping. // are exempt from reaping.
// //
// A negative value, which the forms refuse, is read the same way: as a // It accepts the RetentionForeverDays sentinel written by BeforeSave
// day count it would put the cutoff in the future, where it matches // and, defensively, any non-positive value, which BeforeSave treats
// every event the webhook has. // as a request to retain forever.
func retainsForever(retentionDays int) bool { func retainsForever(retentionDays int) bool {
return retentionDays <= 0 return retentionDays <= 0 ||
retentionDays >= RetentionForeverDays
} }
// RetainsForever reports whether this webhook's events are kept // RetainsForever reports whether this webhook's events are kept
@@ -81,8 +119,7 @@ func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
} }
// RetentionLabel returns the webhook's retention policy as display // RetentionLabel returns the webhook's retention policy as display
// text: "forever" rather than "0 days" for a webhook that keeps its // text, so that no template has to know about the sentinel value.
// events.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
if w.RetainsForever() { if w.RetainsForever() {
return "forever" return "forever"
+72 -10
View File
@@ -2,6 +2,8 @@ package database_test
import ( import (
"context" "context"
"reflect"
"strconv"
"testing" "testing"
"time" "time"
@@ -45,7 +47,8 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int {
} }
// newWebhookWithRetention creates a webhook through the ordinary Create // newWebhookWithRetention creates a webhook through the ordinary Create
// path, as the new webhook form does. // path, so the BeforeSave hook and the GORM column default both apply
// exactly as they do in production.
func newWebhookWithRetention( func newWebhookWithRetention(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
@@ -64,7 +67,7 @@ func newWebhookWithRetention(
return wh.ID return wh.ID
} }
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) { func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -72,10 +75,31 @@ func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
wh := &database.Webhook{RetentionDays: 0} wh := &database.Webhook{RetentionDays: 0}
id := newWebhookWithRetention(t, db, wh) id := newWebhookWithRetention(t, db, wh)
assert.Equal(t, 0, storedRetention(t, db, id)) 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 TestWebhookRetentionDays_DayCountIsStored(t *testing.T) { 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() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -86,9 +110,10 @@ func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
assert.Equal(t, 7, storedRetention(t, db, id)) assert.Equal(t, 7, storedRetention(t, db, id))
} }
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day // TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
// count to 0 through the same Save call the edit handler makes. // fires on update as well as insert, via the same Save call the edit
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) { // handler makes.
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -100,7 +125,28 @@ func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
wh.RetentionDays = 0 wh.RetentionDays = 0
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error) require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
assert.Equal(t, 0, storedRetention(t, db, id)) 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 // TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
@@ -130,6 +176,14 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
time.Duration(overCeiling*hoursPerDay)*time.Hour, time.Duration(overCeiling*hoursPerDay)*time.Hour,
"one day past the ceiling must overflow", "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) { func TestWebhookRetainsForeverAndLabel(t *testing.T) {
@@ -141,8 +195,16 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
forever bool forever bool
label string label string
}{ }{
{"zero", 0, true, testForeverLabel}, {
{"negative", -1, true, testForeverLabel}, "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"}, {"default", database.DefaultRetentionDays, false, "30 days"},
{"one day", 1, false, "1 day"}, {"one day", 1, false, "1 day"},
} }
+1 -2
View File
@@ -243,8 +243,7 @@ func (r *RetentionReaper) reapWebhook(
// retentionCutoff returns the timestamp before which a webhook's // retentionCutoff returns the timestamp before which a webhook's
// events have expired, and whether any cutoff applies at all. It // events have expired, and whether any cutoff applies at all. It
// reports false for a retention of 0, which means retain forever, so // reports false for a retain-forever policy, so no DELETE is issued.
// no DELETE is issued.
// //
// The day count is clamped to MaxFiniteRetentionDays first. This is // The day count is clamped to MaxFiniteRetentionDays first. This is
// defense in depth rather than decoration: a time.Duration is an int64 // defense in depth rather than decoration: a time.Duration is an int64
+65 -19
View File
@@ -85,6 +85,41 @@ func createWebhook(
db.Omit(clause.Associations).Create(wh).Error, 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 return wh.ID
} }
@@ -246,19 +281,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
assertChainPresent(t, db, recent) assertChainPresent(t, db, recent)
} }
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the // TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
// end-to-end retain-forever path: a webhook created with a retention // end-to-end retain-forever path: a webhook created the normal way with
// of 0 reads back 0, and the reaper leaves its ancient events alone // a requested retention of 0 lands on the RetentionForeverDays
// while still reaping a finite-retention webhook in the very same // sentinel, and the reaper leaves its ancient events alone while still
// sweep. // reaping a finite-retention webhook in the very same sweep.
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep( func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
foreverID := createWebhook(t, env.mainDB.DB(), 0) foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
var stored database.Webhook var stored database.Webhook
@@ -267,12 +302,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
env.mainDB.DB().Where("id = ?", foreverID). env.mainDB.DB().Where("id = ?", foreverID).
First(&stored).Error, First(&stored).Error,
) )
require.Zero( require.Equal(
t, stored.RetentionDays, t,
"a retention of 0 must be stored as 0", database.RetentionForeverDays,
stored.RetentionDays,
"a requested retention of 0 must persist as the sentinel",
) )
finiteID := createWebhook(t, env.mainDB.DB(), 30) finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
foreverDB, err := env.mgr.GetDB(foreverID) foreverDB, err := env.mgr.GetDB(foreverID)
require.NoError(t, err) require.NoError(t, err)
@@ -298,10 +335,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
// negative, so subtracting it moves the cutoff into the far future, // negative, so subtracting it moves the cutoff into the far future,
// where "created_at < cutoff" matches every row: an event created a // where "created_at < cutoff" matches every row: an event created a
// moment ago, and its delivery and delivery result, were all deleted // moment ago, and its delivery and delivery result, were all deleted
// on the first sweep. 200000 is inside that band. // 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 form refuses such a value; the point of the test is that a row // The row is planted at the column level because such a value can no
// written by any other path still cannot trigger the wipe. // 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( func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t *testing.T, t *testing.T,
) { ) {
@@ -317,6 +358,12 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
database.MaxFiniteRetentionDays, database.MaxFiniteRetentionDays,
"the test value must exceed what the cutoff can represent", "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( webhookID := createWebhook(
t, env.mainDB.DB(), overflowingRetentionDays, t, env.mainDB.DB(), overflowingRetentionDays,
@@ -332,15 +379,14 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
assertChainPresent(t, db, fresh) assertChainPresent(t, db, fresh)
} }
// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
// retention, which the forms refuse, keeps every event rather than
// putting the cutoff in the future, where it would match them all.
func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), -1) // 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) db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
+1 -1
View File
@@ -46,7 +46,7 @@ CREATE TABLE webhooks (
user_id uuid NOT NULL, user_id uuid NOT NULL,
name text NOT NULL, name text NOT NULL,
description text, description text,
retention_days integer, retention_days integer DEFAULT 30,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id) CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
); );
-153
View File
@@ -2,16 +2,11 @@ package delivery_test
import ( import (
"context" "context"
"io"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -277,154 +272,6 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
} }
// seedHeldDelivery gives the setup's webhook an http target with
// maxRetries, pointed at a receiver that keeps every request open until
// the client gives up on it, and seeds one delivery to that target at
// status. It returns the task that sends attempt attemptNum of the
// delivery, and a channel that is closed when a request reaches the
// receiver.
func seedHeldDelivery(
t *testing.T,
s iSetup,
maxRetries int,
status database.DeliveryStatus,
attemptNum int,
) (delivery.Task, <-chan struct{}) {
t.Helper()
arrived := make(chan struct{})
var once sync.Once
ts := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
// The server notices the client hanging up only once the
// body has been read; until then r.Context() never ends.
_, _ = io.Copy(io.Discard, r.Body)
once.Do(func() { close(arrived) })
<-r.Context().Done()
},
))
t.Cleanup(ts.Close)
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, cfg, maxRetries,
)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
bodyStr := event.Body
return iTask(
d, event, s.WebhookID, targetID, "held", cfg,
maxRetries, attemptNum, &bodyStr,
), arrived
}
// stopDuringAttempt waits until the receiver holds an attempt open, then
// stops the engine through its real stop hook.
func stopDuringAttempt(
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
) {
t.Helper()
select {
case <-arrived:
case <-time.After(5 * time.Second):
t.Fatal("the delivery attempt never reached the receiver")
}
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
}
// requireRequeued runs restart recovery on the stopped engine and
// requires it to queue task's attempt again on ch.
func requireRequeued(
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
) {
t.Helper()
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID,
)
select {
case got := <-ch:
assert.Equal(t, task.DeliveryID, got.DeliveryID)
assert.Equal(t, task.AttemptNum, got.AttemptNum)
case <-time.After(5 * time.Second):
t.Fatal("restart recovery did not queue the delivery again")
}
}
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
// test for a stop that turned an attempt in flight into a terminal
// failure. Stopping the engine cancels the request; for a target with
// no retries that was the delivery's only attempt, recorded as failed
// with "context canceled", and nothing ever sent it again. The
// delivery must instead stay pending, with no result and no count in
// the attempt metrics, for restart recovery to send.
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, 0, database.DeliveryStatusPending, 1,
)
s.Engine.Notify([]delivery.Task{task})
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
)
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
assert.Zero(t, mHTTPDurations(t, reg))
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
}
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
// for a target with retries, on its last attempt: the cancelled attempt
// used to exhaust the retries and end the delivery failed. It must stay
// retrying with only its earlier attempt recorded, and restart recovery
// must send that last attempt again.
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel()
const maxRetries = 2
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries,
)
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
s.Engine.ExportScheduleRetry(task, 0)
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
)
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
}
// deliverToArchive gives the setup's webhook a database target, // deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns // runs one delivery to it through the running engine, and returns
// the target's ID and archive file path. The archive writer holds // the target's ID and archive file path. The archive writer holds
+1 -2
View File
@@ -32,8 +32,7 @@ type Scheduler interface {
// Engine.observeAttempt, alongside the DeliveryResult it records for // Engine.observeAttempt, alongside the DeliveryResult it records for
// it. Deliver is also entered for attempts that never happen — an // it. Deliver is also entered for attempts that never happen — an
// open circuit breaker refuses one — so the count cannot be taken // open circuit breaker refuses one — so the count cannot be taken
// from around this call. An attempt cut off by a stop is neither // from around this call.
// counted nor recorded.
type Target interface { type Target interface {
Deliver( Deliver(
ctx context.Context, ctx context.Context,
+3 -46
View File
@@ -57,11 +57,7 @@ type httpCore struct {
// recorded and no circuit breaker is consulted. A positive // recorded and no circuit breaker is consulted. A positive
// maxRetries gates the attempt on the circuit breaker and // maxRetries gates the attempt on the circuit breaker and
// schedules a backed-off retry on failure. // schedules a backed-off retry on failure.
//
// ctx is the context attempt sends its request with. Stopping the
// engine cancels it; see interruptedByStop.
func (c *httpCore) deliver( func (c *httpCore) deliver(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
@@ -70,55 +66,21 @@ func (c *httpCore) deliver(
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
if maxRetries == 0 { if maxRetries == 0 {
c.fireAndForget(ctx, webhookDB, d, attempt()) c.fireAndForget(webhookDB, d, attempt())
return return
} }
c.withRetry( c.withRetry(
ctx, webhookDB, d, task, sched, maxRetries, attempt, webhookDB, d, task, sched, maxRetries, attempt,
) )
} }
// interruptedByStop reports whether an attempt failed because the
// engine is stopping, which cancels the context its request is made
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
// breaker outcome, no status change, no count in the attempt metrics.
// The delivery stays pending or retrying, and startup recovery sends
// it again on the next start.
//
// A target's own timeout leaves that context alone, so its attempt is
// still recorded as the failure it is.
func (c *httpCore) interruptedByStop(
ctx context.Context,
d *database.Delivery,
res attemptResult,
) bool {
if res.success || ctx.Err() == nil {
return false
}
c.eng.log.Warn(
"delivery attempt interrupted by shutdown; not recorded, "+
"it is sent again on the next start",
"delivery_id", d.ID,
"event_id", d.EventID,
"target_id", d.TargetID,
)
return true
}
func (c *httpCore) fireAndForget( func (c *httpCore) fireAndForget(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
res attemptResult, res attemptResult,
) { ) {
if c.interruptedByStop(ctx, d, res) {
return
}
c.eng.observeAttempt(d.Target.Type, res.elapsed()) c.eng.observeAttempt(d.Target.Type, res.elapsed())
err := c.eng.recordResult( err := c.eng.recordResult(
@@ -148,7 +110,6 @@ func (c *httpCore) fireAndForget(
} }
func (c *httpCore) withRetry( func (c *httpCore) withRetry(
ctx context.Context,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
@@ -170,10 +131,6 @@ func (c *httpCore) withRetry(
res := attempt() res := attempt()
if c.interruptedByStop(ctx, d, res) {
return
}
c.eng.observeAttempt(d.Target.Type, res.elapsed()) c.eng.observeAttempt(d.Target.Type, res.elapsed())
err := c.eng.recordResult( err := c.eng.recordResult(
@@ -438,7 +395,7 @@ func (t *httpTarget) Deliver(
} }
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, webhookDB, d, task, sched,
d.Target.MaxRetries, attempt, d.Target.MaxRetries, attempt,
) )
} }
+1 -1
View File
@@ -82,7 +82,7 @@ func (t *slackTarget) Deliver(
} }
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, webhookDB, d, task, sched,
d.Target.MaxRetries, attempt, d.Target.MaxRetries, attempt,
) )
} }
+15 -6
View File
@@ -16,16 +16,21 @@ import (
// //
// An empty value yields fallback, which lets the create path apply the // An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value // default and the edit path leave the stored value unchanged. A value
// of 0 means retain events forever and is returned as it is. Anything // of 0 is returned as 0 and is rewritten to the retain-forever
// unparseable or negative is refused rather than silently given a // sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// default. // or negative is refused rather than silently given a default.
// //
// The upper bound is not cosmetic. The reaper computes its cutoff as a // The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above // time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the // database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A value above that // future, and deletes every event the webhook has. A finite value
// ceiling is therefore refused, and the message names the ceiling // above that ceiling is therefore refused, and the message names the
// rather than implying the input was not a number. // ceiling rather than implying the input was not a number.
//
// 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, string) { func parseRetentionDays(raw string, fallback int) (int, string) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -38,6 +43,10 @@ func parseRetentionDays(raw string, fallback int) (int, string) {
"retain events forever." "retain events forever."
} }
if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, ""
}
if v > database.MaxFiniteRetentionDays { if v > database.MaxFiniteRetentionDays {
return 0, "Retention must be at most " + return 0, "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) + strconv.Itoa(database.MaxFiniteRetentionDays) +
+92 -53
View File
@@ -137,7 +137,8 @@ func onlyWebhook(
} }
// seedWebhookWithRetention inserts a webhook owned by the test user // seedWebhookWithRetention inserts a webhook owned by the test user
// with the given retention. // with an exact stored retention value, bypassing Webhook.BeforeSave
// via a column-level update so that legacy rows can be planted too.
func seedWebhookWithRetention( func seedWebhookWithRetention(
t *testing.T, t *testing.T,
db *database.Database, db *database.Database,
@@ -154,6 +155,13 @@ func seedWebhookWithRetention(
t, t,
db.DB().Omit(clause.Associations).Create(wh).Error, 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 return *wh
} }
@@ -217,9 +225,10 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
} }
} }
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever checks // TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core
// that the create form's 0 reaches the database as 0, which means // regression test for the bug: the create form's 0 must reach the
// retain forever. // database as the retain-forever sentinel rather than being replaced by
// the column's default of 30.
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever( func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
t *testing.T, t *testing.T,
) { ) {
@@ -232,45 +241,22 @@ func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db) wh := onlyWebhook(t, env.db)
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) assert.Equal(
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
assert.True(t, wh.RetainsForever()) assert.True(t, wh.RetainsForever())
} }
func TestHandleSourceCreateSubmit_DayCountIsStored(t *testing.T) { func TestHandleSourceCreateSubmit_OmittedRetentionUsesDefault(
t.Parallel()
env := setupSourceTest(t)
days := "45"
w := submitCreate(t, env.handlers, env.cookies, "finite", &days)
require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db)
assert.Equal(t, 45, storedRetentionDays(t, env.db, wh.ID))
}
// TestHandleSourceCreateSubmit_BlankRetentionUsesDefault covers a
// retention field left empty and one missing from the form: both
// give the default, not forever.
func TestHandleSourceCreateSubmit_BlankRetentionUsesDefault(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
blank := ""
for name, retention := range map[string]*string{
"blank": &blank,
"omitted": nil,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
w := submitCreate( w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil)
t, env.handlers, env.cookies, "defaulted", retention,
)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db) wh := onlyWebhook(t, env.db)
@@ -279,8 +265,6 @@ func TestHandleSourceCreateSubmit_BlankRetentionUsesDefault(
database.DefaultRetentionDays, database.DefaultRetentionDays,
storedRetentionDays(t, env.db, wh.ID), storedRetentionDays(t, env.db, wh.ID),
) )
})
}
} }
// TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create // TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create
@@ -306,7 +290,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
) )
assert.NotContains( assert.NotContains(
t, body, `max="365"`, t, body, `max="365"`,
"a max of 365 would block any longer retention", "a max below the sentinel would block retain-forever",
) )
assert.Contains(t, body, `min="0"`) assert.Contains(t, body, `min="0"`)
} }
@@ -384,6 +368,40 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
) )
} }
// 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. A value above the sentinel is stored as the sentinel.
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
t *testing.T,
) {
t.Parallel()
for _, days := range []int{
database.RetentionForeverDays,
database.RetentionForeverDays + 1,
} {
raw := strconv.Itoa(days)
t.Run(raw, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
w := submitCreate(t, env.handlers, env.cookies, "forever", &raw)
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 // TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
// validation failure hands the user's typing back, matching what the // validation failure hands the user's typing back, matching what the
// edit form already does. Losing a long description to a mistyped // edit form already does. Losing a long description to a mistyped
@@ -462,7 +480,11 @@ func TestHandleSourceEditSubmit_ZeroRetentionPersistsForever(
w := submitEdit(t, env, wh, "0") w := submitEdit(t, env, wh, "0")
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) assert.Equal(
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
} }
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected( func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
@@ -845,15 +867,18 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
assert.Equal(t, oldName, stored.Name) assert.Equal(t, oldName, stored.Name)
} }
// TestSourceEditForm_ForeverWebhookRoundTrips renders the edit form for // TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
// a retain-forever webhook, confirms it pre-fills 0, then submits that // the removed max="365" cap used to break: render the edit form for a
// pre-filled value straight back and confirms the retention policy // retain-forever webhook, confirm the pre-filled sentinel is not capped
// survives untouched. // by browser validation, then submit that pre-filled value straight
// back and confirm the retention policy survives untouched.
func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 0) wh := seedWebhookWithRetention(
t, env.db, database.RetentionForeverDays,
)
req := getRequest( req := getRequest(
t, "/hook/"+wh.ID+"/edit", env.cookies, t, "/hook/"+wh.ID+"/edit", env.cookies,
@@ -864,12 +889,17 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
sentinel := strconv.Itoa(database.RetentionForeverDays)
body := w.Body.String() body := w.Body.String()
assert.Contains( assert.Contains(
t, body, `name="retention_days" value="0"`, t, body, `value="`+sentinel+`"`,
"the edit form pre-fills the stored retention", "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 // "Currently forever." is the rendered RetentionLabel, not the
// static hint below the input, which says "Enter 0 to retain events // static hint below the input, which says "Enter 0 to retain events
// forever." A bare Contains of "forever" would pass for any // forever." A bare Contains of "forever" would pass for any
@@ -880,20 +910,29 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
) )
// Submit the pre-filled value back, exactly as a browser would. // Submit the pre-filled value back, exactly as a browser would.
post := submitEdit(t, env, wh, "0") post := submitEdit(t, env, wh, sentinel)
require.Equal(t, http.StatusSeeOther, post.Code) require.Equal(t, http.StatusSeeOther, post.Code)
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) assert.Equal(
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
} }
// TestSourceListAndDetail_ShowForeverNotZeroDays checks that a // TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that
// retention of 0 is shown as forever, never as a day count, on either // the retain-forever value is never rendered to the user as a raw day
// read-only view. // count on either read-only view.
func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) { func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
t *testing.T,
) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 0) wh := seedWebhookWithRetention(
t, env.db, database.RetentionForeverDays,
)
sentinel := strconv.Itoa(database.RetentionForeverDays)
listW := httptest.NewRecorder() listW := httptest.NewRecorder()
env.handlers.HandleSourceList().ServeHTTP( env.handlers.HandleSourceList().ServeHTTP(
@@ -902,7 +941,7 @@ func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
require.Equal(t, http.StatusOK, listW.Code) require.Equal(t, http.StatusOK, listW.Code)
assert.Contains(t, listW.Body.String(), "Retention: forever") assert.Contains(t, listW.Body.String(), "Retention: forever")
assert.NotContains(t, listW.Body.String(), "Retention: 0 days") assert.NotContains(t, listW.Body.String(), sentinel)
detailW := httptest.NewRecorder() detailW := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP( env.handlers.HandleSourceDetail().ServeHTTP(
@@ -915,5 +954,5 @@ func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
require.Equal(t, http.StatusOK, detailW.Code) require.Equal(t, http.StatusOK, detailW.Code)
assert.Contains(t, detailW.Body.String(), "Retention: forever") assert.Contains(t, detailW.Body.String(), "Retention: forever")
assert.NotContains(t, detailW.Body.String(), "Retention: 0 days") assert.NotContains(t, detailW.Body.String(), sentinel)
} }
+6 -2
View File
@@ -184,7 +184,8 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's // TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
// retention copy to what the code does: the reaper permanently deletes // retention copy to what the code does: the reaper permanently deletes
// events past the cutoff, an empty field falls back to // events past the cutoff, an empty field falls back to
// DefaultRetentionDays, and 0 means retain forever. // DefaultRetentionDays, and 0 is rewritten to the retain-forever
// sentinel by Webhook.BeforeSave.
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) { func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
@@ -252,7 +253,10 @@ func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) {
"blank means unchanged, not forever", "blank means unchanged, not forever",
) )
forever := &database.Webhook{Name: "wh", RetentionDays: 0} forever := &database.Webhook{
Name: "wh",
RetentionDays: database.RetentionForeverDays,
}
forever.ID = "wh-2" forever.ID = "wh-2"
foreverBody := renderPage( foreverBody := renderPage(
+1 -1
View File
@@ -500,7 +500,7 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
want string want string
}{ }{
{30, "30 days"}, {30, "30 days"},
{0, "forever"}, {database.RetentionForeverDays, "forever"},
} }
for _, tt := range tests { for _, tt := range tests {