Author SHA1 Message Date
sneak 7c273d2d19 Store a webhook's retention as a plain day count, 0 meaning forever (closes #539)
check / check (push) Waiting to run
retention_days loses its column default of 30, and Webhook.RetentionDays
its default tag, so a 0 is stored as 0. The 365000-day stand-in
RetentionForeverDays and the BeforeSave hook that wrote it are gone. The
reaper and the "forever" label read 0 as forever, and the forms accept 0
or 1 to MaxFiniteRetentionDays; a blank field still gives 30 on the new
webhook form and keeps the stored value on the edit form. A negative
value, which the forms refuse, is still read as forever so it cannot put
the reaper's cutoff in the future.

Model: opus-5-5
2026-10-15 11:35:45 +00:00
44 changed files with 854 additions and 1189 deletions
+42 -52
View File
@@ -1496,47 +1496,35 @@ the rotation chosen below that (none, monthly, daily or hourly). Both are
validated as on the add target form, and the webhook and its targets are created 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 (default: 30; 0 means retain forever) | | `retention_days` | integer | Days to retain events; 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.
Setting `retention_days` to `0` means "retain events forever". Because the The value is stored as entered. Setting `retention_days` to `0` means "retain
column carries a default of 30, a literal zero cannot survive an insert, so a events forever": the retention reaper skips the webhook entirely, and the web UI
zero is rewritten on save to a sentinel of `365 * 1000` days displays its retention as "forever" rather than as a day count.
(`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.
Submitted `retention_days` values therefore fall into three bands, not two: The new webhook and edit forms accept `0`, or a whole number of days from `1` up
to `database.MaxFiniteRetentionDays` (106751 days, about 292 years). Anything
- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is else, a negative value or one above that cap included, is rejected with a 400. A
accepted as a finite retention. blank field gives the default of 30 days (`database.DefaultRetentionDays`) on
- Above that ceiling but below the retain-forever sentinel of 365000 the new webhook form, and leaves the stored value unchanged on the edit form.
(`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, so no stored value can trigger that either. value it reads to the cap, and reads a negative value as forever, so no stored
value can trigger that either.
#### Entrypoint #### Entrypoint
@@ -1568,27 +1556,29 @@ did not arrive on the URL and counts in neither.
A delivery destination for events. Each target defines where and how events A delivery destination for events. Each target defines where and how events
should be forwarded. should be forwarded.
| Field | Type | Description | | Field | Type | Description |
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook | | `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name | | `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) | | `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration | | `config` | JSON text | Type-specific configuration |
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | | `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries. **Relations:** Belongs to Webhook. Has many Deliveries.
**Target types:** **Target types:**
- **`http`** — Forward the event as an HTTP POST to a configured URL. With - **`http`** — Forward the event as an HTTP POST to a configured URL.
`max_attempts` at 1 the target makes a single attempt; above 1 it retries a `max_retries` is the total number of delivery attempts, not retries on top of
failed delivery with exponential backoff, up to that many attempts in all, the first: when `max_retries` is 0 (the default), the target operates in
behind a per-target circuit breaker. fire-and-forget mode, a single attempt with no retries and no circuit breaker;
a value of N makes up to N attempts in all, retrying failed deliveries with
exponential backoff and protecting them with a per-target circuit breaker.
- **`slack`** — Post the event as a formatted message to a Slack-compatible - **`slack`** — Post the event as a formatted message to a Slack-compatible
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
core as `http` and honours `max_attempts` identically, circuit breaker core as `http` and honours `max_retries` identically, circuit breaker
included. See the Slack target section under "Per-Webhook Event Databases" for included. See the Slack target section under "Per-Webhook Event Databases" for
the message format. the message format.
- **`database`** — Archive the full event as a row into the target's own archive - **`database`** — Archive the full event as a row into the target's own archive
@@ -2163,11 +2153,11 @@ External Service
▼ ▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ HTTP Target│ │ HTTP Target│ │ Log Target │ │ HTTP Target│ │ HTTP Target│ │ Log Target │
│max_attempts│ │max_attempts│ │ (stdout) │ │(max_retries│ │(max_retries│ │ (stdout) │
│ == 1, │ │ > 1, │ └────────────┘ │ == 0) │ │ > 0, │ └────────────┘
│ one attempt│ │ backoff + │ │ fire+forget│ │ backoff + │
└────────────┘ │ circuit │ └────────────┘ │ circuit │
│ breaker │ │ breaker) │
└────────────┘ └────────────┘
``` ```
@@ -2244,7 +2234,7 @@ re-inject it to every currently active target.
### Circuit Breaker (HTTP and Slack Targets with Retries) ### Circuit Breaker (HTTP and Slack Targets with Retries)
`http` and `slack` targets with `max_attempts` > 1 are protected by a `http` and `slack` targets with `max_retries` > 0 are protected by a
**per-target circuit breaker** that prevents hammering a down target with **per-target circuit breaker** that prevents hammering a down target with
repeated failed delivery attempts. The circuit breaker is in-memory only and repeated failed delivery attempts. The circuit breaker is in-memory only and
resets on restart (which is fine — startup recovery rescans the database resets on restart (which is fine — startup recovery rescans the database
@@ -2285,10 +2275,10 @@ anyway).
- **Cooldown:** 30 seconds in open state before probing - **Cooldown:** 30 seconds in open state before probing
**Scope:** Circuit breakers apply to **`http` and `slack` targets with **Scope:** Circuit breakers apply to **`http` and `slack` targets with
`max_attempts` > 1**. The Slack target is built on the same HTTP core and hands `max_retries` > 0**. The Slack target is built on the same HTTP core and hands
its own `max_attempts` to the same retry path, so it gets a breaker with the its own `max_retries` to the same retry path, so it gets a breaker with the same
same 5-failure / 30-second defaults. Single-attempt targets of either type 5-failure / 30-second defaults. Fire-and-forget targets of either type
(`max_attempts` == 1), database targets (local operations), and log targets (`max_retries` == 0), database targets (local operations), and log targets
(stdout) do not use circuit breakers. (stdout) do not use circuit breakers.
When a circuit is open and a new delivery arrives, the engine marks the delivery When a circuit is open and a new delivery arrives, the engine marks the delivery
+3 -5
View File
@@ -29,11 +29,9 @@ type Target struct {
// handler that marshals the model itself. // handler that marshals the model itself.
Config string `gorm:"type:text" json:"-"` // JSON configuration Config string `gorm:"type:text" json:"-"` // JSON configuration
// MaxAttempts is how many times an HTTP or Slack target attempts // For HTTP targets (max_retries=0 means fire-and-forget,
// each delivery in all, at least 1. 1 is a single attempt with no // >0 enables retries with backoff)
// circuit breaker; above 1, a failed attempt is retried with MaxRetries int `json:"maxRetries,omitempty"`
// backoff behind the breaker.
MaxAttempts int `json:"maxAttempts,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop. // Webhook.Targets leads back here, and the JSON could loop.
+19 -56
View File
@@ -4,27 +4,13 @@ import (
"math" "math"
"strconv" "strconv"
"time" "time"
"gorm.io/gorm"
) )
const ( const (
// DefaultRetentionDays is the event retention period applied to a // DefaultRetentionDays is the event retention period the new
// webhook created without an explicit retention value. It is the // webhook form applies when its retention field is left blank.
// 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
@@ -38,9 +24,7 @@ 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. Note that RetentionForeverDays deliberately sits above this // it.
// 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,
) )
@@ -48,12 +32,10 @@ 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. BeforeSave has to, // Every method below takes a pointer receiver, so handlers put a
// because it mutates the record and GORM only invokes hooks declared // *Webhook into template data: html/template cannot call a pointer
// that way; the display helpers follow suit so the receiver kinds do // method on a value held in a map, because a map element is not
// not mix. Handlers therefore put a *Webhook into template data: // addressable.
// 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
@@ -61,10 +43,9 @@ 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. A value of // RetentionDays is the number of days to retain events, and 0
// RetentionForeverDays means retain forever. The column default // means retain them forever.
// must equal DefaultRetentionDays. RetentionDays int `json:"retentionDays"`
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.
@@ -73,36 +54,17 @@ type Webhook struct {
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
// BeforeSave normalises RetentionDays on every insert and update. A // retainsForever reports whether a RetentionDays value means "keep
// non-positive value is the user's way of asking for "retain forever", // events indefinitely", which 0 does. It is the single definition of
// which is stored as the RetentionForeverDays sentinel. // that question, shared by Webhook.RetainsForever and by the reaper's
//
// 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.
// //
// It accepts the RetentionForeverDays sentinel written by BeforeSave // A negative value, which the forms refuse, is read the same way: as a
// and, defensively, any non-positive value, which BeforeSave treats // day count it would put the cutoff in the future, where it matches
// as a request to retain forever. // every event the webhook has.
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
@@ -119,7 +81,8 @@ 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, so that no template has to know about the sentinel value. // text: "forever" rather than "0 days" for a webhook that keeps its
// events.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
if w.RetainsForever() { if w.RetainsForever() {
return "forever" return "forever"
+10 -72
View File
@@ -2,8 +2,6 @@ package database_test
import ( import (
"context" "context"
"reflect"
"strconv"
"testing" "testing"
"time" "time"
@@ -47,8 +45,7 @@ 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, so the BeforeSave hook and the GORM column default both apply // path, as the new webhook form does.
// exactly as they do in production.
func newWebhookWithRetention( func newWebhookWithRetention(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
@@ -67,7 +64,7 @@ func newWebhookWithRetention(
return wh.ID return wh.ID
} }
func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) { func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -75,31 +72,10 @@ func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(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( assert.Equal(t, 0, storedRetention(t, db, id))
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) { func TestWebhookRetentionDays_DayCountIsStored(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)
@@ -110,10 +86,9 @@ func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
assert.Equal(t, 7, storedRetention(t, db, id)) assert.Equal(t, 7, storedRetention(t, db, id))
} }
// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook // TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day
// fires on update as well as insert, via the same Save call the edit // count to 0 through the same Save call the edit handler makes.
// handler makes. func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
t.Parallel() t.Parallel()
db := startedTestDB(t) db := startedTestDB(t)
@@ -125,28 +100,7 @@ func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(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( assert.Equal(t, 0, storedRetention(t, db, id))
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
@@ -176,14 +130,6 @@ 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) {
@@ -195,16 +141,8 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
forever bool forever bool
label string label string
}{ }{
{ {"zero", 0, true, testForeverLabel},
"sentinel", {"negative", -1, true, testForeverLabel},
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"},
} }
+2 -1
View File
@@ -243,7 +243,8 @@ 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 retain-forever policy, so no DELETE is issued. // reports false for a retention of 0, which means retain forever, so
// 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
+19 -65
View File
@@ -85,41 +85,6 @@ 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
} }
@@ -281,19 +246,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
assertChainPresent(t, db, recent) assertChainPresent(t, db, recent)
} }
// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the // TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the
// end-to-end retain-forever path: a webhook created the normal way with // end-to-end retain-forever path: a webhook created with a retention
// a requested retention of 0 lands on the RetentionForeverDays // of 0 reads back 0, and the reaper leaves its ancient events alone
// sentinel, and the reaper leaves its ancient events alone while still // while still reaping a finite-retention webhook in the very same
// reaping a finite-retention webhook in the very same sweep. // sweep.
func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep( func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
foreverID := createWebhookNormally(t, env.mainDB.DB(), 0) foreverID := createWebhook(t, env.mainDB.DB(), 0)
var stored database.Webhook var stored database.Webhook
@@ -302,14 +267,12 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
env.mainDB.DB().Where("id = ?", foreverID). env.mainDB.DB().Where("id = ?", foreverID).
First(&stored).Error, First(&stored).Error,
) )
require.Equal( require.Zero(
t, t, stored.RetentionDays,
database.RetentionForeverDays, "a retention of 0 must be stored as 0",
stored.RetentionDays,
"a requested retention of 0 must persist as the sentinel",
) )
finiteID := createWebhookNormally(t, env.mainDB.DB(), 30) finiteID := createWebhook(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)
@@ -335,14 +298,10 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
// 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 and below the // on the first sweep. 200000 is inside that band.
// 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 // The form refuses such a value; the point of the test is that a row
// longer be submitted through the form; the point of the test is that // written by any other path still cannot trigger the wipe.
// 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,
) { ) {
@@ -358,12 +317,6 @@ 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,
@@ -379,14 +332,15 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
assertChainPresent(t, db, fresh) assertChainPresent(t, db, fresh)
} }
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) { // TestRetentionReaper_RetainsForeverWhenNegative checks that a negative
// 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)
// A legacy row written before the sentinel existed still carries a webhookID := createWebhook(t, env.mainDB.DB(), -1)
// 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)
+7 -7
View File
@@ -87,13 +87,13 @@ func TestMainSchemaHoldsEveryModel(t *testing.T) {
Active: true, Active: true,
}) })
assertRoundTrip(t, db, &database.Target{ assertRoundTrip(t, db, &database.Target{
BaseModel: newBaseModel(), BaseModel: newBaseModel(),
WebhookID: uuid.New().String(), WebhookID: uuid.New().String(),
Name: "target", Name: "target",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Active: true, Active: true,
Config: `{"url":"https://example.com/"}`, Config: `{"url":"https://example.com/"}`,
MaxAttempts: 3, MaxRetries: 3,
}) })
} }
+2 -2
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 DEFAULT 30, retention_days integer,
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)
); );
@@ -80,7 +80,7 @@ CREATE TABLE targets (
type text NOT NULL, type text NOT NULL,
active numeric DEFAULT true, active numeric DEFAULT true,
config text, config text,
max_attempts integer, max_retries integer,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_webhooks_targets CONSTRAINT fk_webhooks_targets
FOREIGN KEY (webhook_id) REFERENCES webhooks (id) FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
+7 -7
View File
@@ -107,7 +107,7 @@ type Task struct {
TargetName string TargetName string
TargetType database.TargetType TargetType database.TargetType
TargetConfig string TargetConfig string
MaxAttempts int MaxRetries int
Method string Method string
RawQuery string RawQuery string
@@ -1769,10 +1769,10 @@ func buildEventFromTask(task *Task) database.Event {
func buildTargetFromTask(task *Task) database.Target { func buildTargetFromTask(task *Task) database.Target {
target := database.Target{ target := database.Target{
Name: task.TargetName, Name: task.TargetName,
Type: task.TargetType, Type: task.TargetType,
Config: task.TargetConfig, Config: task.TargetConfig,
MaxAttempts: task.MaxAttempts, MaxRetries: task.MaxRetries,
} }
target.ID = task.TargetID target.ID = task.TargetID
@@ -2028,7 +2028,7 @@ func (e *Engine) countAttemptsBatch(
// One delivery id per recorded attempt, tallied here rather than // One delivery id per recorded attempt, tallied here rather than
// grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which // grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which
// a GROUP BY into a struct would need, and an attempt row per // a GROUP BY into a struct would need, and an attempt row per
// delivery is bounded by the target's MaxAttempts. // delivery is bounded by the target's MaxRetries.
var attemptIDs []string var attemptIDs []string
err := webhookDB. err := webhookDB.
@@ -2106,7 +2106,7 @@ func buildRecoveryTask(
TargetName: target.Name, TargetName: target.Name,
TargetType: target.Type, TargetType: target.Type,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxAttempts: target.MaxAttempts, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
+21 -21
View File
@@ -170,7 +170,7 @@ func iTask(
d database.Delivery, d database.Delivery,
event database.Event, event database.Event,
webhookID, targetID, name, config string, webhookID, targetID, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
body *string, body *string,
) delivery.Task { ) delivery.Task {
return delivery.Task{ return delivery.Task{
@@ -181,7 +181,7 @@ func iTask(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
@@ -242,7 +242,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-target", cfg, 1, 1, &bodyStr, "test-target", cfg, 0, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -293,7 +293,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-large", cfg, 1, 1, nil, "test-large", cfg, 0, 1, nil,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -320,7 +320,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
TargetName: "test", TargetName: "test",
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: iHTTPConfig("http://localhost:9999"), TargetConfig: iHTTPConfig("http://localhost:9999"),
MaxAttempts: 1, MaxRetries: 0,
Body: nil, Body: nil,
AttemptNum: 1, AttemptNum: 1,
} }
@@ -645,7 +645,7 @@ func TestRecoverPendingDeliveries(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recovery-target", s.WebhookID, "recovery-target",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
iSeedPendingDeliveries( iSeedPendingDeliveries(
@@ -686,17 +686,17 @@ func iCreateTarget(
targetID, webhookID, name string, targetID, webhookID, name string,
targetType database.TargetType, targetType database.TargetType,
config string, config string,
maxAttempts int, maxRetries int,
) { ) {
t.Helper() t.Helper()
target := database.Target{ target := database.Target{
WebhookID: webhookID, WebhookID: webhookID,
Name: name, Name: name,
Type: targetType, Type: targetType,
Active: true, Active: true,
Config: config, Config: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
} }
target.ID = targetID target.ID = targetID
@@ -1077,7 +1077,7 @@ func TestRecoverInFlight_WithPendingDeliveries(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recover-target", s.WebhookID, "recover-target",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -1232,7 +1232,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"auth-target", string(cfgJSON), "auth-target", string(cfgJSON),
1, 1, &bodyStr, 0, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -1277,7 +1277,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "timeout-target", event, del, "timeout-target",
string(cfgJSON), 1, 1, string(cfgJSON), 0, 1,
) )
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1339,7 +1339,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
del, event, s.WebhookID, targetID, del, event, s.WebhookID, targetID,
"cut-target", cfgJSON, 1, 1, &bodyStr, "cut-target", cfgJSON, 0, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -1403,7 +1403,7 @@ func iHTTPTaskAndDelivery(
event database.Event, event database.Event,
del database.Delivery, del database.Delivery,
name, config string, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
) (*delivery.Task, *database.Delivery) { ) (*delivery.Task, *database.Delivery) {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: del.ID, DeliveryID: del.ID,
@@ -1413,7 +1413,7 @@ func iHTTPTaskAndDelivery(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -1466,7 +1466,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
) )
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1, event, del, "bad-config", `not-json`, 0, 1,
) )
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1490,7 +1490,7 @@ func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
) )
task, d := iHTTPTaskAndDelivery( task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1, event, del, "bad-config", `not-json`, 0, 1,
) )
require.NoError(t, db.Exec("drop table delivery_results").Error) require.NoError(t, db.Exec("drop table delivery_results").Error)
+7 -7
View File
@@ -278,7 +278,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
} }
// seedHeldDelivery gives the setup's webhook an http target with // seedHeldDelivery gives the setup's webhook an http target with
// maxAttempts, pointed at a receiver that keeps every request open until // 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 // 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 // status. It returns the task that sends attempt attemptNum of the
// delivery, and a channel that is closed when a request reaches the // delivery, and a channel that is closed when a request reaches the
@@ -286,7 +286,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
func seedHeldDelivery( func seedHeldDelivery(
t *testing.T, t *testing.T,
s iSetup, s iSetup,
maxAttempts int, maxRetries int,
status database.DeliveryStatus, status database.DeliveryStatus,
attemptNum int, attemptNum int,
) (delivery.Task, <-chan struct{}) { ) (delivery.Task, <-chan struct{}) {
@@ -315,7 +315,7 @@ func seedHeldDelivery(
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held", t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, cfg, maxAttempts, database.TargetTypeHTTP, cfg, maxRetries,
) )
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
@@ -325,7 +325,7 @@ func seedHeldDelivery(
return iTask( return iTask(
d, event, s.WebhookID, targetID, "held", cfg, d, event, s.WebhookID, targetID, "held", cfg,
maxAttempts, attemptNum, &bodyStr, maxRetries, attemptNum, &bodyStr,
), arrived ), arrived
} }
@@ -380,7 +380,7 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery( task, arrived := seedHeldDelivery(
t, s, 1, database.DeliveryStatusPending, 1, t, s, 0, database.DeliveryStatusPending, 1,
) )
s.Engine.Notify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
@@ -404,13 +404,13 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel() t.Parallel()
const maxAttempts = 2 const maxRetries = 2
s := newISetup(t) s := newISetup(t)
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery( task, arrived := seedHeldDelivery(
t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts, t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries,
) )
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID) iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
+24 -110
View File
@@ -142,7 +142,7 @@ func buildHTTPFixture(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, config string, targetID, name, config string,
maxAttempts, attemptNum int, maxRetries, attemptNum int,
) httpDeliveryFixture { ) httpDeliveryFixture {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -152,7 +152,7 @@ func buildHTTPFixture(
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetConfig: config, TargetConfig: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
@@ -162,10 +162,10 @@ func buildHTTPFixture(
Status: dlv.Status, Status: dlv.Status,
Event: event, Event: event,
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: config, Config: config,
MaxAttempts: maxAttempts, MaxRetries: maxRetries,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -277,7 +277,7 @@ func TestDeliverHTTP_Success(t *testing.T) {
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, "test-http", cfg, 1, 1, dlv, event, targetID, "test-http", cfg, 0, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -324,7 +324,7 @@ func TestDeliverHTTP_Failure(t *testing.T) {
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-fail", cfg, 1, 1, "test-http-fail", cfg, 0, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -473,23 +473,14 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
) )
} }
// TestDeliverHTTP_MaxAttemptsExhausted proves a target with three func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
// delivery attempts makes at most three: the first two failures are
// retried, the third is final, and each attempt goes through the
// target's circuit breaker.
func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
t.Parallel() t.Parallel()
const maxAttempts = 3
db := testWebhookDB(t) db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
}, },
), ),
@@ -498,103 +489,26 @@ func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
// It opens on the last failure only if every attempt reached it.
cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute)
e.ExportSetCircuitBreaker(targetID, cb)
event := seedEvent(t, db, `{"retry":"exhaust"}`) event := seedEvent(t, db, `{"retry":"exhaust"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID, targetID,
database.DeliveryStatusPending, database.DeliveryStatusRetrying,
)
cfg := newHTTPTargetConfig(ts.URL)
sched := &recordingScheduler{}
for attempt := 1; attempt <= maxAttempts; attempt++ {
fix := buildHTTPFixture(
dlv, event, targetID,
"test-http-exhaust", cfg, maxAttempts, attempt,
)
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
)
}
assert.Equal(t, int32(maxAttempts), requests.Load())
assert.Len(t, sched.delays, maxAttempts-1,
"every failure but the last should be retried",
)
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed,
)
assert.Equal(t, delivery.CircuitOpen, cb.State(),
"every attempt should be counted by the circuit breaker",
)
}
// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target
// with one delivery attempt makes exactly that attempt, even while
// its circuit breaker is open, and that a failure is final.
func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var requests atomic.Int32
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway)
},
),
)
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
cb := e.ExportGetCircuitBreaker(targetID)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
require.Equal(t, delivery.CircuitOpen, cb.State())
event := seedEvent(t, db, `{"retry":"none"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL) cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-single", cfg, 1, 1, "test-http-exhaust", cfg, 3, 3,
)
sched := &recordingScheduler{}
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
) )
assert.Equal(t, int32(1), requests.Load(), e.ExportDeliverHTTP(
"the open circuit breaker should not hold the attempt back", context.TODO(), db, fix.Delivery, fix.Task,
) )
assert.Empty(t, sched.delays, "no retry should be scheduled")
assertDeliveryStatus(t, db, dlv.ID, assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
assertDeliveryResult(
t, db, dlv.ID, false, http.StatusBadGateway,
)
} }
func TestDeliverHTTP_SchedulesRetryOnFailure( func TestDeliverHTTP_SchedulesRetryOnFailure(
@@ -827,7 +741,7 @@ func seedConcurrencyTasks(
TargetName: tasks[i].Target.Name, TargetName: tasks[i].Target.Name,
TargetType: tasks[i].Target.Type, TargetType: tasks[i].Target.Type,
TargetConfig: tasks[i].Target.Config, TargetConfig: tasks[i].Target.Config,
MaxAttempts: 1, MaxRetries: 0,
AttemptNum: 1, AttemptNum: 1,
} }
} }
@@ -2137,10 +2051,10 @@ func buildSlackRetryDelivery(
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Event: event, Event: event,
Target: database.Target{ Target: database.Target{
Name: "test-slack-retry", Name: "test-slack-retry",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Config: cfg, Config: cfg,
MaxAttempts: 5, MaxRetries: 5,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -2175,11 +2089,11 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
) )
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
TargetID: targetID, TargetID: targetID,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
MaxAttempts: 5, MaxRetries: 5,
AttemptNum: 1, AttemptNum: 1,
} }
e.ExportProcessDelivery(context.TODO(), db, d, task) e.ExportProcessDelivery(context.TODO(), db, d, task)
+1 -1
View File
@@ -152,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", cfg, 1, 1, &body, "leaky-target", cfg, 0, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
+1 -1
View File
@@ -176,7 +176,7 @@ func tsTask(
TargetName: target.Name, TargetName: target.Name,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxAttempts: 1, MaxRetries: 0,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
+1 -1
View File
@@ -229,7 +229,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "released", s.WebhookID, "released",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
+10 -10
View File
@@ -291,20 +291,20 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
// An attempt budget above the failure threshold, so the breaker // A retry budget above the failure threshold, so the breaker
// rather than the budget is what stops the delivery. // rather than the budget is what stops the delivery.
maxAttempts := delivery.ExportDefaultFailureThreshold + 5 maxRetries := delivery.ExportDefaultFailureThreshold + 5
// The retries below are only run if their target still exists; // The retries below are only run if their target still exists;
// see https://git.eeqj.de/sneak/webhooker/issues/107. // see https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-trip", t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
database.TargetTypeHTTP, cfg, maxAttempts, database.TargetTypeHTTP, cfg, maxRetries,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxAttempts, 1, &body, "metrics-trip", cfg, maxRetries, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -316,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxAttempts, attempt, &body, "metrics-trip", cfg, maxRetries, attempt, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -364,16 +364,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
maxAttempts := delivery.ExportDefaultFailureThreshold + 5 maxRetries := delivery.ExportDefaultFailureThreshold + 5
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
database.TargetTypeHTTP, cfg, maxAttempts, database.TargetTypeHTTP, cfg, maxRetries,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxAttempts, 1, &body, "metrics-blocked", cfg, maxRetries, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -382,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxAttempts, attempt, &body, "metrics-blocked", cfg, maxRetries, attempt, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &task) s.Engine.ExportProcessRetryTask(context.TODO(), &task)
@@ -405,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
blocked := iTask( blocked := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxAttempts, "metrics-blocked", cfg, maxRetries,
delivery.ExportDefaultFailureThreshold+1, &body, delivery.ExportDefaultFailureThreshold+1, &body,
) )
@@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "already-delivered", s.WebhookID, "already-delivered",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "numbering", s.WebhookID, "numbering",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -444,10 +444,10 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Event: event, Event: event,
Target: database.Target{ Target: database.Target{
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL), Config: iHTTPConfig(ts.URL),
MaxAttempts: 3, MaxRetries: 3,
}, },
} }
full.ID = d.ID full.ID = d.ID
+14 -9
View File
@@ -120,8 +120,8 @@ func unavailableConfigFields() []ConfigField {
} }
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target: its masked
// webhook URL and its number of delivery attempts. Only the // webhook URL and its retry count. Only the masked URL is
// masked URL is shown; the full URL is the credential. // shown; the full URL is the credential.
func slackConfigFields(t *database.Target) []ConfigField { func slackConfigFields(t *database.Target) []ConfigField {
cfg, err := parseSlackConfig(t.Config) cfg, err := parseSlackConfig(t.Config)
if err != nil { if err != nil {
@@ -131,7 +131,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(), Value: cfg.MaskedWebhookURL(),
}, maxAttemptsField(t)} }, maxRetriesField(t)}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -178,18 +178,23 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
fields = append(fields, maxAttemptsField(t)) fields = append(fields, maxRetriesField(t))
return fields return fields
} }
// maxAttemptsField describes a target's number of delivery // maxRetriesField describes a target's retry count, which lives
// attempts, which lives on the target row rather than in its // on the target row rather than in its configuration blob. A
// configuration blob. // stored 0 makes a single attempt, so it is shown as 1.
func maxAttemptsField(t *database.Target) ConfigField { func maxRetriesField(t *database.Target) ConfigField {
attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Delivery attempts",
Value: strconv.Itoa(t.MaxAttempts), Value: attempts,
} }
} }
+15 -20
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxAttempts = "Delivery attempts" viewMaxRetries = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -171,7 +171,6 @@ func slackTarget() database.Target {
Active: true, Active: true,
Config: `{"webhookUrl":"` + Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`, slackWebhookURL + `"}`,
MaxAttempts: 1,
} }
} }
@@ -190,29 +189,28 @@ func TestNewTargetViews_Slack(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxAttempts: "1", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
} }
// TestNewTargetViews_SlackAttempts proves a Slack target shows // TestNewTargetViews_SlackRetries proves a Slack target shows
// its number of delivery attempts the same way an HTTP target // its retry count the same way an HTTP target does.
// does. func TestNewTargetViews_SlackRetries(t *testing.T) {
func TestNewTargetViews_SlackAttempts(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() target := slackTarget()
target.MaxAttempts = 2 target.MaxRetries = 2
view := viewFor(t, target) view := viewFor(t, target)
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxAttempts: "2", viewMaxRetries: "2",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -227,7 +225,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` + `"headers":{"Authorization":"Bearer sekrit"},` +
`"forwardQuery":true}`, `"forwardQuery":true}`,
MaxAttempts: 5, MaxRetries: 5,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -239,7 +237,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
"Query string": "passed on to this target", "Query string": "passed on to this target",
viewMaxAttempts: "5", viewMaxRetries: "5",
}, },
fields, fields,
) )
@@ -250,22 +248,19 @@ func TestNewTargetViews_HTTP(t *testing.T) {
} }
} }
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
// shown as the plain number, like any other.
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `"}`, Config: `{"url":"` + viewExampleHook + `"}`,
MaxAttempts: 1,
}) })
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxAttempts: "1", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
+12 -12
View File
@@ -53,10 +53,10 @@ type httpCore struct {
} }
// deliver runs one delivery attempt through the retry core. // deliver runs one delivery attempt through the retry core.
// A maxAttempts of 1 is a single attempt, recorded without // A maxRetries of 0 is fire-and-forget: a single attempt is
// consulting a circuit breaker. A higher maxAttempts gates the // recorded and no circuit breaker is consulted. A positive
// attempt on the circuit breaker and schedules a backed-off retry // maxRetries gates the attempt on the circuit breaker and
// on failure, until maxAttempts attempts have been made. // schedules a backed-off retry on failure.
// //
// ctx is the context attempt sends its request with. Stopping the // ctx is the context attempt sends its request with. Stopping the
// engine cancels it; see interruptedByStop. // engine cancels it; see interruptedByStop.
@@ -66,17 +66,17 @@ func (c *httpCore) deliver(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
if maxAttempts <= 1 { if maxRetries == 0 {
c.fireAndForget(ctx, webhookDB, d, attempt()) c.fireAndForget(ctx, webhookDB, d, attempt())
return return
} }
c.withRetry( c.withRetry(
ctx, webhookDB, d, task, sched, maxAttempts, attempt, ctx, webhookDB, d, task, sched, maxRetries, attempt,
) )
} }
@@ -153,7 +153,7 @@ func (c *httpCore) withRetry(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attempt func() attemptResult, attempt func() attemptResult,
) { ) {
cb := c.getCircuitBreaker(task.TargetID) cb := c.getCircuitBreaker(task.TargetID)
@@ -205,7 +205,7 @@ func (c *httpCore) withRetry(
cb.RecordFailure() cb.RecordFailure()
c.handleRetry( c.handleRetry(
webhookDB, d, task, sched, maxAttempts, attemptNum, webhookDB, d, task, sched, maxRetries, attemptNum,
) )
} }
@@ -266,10 +266,10 @@ func (c *httpCore) handleRetry(
d *database.Delivery, d *database.Delivery,
task *Task, task *Task,
sched Scheduler, sched Scheduler,
maxAttempts int, maxRetries int,
attemptNum int, attemptNum int,
) { ) {
if attemptNum >= maxAttempts { if attemptNum >= maxRetries {
c.eng.settleStatus( c.eng.settleStatus(
webhookDB, d, d.Target.Type, webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
@@ -439,7 +439,7 @@ func (t *httpTarget) Deliver(
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, ctx, webhookDB, d, task, sched,
d.Target.MaxAttempts, attempt, d.Target.MaxRetries, attempt,
) )
} }
+4 -3
View File
@@ -30,8 +30,9 @@ type SlackTargetConfig struct {
// slackTarget delivers events to Slack incoming webhooks. It // slackTarget delivers events to Slack incoming webhooks. It
// formats the event into a Slack message and posts it as // formats the event into a Slack message and posts it as
// JSON. It shares the retry core with the HTTP target: a // JSON. It shares the retry core with the HTTP target: a
// MaxAttempts of 1 is a single attempt, while a higher one // MaxRetries of 0 stays single-attempt fire-and-forget
// adds backoff and circuit breaking. // (preserving existing Slack targets), while a positive
// MaxRetries adds backoff and circuit breaking.
type slackTarget struct { type slackTarget struct {
*httpCore *httpCore
@@ -82,7 +83,7 @@ func (t *slackTarget) Deliver(
t.deliver( t.deliver(
ctx, webhookDB, d, task, sched, ctx, webhookDB, d, task, sched,
d.Target.MaxAttempts, attempt, d.Target.MaxRetries, attempt,
) )
} }
+2 -2
View File
@@ -743,7 +743,7 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup", t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -790,7 +790,7 @@ func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "healthy", t, s.MainDB, targetID, s.WebhookID, "healthy",
database.TargetTypeLog, "", 1, database.TargetTypeLog, "", 0,
) )
event := iSeedEvent( event := iSeedEvent(
+1 -1
View File
@@ -308,7 +308,7 @@ func createReplayDelivery(
TargetName: target.Name, TargetName: target.Name,
TargetType: target.Type, TargetType: target.Type,
TargetConfig: target.Config, TargetConfig: target.Config,
MaxAttempts: target.MaxAttempts, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
+1 -1
View File
@@ -496,7 +496,7 @@ const deliveryIDChunkSize = 500
// //
// Each response body is cut by SQLite rather than in Go, for // Each response body is cut by SQLite rather than in Go, for
// the reason deliveryResultColumns gives. How many attempts a // the reason deliveryResultColumns gives. How many attempts a
// delivery has is bounded by the target's MaxAttempts, which the // delivery has is the target's MaxRetries, which the
// authenticated operator sets; how many of them reach the page // authenticated operator sets; how many of them reach the page
// is bounded again by maxRenderedAttempts. // is bounded again by maxRenderedAttempts.
func (h *Handlers) loadDeliveryResults( func (h *Handlers) loadDeliveryResults(
+2 -2
View File
@@ -37,10 +37,10 @@ const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
// per-delivery attempt ceiling to the handlers_test package. // per-delivery attempt ceiling to the handlers_test package.
const MaxRenderedAttemptsForTest = maxRenderedAttempts const MaxRenderedAttemptsForTest = maxRenderedAttempts
// MaxTargetAttemptsForTest exposes the target max_attempts ceiling to // MaxTargetRetriesForTest exposes the target max_retries ceiling to
// the handlers_test package, so the tests assert against the constant // the handlers_test package, so the tests assert against the constant
// the handlers enforce rather than a number copied beside it. // the handlers enforce rather than a number copied beside it.
const MaxTargetAttemptsForTest = maxTargetAttempts const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two // EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event // messages the webhook delete handler logs when a file of the event
+6 -15
View File
@@ -16,21 +16,16 @@ 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 is returned as 0 and is rewritten to the retain-forever // of 0 means retain events forever and is returned as it is. Anything
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable // unparseable or negative is refused rather than silently given a
// or negative is refused rather than silently given a default. // 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 finite value // future, and deletes every event the webhook has. A value above that
// above that ceiling is therefore refused, and the message names the // ceiling is therefore refused, and the message names the ceiling
// ceiling rather than implying the input was not a number. // 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 == "" {
@@ -43,10 +38,6 @@ 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) +
+61 -100
View File
@@ -137,8 +137,7 @@ func onlyWebhook(
} }
// seedWebhookWithRetention inserts a webhook owned by the test user // seedWebhookWithRetention inserts a webhook owned by the test user
// with an exact stored retention value, bypassing Webhook.BeforeSave // with the given retention.
// 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,
@@ -155,13 +154,6 @@ 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
} }
@@ -225,10 +217,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
} }
} }
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core // TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever checks
// regression test for the bug: the create form's 0 must reach the // that the create form's 0 reaches the database as 0, which means
// database as the retain-forever sentinel rather than being replaced by // retain forever.
// the column's default of 30.
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever( func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
t *testing.T, t *testing.T,
) { ) {
@@ -241,30 +232,55 @@ 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( assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
assert.True(t, wh.RetainsForever()) assert.True(t, wh.RetainsForever())
} }
func TestHandleSourceCreateSubmit_OmittedRetentionUsesDefault( func TestHandleSourceCreateSubmit_DayCountIsStored(t *testing.T) {
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()
env := setupSourceTest(t) blank := ""
w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil) for name, retention := range map[string]*string{
require.Equal(t, http.StatusSeeOther, w.Code) "blank": &blank,
"omitted": nil,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
wh := onlyWebhook(t, env.db) env := setupSourceTest(t)
assert.Equal(
t, w := submitCreate(
database.DefaultRetentionDays, t, env.handlers, env.cookies, "defaulted", retention,
storedRetentionDays(t, env.db, wh.ID), )
) 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 // TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create
@@ -290,7 +306,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
) )
assert.NotContains( assert.NotContains(
t, body, `max="365"`, t, body, `max="365"`,
"a max below the sentinel would block retain-forever", "a max of 365 would block any longer retention",
) )
assert.Contains(t, body, `min="0"`) assert.Contains(t, body, `min="0"`)
} }
@@ -368,40 +384,6 @@ 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
@@ -480,11 +462,7 @@ 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( assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
} }
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected( func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
@@ -867,18 +845,15 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
assert.Equal(t, oldName, stored.Name) assert.Equal(t, oldName, stored.Name)
} }
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that // TestSourceEditForm_ForeverWebhookRoundTrips renders the edit form for
// the removed max="365" cap used to break: render the edit form for a // a retain-forever webhook, confirms it pre-fills 0, then submits that
// retain-forever webhook, confirm the pre-filled sentinel is not capped // pre-filled value straight back and confirms the retention policy
// by browser validation, then submit that pre-filled value straight // survives untouched.
// 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( wh := seedWebhookWithRetention(t, env.db, 0)
t, env.db, database.RetentionForeverDays,
)
req := getRequest( req := getRequest(
t, "/hook/"+wh.ID+"/edit", env.cookies, t, "/hook/"+wh.ID+"/edit", env.cookies,
@@ -889,17 +864,12 @@ 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, `value="`+sentinel+`"`, t, body, `name="retention_days" value="0"`,
"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
@@ -910,29 +880,20 @@ 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, sentinel) post := submitEdit(t, env, wh, "0")
require.Equal(t, http.StatusSeeOther, post.Code) require.Equal(t, http.StatusSeeOther, post.Code)
assert.Equal( assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
} }
// TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that // TestSourceListAndDetail_ShowForeverNotZeroDays checks that a
// the retain-forever value is never rendered to the user as a raw day // retention of 0 is shown as forever, never as a day count, on either
// count on either read-only view. // read-only view.
func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention( wh := seedWebhookWithRetention(t, env.db, 0)
t, env.db, database.RetentionForeverDays,
)
sentinel := strconv.Itoa(database.RetentionForeverDays)
listW := httptest.NewRecorder() listW := httptest.NewRecorder()
env.handlers.HandleSourceList().ServeHTTP( env.handlers.HandleSourceList().ServeHTTP(
@@ -941,7 +902,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
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(), sentinel) assert.NotContains(t, listW.Body.String(), "Retention: 0 days")
detailW := httptest.NewRecorder() detailW := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP( env.handlers.HandleSourceDetail().ServeHTTP(
@@ -954,5 +915,5 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
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(), sentinel) assert.NotContains(t, detailW.Body.String(), "Retention: 0 days")
} }
-81
View File
@@ -1,81 +0,0 @@
package handlers
import (
"errors"
"strconv"
"strings"
)
// maxTargetAttempts bounds a target's max_attempts.
//
// Both target forms already declare max="20" on the input, so this
// enforces server-side what the UI has always advertised rather than
// introducing a new limit.
//
// The number is not cosmetic. Every attempt writes a delivery_results
// row that the event log then loads and renders, and the engine backs
// off by 2^(n-1) seconds, so attempt 20 is already about six days
// after the first. A value beyond this buys no additional durability
// and only costs rows.
const maxTargetAttempts = 20
// Errors returned when a max_attempts form value cannot be turned into
// a number of attempts.
var (
// errAttemptsInvalid signals a max_attempts form value that is not
// a whole number of at least 1.
errAttemptsInvalid = errors.New(
"must be a whole number, at least 1",
)
// errAttemptsTooLarge signals a max_attempts form value that is a
// whole number but above maxTargetAttempts. It is distinguished
// from errAttemptsInvalid so the message can name the ceiling
// instead of implying the input was not a number.
errAttemptsTooLarge = errors.New("out of range")
)
// parseMaxAttempts interprets a max_attempts form value.
//
// An ABSENT value — the field empty or not submitted — yields
// fallback, which lets the create path apply its default and the edit
// path leave the stored value alone. A value that is SET BUT INVALID
// is an error: unparseable, below 1, or above maxTargetAttempts.
//
// The distinction is the whole point of this function. Returning a
// default for input the operator typed but that did not parse would
// silently turn retries off on a store-and-forward proxy — and on the
// edit path destroy a working configuration over a typo. A default
// answers a question that was not asked; it never answers one that
// was asked badly.
func parseMaxAttempts(raw string, fallback int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 {
return 0, errAttemptsInvalid
}
if v > maxTargetAttempts {
return 0, errAttemptsTooLarge
}
return v, nil
}
// attemptsErrorMessage returns the message the create and edit forms
// show for a rejected max_attempts value. Any error other than
// errAttemptsTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400.
func attemptsErrorMessage(err error) string {
if errors.Is(err, errAttemptsTooLarge) {
return errAttemptsTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetAttempts) +
" attempts"
}
return errAttemptsInvalid.Error()
}
-429
View File
@@ -1,429 +0,0 @@
package handlers_test
import (
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
)
// attemptsTargetURL is the destination the attempts-validation targets
// point at. It is a literal public address rather than a hostname so
// the SSRF check resolves nothing and a sandbox without DNS cannot
// make these cases pass or fail for the wrong reason.
const attemptsTargetURL = "https://93.184.216.34/hooks/attempts"
const (
// wayAboveCeiling is the typo'd-extra-zero case from the report.
wayAboveCeiling = "999999999"
// notANumber is the plainest garbage an operator can type, and
// the value the report submitted on the edit form.
notANumber = "abc"
// workingAttempts is the number of attempts a seeded target is
// already delivering with, which a rejected submission must not
// disturb.
workingAttempts = 2
)
// aboveCeiling is the smallest rejected whole number.
func aboveCeiling() string {
return strconv.Itoa(handlers.MaxTargetAttemptsForTest + 1)
}
// overCeilingAttempts is whole-number input past the limit, which is
// rejected with the limit named.
func overCeilingAttempts() []string {
return []string{aboveCeiling(), wayAboveCeiling}
}
// belowOneAttempts is whole-number input under the floor of one
// attempt, which is rejected with the floor named.
func belowOneAttempts() []string {
return []string{"0", "-5"}
}
// unparseableAttempts is input an operator can type into the field
// that is not a number of attempts. Each must be REJECTED: silently
// reading any of them as a default turns retries off on a
// store-and-forward proxy without saying so.
//
// The twenty-digit case is here because it parses as digits but
// overflows int, which is the one failure the field's own min/max
// attributes cannot describe.
func unparseableAttempts() []string {
return []string{
notANumber,
"2.7",
"12345678901234567890",
"1e3",
}
}
// createAttemptsForm is a complete, otherwise-valid HTTP target
// creation, so the only thing any case below varies is max_attempts.
func createAttemptsForm(attempts string) url.Values {
form := url.Values{}
form.Set("name", "attempts-target")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", attemptsTargetURL)
if attempts != absentField {
form.Set("max_attempts", attempts)
}
return form
}
// absentField marks a field the form does not submit at all, which is
// the case that legitimately takes a default and must stay distinct
// from a field submitted with garbage in it.
const absentField = "\x00absent"
// absentAttempts is every way of saying "the operator did not set
// this", each of which takes the default rather than a 400. Blank and
// whitespace-only count as absent here because they do in the timeout
// and retention controls on the same forms; a rule the fields do not
// share would be its own surprise.
func absentAttempts() []string {
return []string{absentField, "", " "}
}
// createWithAttempts posts the target create form for a fresh webhook
// and returns the webhook and the response.
func createWithAttempts(
t *testing.T,
env *sourceTestEnv,
attempts string,
) (database.Webhook, int, string) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets",
createAttemptsForm(attempts),
)
return webhook, w.Code, w.Body.String()
}
// TestTargetCreate_AttemptsAboveCeilingRejected proves the create form
// enforces a ceiling at all, and that the 400 names it — a rejection
// that does not say what the limit is leaves the operator guessing.
func TestTargetCreate_AttemptsAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
for _, attempts := range overCeilingAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%s should be rejected", attempts)
assert.Contains(t, body, ceiling,
"the rejection for %s should name the ceiling",
attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %s", attempts)
}
}
// TestTargetCreate_AttemptsBelowOneRejected proves the create form
// refuses 0 and less, naming the floor: every delivery is attempted
// at least once.
func TestTargetCreate_AttemptsBelowOneRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range belowOneAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%s should be rejected", attempts)
assert.Contains(t, body, "at least 1",
"the rejection for %s should name the floor",
attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %s", attempts)
}
}
// TestTargetCreate_UnparseableAttemptsRejected is the core of the
// defect: each of these was accepted with HTTP 200 and stored as a
// default.
func TestTargetCreate_UnparseableAttemptsRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range unparseableAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
assert.Equal(t, http.StatusBadRequest, code,
"max_attempts=%q should be rejected, not coerced",
attempts)
assert.Contains(t, body, "whole number",
"the rejection for %q should say why", attempts)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %q", attempts)
}
}
// TestTargetCreate_ValidAttemptsStored covers the accepting half,
// including both bounds: a bound that rejects its own limit would
// make the advertised range unreachable.
func TestTargetCreate_ValidAttemptsStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{1, 3, handlers.MaxTargetAttemptsForTest} {
webhook, code, body := createWithAttempts(
t, env, strconv.Itoa(want),
)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, want, targets[0].MaxAttempts)
}
}
// TestTargetCreate_AbsentAttemptsTakesDefault keeps the two cases
// distinct. An omitted field is not an operator asking for something
// invalid, so it still gets the default of one attempt rather than a
// 400 — otherwise the fix above would make the form unusable.
func TestTargetCreate_AbsentAttemptsTakesDefault(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range absentAttempts() {
webhook, code, body := createWithAttempts(t, env, attempts)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, 1, targets[0].MaxAttempts,
"an absent max_attempts should take the default")
}
}
// seedAttemptsTarget creates an HTTP target already delivering with
// workingAttempts attempts, through the real create handler.
func seedAttemptsTarget(
t *testing.T,
env *sourceTestEnv,
) (database.Webhook, database.Target) {
t.Helper()
webhook, code, body := createWithAttempts(
t, env, strconv.Itoa(workingAttempts),
)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
require.Equal(t, workingAttempts, targets[0].MaxAttempts)
return webhook, targets[0]
}
// editAttemptsForm is a complete edit submission that changes the
// target's name as well, so a rejected submission can be shown to
// have written nothing at all rather than merely to have left
// max_attempts alone.
func editAttemptsForm(attempts string) url.Values {
form := url.Values{}
form.Set("name", "renamed-by-edit")
form.Set("url", attemptsTargetURL)
if attempts != absentField {
form.Set("max_attempts", attempts)
}
return form
}
// assertEditRejectedAndUnchanged submits an edit expected to fail and
// checks both halves of the requirement: the 400 explains itself, and
// the target it was submitted against is untouched.
func assertEditRejectedAndUnchanged(
t *testing.T,
env *sourceTestEnv,
attempts, wantReason string,
) {
t.Helper()
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID, editAttemptsForm(attempts),
)
assert.Equal(t, http.StatusBadRequest, w.Code,
"max_attempts=%q should be rejected on edit", attempts)
assert.Contains(t, w.Body.String(), wantReason,
"the rejection for %q should say why", attempts)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, workingAttempts, stored.MaxAttempts,
"a rejected edit must not destroy the working number "+
"of attempts with %q", attempts)
assert.Equal(t, "attempts-target", stored.Name,
"a rejected edit must write nothing at all")
}
// TestTargetEdit_UnparseableAttemptsRejected is the damaging half of
// the defect. A target delivering with retries, re-saved with a typo
// in the field, returned 200 and was left with retries disabled and
// nothing said.
func TestTargetEdit_UnparseableAttemptsRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range unparseableAttempts() {
assertEditRejectedAndUnchanged(
t, env, attempts, "whole number",
)
}
}
// TestTargetEdit_AttemptsBelowOneRejected proves the floor applies
// to the edit path too.
func TestTargetEdit_AttemptsBelowOneRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range belowOneAttempts() {
assertEditRejectedAndUnchanged(t, env, attempts, "at least 1")
}
}
// TestTargetEdit_AttemptsAboveCeilingRejected proves the ceiling
// applies to the edit path too, naming itself, so the two paths
// cannot disagree about what is storable.
func TestTargetEdit_AttemptsAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
for _, attempts := range overCeilingAttempts() {
assertEditRejectedAndUnchanged(t, env, attempts, ceiling)
}
}
// TestTargetEdit_ValidAttemptsStored covers the accepting half of the
// edit path, so the bounds cannot be enforced by simply refusing
// every submission.
func TestTargetEdit_ValidAttemptsStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{1, 9, handlers.MaxTargetAttemptsForTest} {
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(strconv.Itoa(want)),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, want,
storedTarget(t, env, target.ID).MaxAttempts)
}
}
// TestTargetEdit_AbsentAttemptsLeavesStoredValue is the edit path's
// absent-versus-invalid case. The field is only offered by the forms
// for types that retry, so a submission without it must leave the
// stored count alone rather than be rejected or reset.
func TestTargetEdit_AbsentAttemptsLeavesStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, attempts := range absentAttempts() {
webhook, target := seedAttemptsTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(attempts),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, workingAttempts,
storedTarget(t, env, target.ID).MaxAttempts,
"an absent max_attempts must leave the stored "+
"count alone (%q)", attempts)
}
}
// TestTargetAttempts_CreateAndEditAgreeOnEveryCase proves the two
// paths cannot disagree, which is what let the create form and the
// edit form drift apart in the first place. Every input is submitted
// to both and the accept/reject verdicts are compared.
func TestTargetAttempts_CreateAndEditAgreeOnEveryCase(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
accepted := []string{
"1", "2",
strconv.Itoa(handlers.MaxTargetAttemptsForTest),
}
overCeiling := overCeilingAttempts()
belowOne := belowOneAttempts()
unparseable := unparseableAttempts()
cases := make(
[]string, 0,
len(accepted)+len(overCeiling)+len(belowOne)+
len(unparseable),
)
cases = append(cases, accepted...)
cases = append(cases, overCeiling...)
cases = append(cases, belowOne...)
cases = append(cases, unparseable...)
for _, attempts := range cases {
_, createCode, _ := createWithAttempts(t, env, attempts)
webhook, target := seedAttemptsTarget(t, env)
editCode := submitTargetEdit(
env, webhook.ID, target.ID,
editAttemptsForm(attempts),
).Code
assert.Equal(t,
createCode == http.StatusBadRequest,
editCode == http.StatusBadRequest,
"create and edit must agree on max_attempts=%q "+
"(create %d, edit %d)",
attempts, createCode, editCode,
)
}
}
+18 -20
View File
@@ -102,10 +102,9 @@ func (h *Handlers) newTarget(
in targetFormInput, in targetFormInput,
) (*database.Target, string, error) { ) (*database.Target, string, error) {
target := &database.Target{ target := &database.Target{
WebhookID: webhookID, WebhookID: webhookID,
Type: in.Type, Type: in.Type,
Active: true, Active: true,
MaxAttempts: 1,
} }
errMsg, err := h.setTargetFromForm(ctx, target, in) errMsg, err := h.setTargetFromForm(ctx, target, in)
@@ -118,10 +117,9 @@ func (h *Handlers) newTarget(
// setTargetFromForm validates a target form against the target's type // setTargetFromForm validates a target form against the target's type
// and, when it accepts it, sets the target's name, configuration and // and, when it accepts it, sets the target's name, configuration and
// number of delivery attempts from it. It returns the message the form // retry count from it. It returns the message the form shows for
// shows for anything it refuses, an unknown type among them, and then // anything it refuses, an unknown type among them, and then leaves the
// leaves the target unchanged; an error is the server's fault, as for // target unchanged; an error is the server's fault, as for newTarget.
// newTarget.
// The add target form and the target edit form both go through here, // The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be. // so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm( func (h *Handlers) setTargetFromForm(
@@ -138,20 +136,20 @@ func (h *Handlers) setTargetFromForm(
return errMsg, err return errMsg, err
} }
// An empty max_attempts keeps the target's count: the default of // An empty max_retries keeps the target's count: the
// 1 for a new target, and the stored count for an edited one, // fire-and-forget default of 0 for a new target, and the stored
// since the forms for target types that do not retry have no such // count for an edited one, since the forms for target types that
// field. A value that is filled in but invalid is refused rather // do not retry have no such field. A value that is filled in but
// than becoming that count, so a typo cannot destroy the count a // invalid is refused rather than becoming that count, so a typo
// target is delivering with. // cannot destroy the count a target is delivering with.
maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts) maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil { if err != nil {
return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
} }
target.Name = in.Name target.Name = in.Name
target.Config = configJSON target.Config = configJSON
target.MaxAttempts = maxAttempts target.MaxRetries = maxRetries
return "", nil return "", nil
} }
@@ -178,8 +176,8 @@ type targetFormInput struct {
// ForwardQuery is an HTTP target's checkbox that passes each // ForwardQuery is an HTTP target's checkbox that passes each
// event's query string on to it. // event's query string on to it.
ForwardQuery bool ForwardQuery bool
// MaxAttempts is an HTTP or Slack target's max_attempts. // MaxRetries is an HTTP or Slack target's max_retries.
MaxAttempts string MaxRetries string
// Expiry is a database (archive) target's row expiry. // Expiry is a database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is a database (archive) target's rotation. // Rotation is a database (archive) target's rotation.
@@ -206,7 +204,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
Headers: r.PostFormValue("headers"), Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"), Timeout: r.PostFormValue("timeout"),
ForwardQuery: r.PostFormValue("forward_query") != "", ForwardQuery: r.PostFormValue("forward_query") != "",
MaxAttempts: r.PostFormValue("max_attempts"), MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"), Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"), Rotation: r.PostFormValue("rotation"),
} }
@@ -196,7 +196,7 @@ func TestHandleTargetCreate_QueryStringCannotSupplyNameOrType(
w, _ := postTargetCreate( w, _ := postTargetCreate(
t, env, webhook.ID, t, env, webhook.ID,
"name=leaky&type=slack&max_attempts=9&expiry=30d&url="+ "name=leaky&type=slack&max_retries=9&expiry=30d&url="+
url.QueryEscape(targetSecretURL), url.QueryEscape(targetSecretURL),
url.Values{}, url.Values{},
) )
+13 -13
View File
@@ -21,24 +21,24 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
// fields is the rest of each submission, as a query string. // fields is the rest of each submission, as a query string.
cases := []struct { cases := []struct {
targetType database.TargetType targetType database.TargetType
fields string fields string
wantConfig string wantConfig string
wantAttempts int wantRetries int
}{ }{
{ {
database.TargetTypeHTTP, "timeout=12&max_attempts=3", database.TargetTypeHTTP, "timeout=12&max_retries=3",
`{"url":"` + editOriginalURL + `","timeout":12}`, 3, `{"url":"` + editOriginalURL + `","timeout":12}`, 3,
}, },
{ {
database.TargetTypeSlack, "max_attempts=4", database.TargetTypeSlack, "max_retries=4",
`{"webhookUrl":"` + editOriginalURL + `"}`, 4, `{"webhookUrl":"` + editOriginalURL + `"}`, 4,
}, },
{ {
database.TargetTypeDatabase, "expiry=720h", database.TargetTypeDatabase, "expiry=720h",
`{"expiry":"720h"}`, 1, `{"expiry":"720h"}`, 0,
}, },
{database.TargetTypeLog, "", "", 1}, {database.TargetTypeLog, "", "", 0},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -62,7 +62,7 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, tc.targetType, targets[0].Type) assert.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts) assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
if tc.wantConfig == "" { if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config) assert.Empty(t, targets[0].Config)
@@ -90,11 +90,11 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeHTTP, database.TargetTypeHTTP,
"name=private&url=" + editBlockedURL + "name=private&url=" + editBlockedURL +
"&timeout=12&max_attempts=3", "&timeout=12&max_retries=3",
"Invalid target URL", "Invalid target URL",
}, },
{ {
database.TargetTypeSlack, "name=no-url&max_attempts=4", database.TargetTypeSlack, "name=no-url&max_retries=4",
"Webhook URL is required for Slack targets", "Webhook URL is required for Slack targets",
}, },
{ {
@@ -133,8 +133,8 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
assert.Contains(t, page, html.EscapeString(tc.reason)) assert.Contains(t, page, html.EscapeString(tc.reason))
// Each value comes back in a data attribute of the targets // Each value comes back in a data attribute of the targets
// section named after its field (max_attempts as // section named after its field (max_retries as
// data-max-attempts), except url, which comes back in // data-max-retries), except url, which comes back in
// data-destination; templates/source_detail.html says why. // data-destination; templates/source_detail.html says why.
for field := range typed { for field := range typed {
attr := "data-" + strings.ReplaceAll(field, "_", "-") attr := "data-" + strings.ReplaceAll(field, "_", "-")
+1 -1
View File
@@ -82,7 +82,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
Headers: cfg.Headers, Headers: cfg.Headers,
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery, ForwardQuery: cfg.ForwardQuery,
MaxAttempts: strconv.Itoa(target.MaxAttempts), MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry, Expiry: cfg.Expiry,
Rotation: cfg.Rotation, Rotation: cfg.Rotation,
} }
+14 -14
View File
@@ -117,7 +117,7 @@ func seedHTTPTarget(
form.Set("url", editOriginalURL) form.Set("url", editOriginalURL)
form.Set("headers", headers) form.Set("headers", headers)
form.Set("timeout", timeout) form.Set("timeout", timeout)
form.Set("max_attempts", "3") form.Set("max_retries", "3")
w := serveTarget( w := serveTarget(
env, http.MethodPost, env, http.MethodPost,
@@ -179,7 +179,7 @@ func editForm(targetURL, headers, timeout string) url.Values {
form.Set("url", targetURL) form.Set("url", targetURL)
form.Set("headers", headers) form.Set("headers", headers)
form.Set("timeout", timeout) form.Set("timeout", timeout)
form.Set("max_attempts", "5") form.Set("max_retries", "5")
return form return form
} }
@@ -280,9 +280,9 @@ func TestHandleTargetEditSubmit_Saves(t *testing.T) {
assertEditClearingRemovesThem(t, env) assertEditClearingRemovesThem(t, env)
}) })
t.Run("absent max_attempts is kept", func(t *testing.T) { t.Run("absent max_retries is not zeroed", func(t *testing.T) {
t.Parallel() t.Parallel()
assertEditKeepsAbsentMaxAttempts(t, env) assertEditKeepsAbsentMaxRetries(t, env)
}) })
} }
@@ -307,7 +307,7 @@ func assertEditChangesDestination(
reloaded := storedTarget(t, env, target.ID) reloaded := storedTarget(t, env, target.ID)
assert.Equal(t, "edited-name", reloaded.Name) assert.Equal(t, "edited-name", reloaded.Name)
assert.Equal(t, 5, reloaded.MaxAttempts) assert.Equal(t, 5, reloaded.MaxRetries)
assert.Equal( assert.Equal(
t, database.TargetTypeHTTP, reloaded.Type, t, database.TargetTypeHTTP, reloaded.Type,
"the edit form must not change a target's type", "the edit form must not change a target's type",
@@ -368,25 +368,25 @@ func assertEditClearingRemovesThem(
assert.Zero(t, cfg.Timeout) assert.Zero(t, cfg.Timeout)
} }
// Delivery attempts are offered only by the forms for target types // Retries are offered only by the forms for target types that retry.
// that retry. An absent field means the form does not edit them, not // An absent field means the form does not edit retries, not that
// that retries should be turned off. // they should be turned off.
func assertEditKeepsAbsentMaxAttempts( func assertEditKeepsAbsentMaxRetries(
t *testing.T, env *sourceTestEnv, t *testing.T, env *sourceTestEnv,
) { ) {
t.Helper() t.Helper()
webhook, target := seedHTTPTarget(t, env, "", "") webhook, target := seedHTTPTarget(t, env, "", "")
require.Equal(t, 3, target.MaxAttempts) require.Equal(t, 3, target.MaxRetries)
form := editForm(editOriginalURL, "", "") form := editForm(editOriginalURL, "", "")
form.Del("max_attempts") form.Del("max_retries")
w := submitTargetEdit(env, webhook.ID, target.ID, form) w := submitTargetEdit(env, webhook.ID, target.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal( assert.Equal(
t, 3, storedTarget(t, env, target.ID).MaxAttempts, t, 3, storedTarget(t, env, target.ID).MaxRetries,
) )
} }
@@ -659,12 +659,12 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeHTTP, database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL + "name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_attempts=3", "&headers=X-Edited:+kept&timeout=12&max_retries=3",
"Invalid target URL", "Invalid target URL",
}, },
{ {
database.TargetTypeSlack, database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_attempts=25", "name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid delivery attempts", "Invalid delivery attempts",
}, },
{ {
+83
View File
@@ -0,0 +1,83 @@
package handlers
import (
"errors"
"strconv"
"strings"
)
// maxTargetRetries bounds a target's max_retries.
//
// Both target forms already declare max="20" on the input, so this
// enforces server-side what the UI has always advertised rather than
// introducing a new limit.
//
// The number is not cosmetic. Every attempt writes a delivery_results
// row that the event log then loads and renders, and the engine backs
// off by 2^(n-1) seconds, so attempt 20 is already about six days
// after the first. A value beyond this buys no additional durability
// and only costs rows.
const maxTargetRetries = 20
// Errors returned when a max_retries form value cannot be turned into
// a retry count.
var (
// errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number.
errRetriesInvalid = errors.New(
"must be a whole number",
)
// errRetriesTooLarge signals a max_retries form value that is a
// whole number but above maxTargetRetries. It is distinguished
// from errRetriesInvalid so the message can name the ceiling
// instead of implying the input was not a number.
errRetriesTooLarge = errors.New("out of range")
)
// parseMaxRetries interprets a max_retries form value.
//
// An ABSENT value — the field empty or not submitted — yields
// fallback, which lets the create path apply its default and the edit
// path leave the stored value alone. A value that is SET BUT INVALID
// is an error: unparseable, negative, or above maxTargetRetries.
//
// The distinction is the whole point of this function. max_retries=0
// means fire-and-forget, so returning 0 for input the operator typed
// but that did not parse silently disables retries on a
// store-and-forward proxy — and on the edit path it destroys a
// working retry configuration over a typo. A default answers a
// question that was not asked; it never answers one that was asked
// badly.
func parseMaxRetries(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, errRetriesInvalid
}
if v > maxTargetRetries {
return 0, errRetriesTooLarge
}
return v, nil
}
// retriesErrorMessage returns the message the create and edit forms
// show for a rejected max_retries value. Any error other than
// errRetriesTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400.
func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) +
" attempts"
}
return errRetriesInvalid.Error() +
", or 0 for fire-and-forget"
}
+385
View File
@@ -0,0 +1,385 @@
package handlers_test
import (
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
)
// retriesTargetURL is the destination the retry-validation targets
// point at. It is a literal public address rather than a hostname so
// the SSRF check resolves nothing and a sandbox without DNS cannot
// make these cases pass or fail for the wrong reason.
const retriesTargetURL = "https://93.184.216.34/hooks/retries"
const (
// wayAboveCeiling is the typo'd-extra-zero case from the report.
wayAboveCeiling = "999999999"
// notANumber is the plainest garbage an operator can type, and
// the value the report submitted on the edit form.
notANumber = "abc"
// workingRetries is the retry count a seeded target is already
// delivering with, which a rejected submission must not disturb.
workingRetries = 2
)
// aboveCeiling is the smallest rejected whole number.
func aboveCeiling() string {
return strconv.Itoa(handlers.MaxTargetRetriesForTest + 1)
}
// overCeilingRetries is whole-number input past the limit, which is
// rejected with the limit named.
func overCeilingRetries() []string {
return []string{aboveCeiling(), wayAboveCeiling}
}
// unparseableRetries is input an operator can type into the field
// that is not a retry count. Each must be REJECTED: silently reading
// any of them as 0 turns a store-and-forward proxy into
// fire-and-forget without saying so.
//
// The twenty-digit case is here because it parses as digits but
// overflows int, which is the one failure the field's own min/max
// attributes cannot describe.
func unparseableRetries() []string {
return []string{
notANumber,
"2.7",
"-5",
"12345678901234567890",
"1e3",
}
}
// createRetriesForm is a complete, otherwise-valid HTTP target
// creation, so the only thing any case below varies is max_retries.
func createRetriesForm(retries string) url.Values {
form := url.Values{}
form.Set("name", "retries-target")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", retriesTargetURL)
if retries != absentField {
form.Set("max_retries", retries)
}
return form
}
// absentField marks a field the form does not submit at all, which is
// the case that legitimately takes a default and must stay distinct
// from a field submitted with garbage in it.
const absentField = "\x00absent"
// absentRetries is every way of saying "the operator did not set
// this", each of which takes the default rather than a 400. Blank and
// whitespace-only count as absent here because they do in the timeout
// and retention controls on the same forms; a rule the fields do not
// share would be its own surprise.
func absentRetries() []string {
return []string{absentField, "", " "}
}
// createWithRetries posts the target create form for a fresh webhook
// and returns the webhook and the response.
func createWithRetries(
t *testing.T,
env *sourceTestEnv,
retries string,
) (database.Webhook, int, string) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets",
createRetriesForm(retries),
)
return webhook, w.Code, w.Body.String()
}
// TestTargetCreate_RetriesAboveCeilingRejected proves the create form
// enforces a ceiling at all, and that the 400 names it — a rejection
// that does not say what the limit is leaves the operator guessing.
func TestTargetCreate_RetriesAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
for _, retries := range overCeilingRetries() {
webhook, code, body := createWithRetries(t, env, retries)
assert.Equal(t, http.StatusBadRequest, code,
"max_retries=%s should be rejected", retries)
assert.Contains(t, body, ceiling,
"the rejection for %s should name the ceiling",
retries)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %s", retries)
}
}
// TestTargetCreate_UnparseableRetriesRejected is the core of the
// defect: each of these was accepted with HTTP 200 and stored as 0.
func TestTargetCreate_UnparseableRetriesRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, retries := range unparseableRetries() {
webhook, code, body := createWithRetries(t, env, retries)
assert.Equal(t, http.StatusBadRequest, code,
"max_retries=%q should be rejected, not coerced",
retries)
assert.Contains(t, body, "whole number",
"the rejection for %q should say why", retries)
assert.Empty(t,
targetsForWebhook(t, env.db, webhook.ID),
"no target should be created for %q", retries)
}
}
// TestTargetCreate_ValidRetriesStored covers the accepting half,
// including the ceiling itself: a bound that rejects its own limit
// would make the advertised maximum unreachable.
func TestTargetCreate_ValidRetriesStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{0, 3, handlers.MaxTargetRetriesForTest} {
webhook, code, body := createWithRetries(
t, env, strconv.Itoa(want),
)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, want, targets[0].MaxRetries)
}
}
// TestTargetCreate_AbsentRetriesTakesDefault keeps the two cases
// distinct. An omitted field is not an operator asking for something
// invalid, so it still gets the fire-and-forget default rather than a
// 400 — otherwise the fix above would make the form unusable.
func TestTargetCreate_AbsentRetriesTakesDefault(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, retries := range absentRetries() {
webhook, code, body := createWithRetries(t, env, retries)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, 0, targets[0].MaxRetries,
"an absent max_retries should take the default")
}
}
// seedRetriesTarget creates an HTTP target already delivering with
// workingRetries retries, through the real create handler.
func seedRetriesTarget(
t *testing.T,
env *sourceTestEnv,
) (database.Webhook, database.Target) {
t.Helper()
webhook, code, body := createWithRetries(
t, env, strconv.Itoa(workingRetries),
)
require.Equal(t, http.StatusSeeOther, code, body)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
require.Equal(t, workingRetries, targets[0].MaxRetries)
return webhook, targets[0]
}
// editRetriesForm is a complete edit submission that changes the
// target's name as well, so a rejected submission can be shown to
// have written nothing at all rather than merely to have left
// max_retries alone.
func editRetriesForm(retries string) url.Values {
form := url.Values{}
form.Set("name", "renamed-by-edit")
form.Set("url", retriesTargetURL)
if retries != absentField {
form.Set("max_retries", retries)
}
return form
}
// assertEditRejectedAndUnchanged submits an edit expected to fail and
// checks both halves of the requirement: the 400 explains itself, and
// the target it was submitted against is untouched.
func assertEditRejectedAndUnchanged(
t *testing.T,
env *sourceTestEnv,
retries, wantReason string,
) {
t.Helper()
webhook, target := seedRetriesTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID, editRetriesForm(retries),
)
assert.Equal(t, http.StatusBadRequest, w.Code,
"max_retries=%q should be rejected on edit", retries)
assert.Contains(t, w.Body.String(), wantReason,
"the rejection for %q should say why", retries)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, workingRetries, stored.MaxRetries,
"a rejected edit must not destroy the working retry "+
"count with %q", retries)
assert.Equal(t, "retries-target", stored.Name,
"a rejected edit must write nothing at all")
}
// TestTargetEdit_UnparseableRetriesRejected is the damaging half of
// the defect. A target delivering with two retries, re-saved with a
// typo in the field, returned 200 and was left with retries disabled
// and nothing said.
func TestTargetEdit_UnparseableRetriesRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, retries := range unparseableRetries() {
assertEditRejectedAndUnchanged(
t, env, retries, "whole number",
)
}
}
// TestTargetEdit_RetriesAboveCeilingRejected proves the ceiling
// applies to the edit path too, naming itself, so the two paths
// cannot disagree about what is storable.
func TestTargetEdit_RetriesAboveCeilingRejected(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
for _, retries := range overCeilingRetries() {
assertEditRejectedAndUnchanged(t, env, retries, ceiling)
}
}
// TestTargetEdit_ValidRetriesStored covers the accepting half of the
// edit path, so the ceiling cannot be enforced by simply refusing
// every submission.
func TestTargetEdit_ValidRetriesStored(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, want := range []int{0, 9, handlers.MaxTargetRetriesForTest} {
webhook, target := seedRetriesTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editRetriesForm(strconv.Itoa(want)),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, want,
storedTarget(t, env, target.ID).MaxRetries)
}
}
// TestTargetEdit_AbsentRetriesLeavesStoredValue is the edit path's
// absent-versus-invalid case. Retries are only offered by the forms
// for types that retry, so a submission without the field must leave
// the stored count alone rather than be rejected or zeroed.
func TestTargetEdit_AbsentRetriesLeavesStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, retries := range absentRetries() {
webhook, target := seedRetriesTarget(t, env)
w := submitTargetEdit(
env, webhook.ID, target.ID,
editRetriesForm(retries),
)
require.Equal(t,
http.StatusSeeOther, w.Code, w.Body.String(),
)
assert.Equal(t, workingRetries,
storedTarget(t, env, target.ID).MaxRetries,
"an absent max_retries must leave the stored "+
"count alone (%q)", retries)
}
}
// TestTargetRetries_CreateAndEditAgreeOnEveryCase proves the two
// paths cannot disagree, which is what let the create form and the
// edit form drift apart in the first place. Every input is submitted
// to both and the accept/reject verdicts are compared.
func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
accepted := []string{
"0", "1",
strconv.Itoa(handlers.MaxTargetRetriesForTest),
}
overCeiling := overCeilingRetries()
unparseable := unparseableRetries()
cases := make(
[]string, 0,
len(accepted)+len(overCeiling)+len(unparseable),
)
cases = append(cases, accepted...)
cases = append(cases, overCeiling...)
cases = append(cases, unparseable...)
for _, retries := range cases {
_, createCode, _ := createWithRetries(t, env, retries)
webhook, target := seedRetriesTarget(t, env)
editCode := submitTargetEdit(
env, webhook.ID, target.ID,
editRetriesForm(retries),
).Code
assert.Equal(t,
createCode == http.StatusBadRequest,
editCode == http.StatusBadRequest,
"create and edit must agree on max_retries=%q "+
"(create %d, edit %d)",
retries, createCode, editCode,
)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
stored := storedTarget(t, env, tgt.ID) stored := storedTarget(t, env, tgt.ID)
assert.False(t, stored.Active) assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name) assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxAttempts) assert.Equal(t, 5, stored.MaxRetries)
assert.Equal( assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL, t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
) )
+18 -20
View File
@@ -184,8 +184,7 @@ 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 is rewritten to the retain-forever // DefaultRetentionDays, and 0 means retain forever.
// sentinel by Webhook.BeforeSave.
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) { func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
@@ -253,10 +252,7 @@ func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) {
"blank means unchanged, not forever", "blank means unchanged, not forever",
) )
forever := &database.Webhook{ forever := &database.Webhook{Name: "wh", RetentionDays: 0}
Name: "wh",
RetentionDays: database.RetentionForeverDays,
}
forever.ID = "wh-2" forever.ID = "wh-2"
foreverBody := renderPage( foreverBody := renderPage(
@@ -348,19 +344,21 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
) )
} }
// maxAttemptsHelp is the wording both target forms must carry. The // maxRetriesHelp is the wording both target forms must carry. The
// delivery core makes max_attempts attempts in total, not that many // delivery core makes max_retries attempts in total, not that many
// retries on top of a first try (a fresh delivery starts at attempt 1 // retries on top of a first try (a fresh delivery starts at attempt 1
// and target_http gives up once the attempt number reaches // and target_http gives up once the attempt number reaches
// max_attempts). // max_retries), and 0 is special-cased to a single fire-and-forget
const maxAttemptsHelp = "How many times each delivery is attempted in " + // attempt with no circuit breaker.
"all, the first attempt included." const maxRetriesHelp = "How many times each delivery is attempted in " +
"all, the first attempt included. 0 means a single attempt with no " +
"retries and no circuit breaker."
// TestTargetFormMaxAttemptsCopyMatchesBehaviour pins the max_attempts // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
// label, "Delivery attempts", and help text on both the create form // label, "Delivery attempts", and help text on both the create form
// (the add-target form on the webhook detail page) and the edit form, // (the add-target form on the webhook detail page) and the edit form,
// so the copy cannot drift back to calling the number a retry count. // so the copy cannot drift back to calling the number a retry count.
func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) { func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
var h *handlers.Handlers var h *handlers.Handlers
@@ -392,11 +390,11 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
assert.Contains(t, createBody, "Delivery attempts:</label>") assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains( assert.Contains(
t, createBody, maxAttemptsHelp, t, createBody, maxRetriesHelp,
"the add-target form must explain max_attempts as total attempts", "the add-target form must explain max_retries as total attempts",
) )
// A slack target exercises the same max_attempts field while needing // A slack target exercises the same max_retries field while needing
// only a URL from the edit template, so the test data stays // only a URL from the edit template, so the test data stays
// minimal. The Target and TargetForm keys mirror the field names // minimal. The Target and TargetForm keys mirror the field names
// the template reads off the handler's values. // the template reads off the handler's values.
@@ -410,8 +408,8 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
"Active": true, "Active": true,
}, },
"TargetForm": map[string]any{ "TargetForm": map[string]any{
"URL": "https://hooks.slack.com/services/x", "URL": "https://hooks.slack.com/services/x",
"MaxAttempts": "3", "MaxRetries": "3",
}, },
dataKeyError: "", dataKeyError: "",
}, },
@@ -419,8 +417,8 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`) assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
assert.Contains( assert.Contains(
t, editBody, maxAttemptsHelp, t, editBody, maxRetriesHelp,
"the target edit form must explain max_attempts as total attempts", "the target edit form must explain max_retries as total attempts",
) )
} }
+1 -1
View File
@@ -442,7 +442,7 @@ func buildDeliveryTasks(
TargetName: targets[i].Name, TargetName: targets[i].Name,
TargetType: targets[i].Type, TargetType: targets[i].Type,
TargetConfig: targets[i].Config, TargetConfig: targets[i].Config,
MaxAttempts: targets[i].MaxAttempts, MaxRetries: targets[i].MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery, RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
+1 -1
View File
@@ -500,7 +500,7 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
want string want string
}{ }{
{30, "30 days"}, {30, "30 days"},
{database.RetentionForeverDays, "forever"}, {0, "forever"},
} }
for _, tt := range tests { for _, tt := range tests {
+7 -7
View File
@@ -354,11 +354,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
}{ }{
{ {
"http", "http", "http", "http",
"csrf_token name type url headers timeout max_attempts", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"slack", "slack", "csrf_token name type url max_attempts", "slack", "slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
@@ -630,11 +630,11 @@ func checkRefusedEdits(
values map[string]string values map[string]string
}{ }{
{page + "/targets/" + targetID + "/edit", map[string]string{ {page + "/targets/" + targetID + "/edit", map[string]string{
"#name": "edited-target", "#name": "edited-target",
"#url": "http://127.0.0.1/hook", "#url": "http://127.0.0.1/hook",
"#headers": "X-Edited: kept", "#headers": "X-Edited: kept",
"#timeout": "12", "#timeout": "12",
"#max_attempts": "3", "#max_retries": "3",
}}, }},
{page + "/edit", map[string]string{ {page + "/edit", map[string]string{
"#name": "edited-webhook", "#name": "edited-webhook",
+3 -3
View File
@@ -139,7 +139,7 @@ document.addEventListener("alpine:init", function () {
headers: "", headers: "",
timeout: "", timeout: "",
forwardQuery: false, forwardQuery: false,
maxAttempts: "", maxRetries: "",
expiry: "", expiry: "",
rotation: "", rotation: "",
init() { init() {
@@ -153,7 +153,7 @@ document.addEventListener("alpine:init", function () {
this.timeout = refused.timeout; this.timeout = refused.timeout;
this.forwardQuery = this.forwardQuery =
this.$root.hasAttribute("data-forward-query"); this.$root.hasAttribute("data-forward-query");
this.maxAttempts = refused.maxAttempts; this.maxRetries = refused.maxRetries;
this.expiry = refused.expiry; this.expiry = refused.expiry;
this.rotation = refused.rotation; this.rotation = refused.rotation;
}, },
@@ -173,7 +173,7 @@ document.addEventListener("alpine:init", function () {
this.headers = ""; this.headers = "";
this.timeout = ""; this.timeout = "";
this.forwardQuery = false; this.forwardQuery = false;
this.maxAttempts = ""; this.maxRetries = "";
this.expiry = ""; this.expiry = "";
this.rotation = ""; this.rotation = "";
this.$refs.form.reset(); this.$refs.form.reset();
+5 -5
View File
@@ -136,7 +136,7 @@
data-headers="{{.TargetForm.Headers}}" data-headers="{{.TargetForm.Headers}}"
data-timeout="{{.TargetForm.Timeout}}" data-timeout="{{.TargetForm.Timeout}}"
{{if .TargetForm.ForwardQuery}}data-forward-query{{end}} {{if .TargetForm.ForwardQuery}}data-forward-query{{end}}
data-max-attempts="{{.TargetForm.MaxAttempts}}" data-max-retries="{{.TargetForm.MaxRetries}}"
data-expiry="{{.TargetForm.Expiry}}" data-expiry="{{.TargetForm.Expiry}}"
data-rotation="{{.TargetForm.Rotation}}"> data-rotation="{{.TargetForm.Rotation}}">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
@@ -194,9 +194,9 @@
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -210,9 +210,9 @@
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_attempts" :value="maxAttempts" placeholder="1" min="1" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
+3 -3
View File
@@ -89,9 +89,9 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_attempts" class="label">Delivery attempts</label> <label for="max_retries" class="label">Delivery attempts</label>
<input type="number" id="max_attempts" name="max_attempts" value="{{.TargetForm.MaxAttempts}}" min="1" max="20" class="input"> <input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
{{end}} {{end}}