Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c273d2d19 | ||
|
|
c0f7cc29fd | ||
|
|
f14fe02011 | ||
|
|
573f7f986a | ||
|
|
373a2f828d | ||
|
|
267d5b12f2 |
+15
-9
@@ -214,16 +214,22 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
||||
# (Alpine.js, extracted from its tarball in 3p/) first.
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
# Rebuild with static linking for Alpine runtime.
|
||||
# make build already verified compilation.
|
||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||
# the binary runs on Alpine without glibc.
|
||||
# Rebuild with static linking for the Alpine runtime. The cgo binary the
|
||||
# step above builds is dynamically linked against glibc, which Alpine
|
||||
# (musl) does not have.
|
||||
#
|
||||
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
||||
# their own: the build target composes them with the -X that stamps the
|
||||
# version, so this relink cannot silently drop the stamp.
|
||||
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
||||
# A statically linked glibc still loads shared libraries at run time for
|
||||
# getaddrinfo and dlopen, and Alpine has none to load, so the build tags
|
||||
# keep both out of the binary: netgo and osusergo select Go's own DNS
|
||||
# resolver and user lookup instead of glibc's, and
|
||||
# sqlite_omit_load_extension leaves out SQLite extension loading, which
|
||||
# webhooker never uses.
|
||||
#
|
||||
# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather
|
||||
# than a -ldflags of their own: the build target composes them with the
|
||||
# -X that stamps the version, so this relink cannot silently drop the
|
||||
# stamp.
|
||||
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"'
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-03-17
|
||||
|
||||
@@ -16,14 +16,16 @@ VERSION ?= $(shell script/version)
|
||||
# exists to correct.
|
||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||
|
||||
# Extra linker flags for the build target. The static relink in the
|
||||
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||
# so composing flags cannot drop the version stamp.
|
||||
# Extra linker flags and build tags for the build target. The static
|
||||
# relink in the Dockerfile passes -extldflags and its build tags through
|
||||
# these rather than a -ldflags of its own, so composing flags cannot drop
|
||||
# the version stamp.
|
||||
#
|
||||
# The build target itself always passes -trimpath and -s -w, as the Go
|
||||
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
|
||||
# debug information in the binary.
|
||||
GO_LDFLAGS ?=
|
||||
GO_TAGS ?=
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -53,7 +55,7 @@ check:
|
||||
@script/check
|
||||
|
||||
build: assets
|
||||
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
@@ -1317,20 +1317,22 @@ webhooker solves this by acting as a durable intermediary:
|
||||
nothing is lost if downstream targets are unavailable.
|
||||
|
||||
2. **Guaranteed delivery** — Events are queued for delivery to each configured
|
||||
target. Failed deliveries are retried with configurable backoff. Every
|
||||
delivery attempt is logged with status codes, response bodies, and timing.
|
||||
target. Failed deliveries are retried with configurable backoff. Delivery
|
||||
attempts are logged with status codes, response bodies, and timing.
|
||||
|
||||
**That guarantee is at-least-once, not exactly-once.** When a send reaches
|
||||
its target but the write recording that outcome fails, the delivery is
|
||||
deliberately left in a recoverable state rather than marked done — losing a
|
||||
delivery is the worse failure — so the pending sweep picks it up about
|
||||
fifteen minutes later, or the next restart does, and the target receives a
|
||||
payload it already got. webhooker adds no delivery identifier of its own to
|
||||
an outbound request, so **make your receiver idempotent** against whatever
|
||||
the payload itself carries.
|
||||
payload it already got. A send still in flight when the service stops is
|
||||
left the same way: its attempt is not recorded, and the next start sends it
|
||||
again (see Shutdown). webhooker adds no delivery identifier of its own to an
|
||||
outbound request, so **make your receiver idempotent** against whatever the
|
||||
payload itself carries.
|
||||
|
||||
3. **Observability** — Full request/response logging for every webhook received
|
||||
and every delivery attempted. Prometheus metrics expose volume, latency, and
|
||||
and for delivery attempts. Prometheus metrics expose volume, latency, and
|
||||
error rates. The web UI provides real-time visibility into event flow.
|
||||
|
||||
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
|
||||
@@ -1494,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
|
||||
together or not at all.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | ----------------------------------------------------------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Foreign key → User |
|
||||
| `name` | string | Human-readable name |
|
||||
| `description` | string | Optional description |
|
||||
| `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) |
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | --------------------------------------------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Foreign key → User |
|
||||
| `name` | string | Human-readable name |
|
||||
| `description` | string | Optional description |
|
||||
| `retention_days` | integer | Days to retain events; 0 means retain forever |
|
||||
|
||||
**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
|
||||
dedicated database before automatic cleanup.
|
||||
|
||||
Setting `retention_days` to `0` means "retain events forever". Because the
|
||||
column carries a default of 30, a literal zero cannot survive an insert, so a
|
||||
zero is rewritten on save to a sentinel of `365 * 1000` days
|
||||
(`database.RetentionForeverDays`). The retention reaper recognises that sentinel
|
||||
and skips the webhook entirely, and the web UI displays such a webhook's
|
||||
retention as "forever" rather than as a day count.
|
||||
The value is stored as entered. Setting `retention_days` to `0` means "retain
|
||||
events forever": the retention reaper skips the webhook entirely, and the web UI
|
||||
displays its retention as "forever" rather than as a day count.
|
||||
|
||||
Submitted `retention_days` values therefore fall into three bands, not two:
|
||||
|
||||
- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is
|
||||
accepted as a finite retention.
|
||||
- Above that ceiling but below the retain-forever sentinel of 365000
|
||||
(`database.RetentionForeverDays`) is rejected with a 400. This is the band the
|
||||
cap exists for.
|
||||
- `0`, and `365000` or above, are accepted and mean retain forever, collapsing
|
||||
to the sentinel — `0` in `Webhook.BeforeSave`, the large values in
|
||||
`parseRetentionDays`. The large values are not out of range: the edit form
|
||||
pre-fills the sentinel for a retain-forever webhook, so submitting that form
|
||||
back unchanged has to keep meaning "forever".
|
||||
|
||||
A negative value is in none of the three: `parseRetentionDays` rejects it with a
|
||||
400 before `BeforeSave` ever sees it.
|
||||
The 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
|
||||
else, a negative value or one above that cap included, is rejected with a 400. A
|
||||
blank field gives the default of 30 days (`database.DefaultRetentionDays`) on
|
||||
the new webhook form, and leaves the stored value unchanged on the edit form.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1768,7 +1758,7 @@ is what bounds it.
|
||||
#### DeliveryResult
|
||||
|
||||
The result of a single delivery attempt. Every attempt (including retries) is
|
||||
individually logged for full observability.
|
||||
individually logged, except one cut off by a stop (see Shutdown).
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------- | ------- | ----------------------------------------- |
|
||||
@@ -2206,12 +2196,16 @@ This means:
|
||||
buffer rather than spawning more goroutines.
|
||||
- **Independent results** — each worker records its own delivery result in the
|
||||
per-webhook database without coordination.
|
||||
- **Graceful shutdown** — cancel the context, workers finish their current task
|
||||
and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`,
|
||||
which bounds that wait by fx's stop timeout rather than blocking forever on a
|
||||
wedged worker. On timeout it logs at `ERROR` and returns an error, and the
|
||||
goroutines that did not finish are still running — an unclean shutdown is
|
||||
reported rather than hidden.
|
||||
- **Graceful shutdown** — the stop hook cancels the workers' context. A worker
|
||||
may still take tasks already queued before it exits. An `http` or `slack`
|
||||
request in flight is cut off, and one started after the cancel fails at once.
|
||||
Neither is recorded or counted in the metrics, and the next start sends the
|
||||
delivery again (see Shutdown). A `database` or `log` task runs as normal. The
|
||||
stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds
|
||||
that wait by fx's stop timeout rather than blocking forever on a wedged
|
||||
worker. On timeout it logs at `ERROR` and returns an error, and the goroutines
|
||||
that did not finish are still running — an unclean shutdown is reported rather
|
||||
than hidden.
|
||||
|
||||
**Recovery paths:**
|
||||
|
||||
@@ -2317,7 +2311,7 @@ getting anywhere.
|
||||
| Metric | Type | Meaning |
|
||||
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
|
||||
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` |
|
||||
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) |
|
||||
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
|
||||
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
|
||||
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
|
||||
@@ -3284,7 +3278,8 @@ stop-hook log:
|
||||
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
|
||||
seconds**) and by what the hooks before it left, then a Sentry flush if
|
||||
`SENTRY_DSN` is set
|
||||
4. `delivery.Engine` — waits for its workers, then closes the archive databases
|
||||
4. `delivery.Engine` — cuts off any `http` or `slack` request in flight, waits
|
||||
for its workers, then closes the archive databases
|
||||
5. `healthcheck`
|
||||
6. `WebhookDBManager`
|
||||
7. the database close
|
||||
@@ -3295,6 +3290,12 @@ by the stop context, so a wedge there consumes the 5 seconds before the HTTP
|
||||
server hook is ever entered. The hooks after the server are microsecond-scale in
|
||||
normal operation.
|
||||
|
||||
The delivery engine cancels a delivery request in flight. That attempt is not
|
||||
recorded or counted in the metrics, and the delivery keeps its `pending` or
|
||||
`retrying` status, so startup recovery sends it again on the next start (see
|
||||
Recovery paths under Bounded Worker Pool). If the receiver already had it, it
|
||||
receives it twice: the at-least-once duplicate described under Rationale.
|
||||
|
||||
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
|
||||
the two equal, a drain that used its whole budget would exhaust the sequence
|
||||
budget at the instant it finished, and every later hook — the delivery engine,
|
||||
@@ -3419,12 +3420,14 @@ fixed independently of the compiler's:
|
||||
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
||||
file from each), runs `make build` (which extracts Alpine.js from `3p/`
|
||||
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
|
||||
so it runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
|
||||
the version. The version is the `VERSION` build arg if one is given,
|
||||
otherwise derived from the `.git` in the context, and the stage fails if a
|
||||
context with `.git` would stamp an empty version, `dev` or `unknown` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
so it runs on musl, with the build tags `netgo`, `osusergo` and
|
||||
`sqlite_omit_load_extension` so it needs none of glibc's shared libraries at
|
||||
run time. Both builds go through `make build`, the relink adding its
|
||||
`-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can
|
||||
drop the `-X` that stamps the version. The version is the `VERSION` build arg
|
||||
if one is given, otherwise derived from the `.git` in the context, and the
|
||||
stage fails if a context with `.git` would stamp an empty version, `dev` or
|
||||
`unknown` (see [Version stamping](#version-stamping)).
|
||||
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
||||
all SQLite databases, exposes port 8080, and includes a health check against
|
||||
|
||||
@@ -45,8 +45,7 @@ type Event struct {
|
||||
|
||||
// ResubmittedFromID names the event this one was copied from by
|
||||
// an operator resubmit. It is nil for an event that arrived on
|
||||
// the receiver, which is every event created before the column
|
||||
// existed. It is not a foreign key: the source event can be
|
||||
// the receiver. It is not a foreign key: the source event can be
|
||||
// reaped by retention while its copies remain, and the id is
|
||||
// kept as the record of where the copy came from either way.
|
||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||
|
||||
@@ -4,27 +4,13 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetentionDays is the event retention period applied to a
|
||||
// webhook created without an explicit retention value. It is the
|
||||
// single source of truth for that policy and must stay in sync
|
||||
// with the `gorm:"default:30"` column default on
|
||||
// Webhook.RetentionDays below; a struct tag cannot reference a
|
||||
// constant, so a test asserts the two agree.
|
||||
// DefaultRetentionDays is the event retention period the new
|
||||
// webhook form applies when its retention field is left blank.
|
||||
DefaultRetentionDays = 30
|
||||
|
||||
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
||||
// "retain events forever". Users express that intent as 0, which
|
||||
// Webhook.BeforeSave rewrites to this value: the column default
|
||||
// substitutes DefaultRetentionDays for a zero value at insert
|
||||
// time, so a zero can never survive a round trip to the database.
|
||||
// Nothing outside this file may hardcode the number.
|
||||
RetentionForeverDays = 365 * 1000
|
||||
|
||||
// MaxFiniteRetentionDays is the largest finite retention period the
|
||||
// reaper's cutoff arithmetic can represent, and therefore the
|
||||
// largest one a caller may request. It is derived from that
|
||||
@@ -38,9 +24,7 @@ const (
|
||||
// turns the cutoff into a timestamp in the far future that matches
|
||||
// every row in the webhook's database. That is why this bound is
|
||||
// enforced on input and why retentionCutoff saturates underneath
|
||||
// it. Note that RetentionForeverDays deliberately sits above this
|
||||
// ceiling: such webhooks are skipped before any cutoff is
|
||||
// computed, and never reach the arithmetic at all.
|
||||
// it.
|
||||
MaxFiniteRetentionDays = int(
|
||||
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
||||
)
|
||||
@@ -48,12 +32,10 @@ const (
|
||||
|
||||
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
||||
//
|
||||
// Every method below takes a pointer receiver. BeforeSave has to,
|
||||
// because it mutates the record and GORM only invokes hooks declared
|
||||
// that way; the display helpers follow suit so the receiver kinds do
|
||||
// not mix. Handlers therefore put a *Webhook into template data:
|
||||
// html/template cannot call a pointer method on a value held in a map,
|
||||
// because a map element is not addressable.
|
||||
// Every method below takes a pointer receiver, so handlers put a
|
||||
// *Webhook into template data: html/template cannot call a pointer
|
||||
// method on a value held in a map, because a map element is not
|
||||
// addressable.
|
||||
type Webhook struct {
|
||||
BaseModel
|
||||
|
||||
@@ -61,10 +43,9 @@ type Webhook struct {
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// RetentionDays is the number of days to retain events. A value of
|
||||
// RetentionForeverDays means retain forever. The column default
|
||||
// must equal DefaultRetentionDays.
|
||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||
// RetentionDays is the number of days to retain events, and 0
|
||||
// means retain them forever.
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// User.Webhooks leads back here, and the JSON could loop.
|
||||
@@ -73,36 +54,17 @@ type Webhook struct {
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeSave normalises RetentionDays on every insert and update. A
|
||||
// non-positive value is the user's way of asking for "retain forever",
|
||||
// which is stored as the RetentionForeverDays sentinel.
|
||||
//
|
||||
// This has to happen in a hook rather than at the call sites. GORM
|
||||
// substitutes the column default (DefaultRetentionDays) for a zero
|
||||
// value while building the insert statement, which runs after
|
||||
// BeforeSave; rewriting any later than this loses that race and the
|
||||
// row lands at 30 days. Living on the model also means a future call
|
||||
// site — a REST API, a fixture, a migration — cannot bypass it.
|
||||
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||
if w.RetentionDays <= 0 {
|
||||
w.RetentionDays = RetentionForeverDays
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retainsForever reports whether a stored RetentionDays value means
|
||||
// "keep events indefinitely". It is the single definition of that
|
||||
// question, shared by Webhook.RetainsForever and by the reaper's
|
||||
// retainsForever reports whether a RetentionDays value means "keep
|
||||
// events indefinitely", which 0 does. It is the single definition of
|
||||
// that question, shared by Webhook.RetainsForever and by the reaper's
|
||||
// cutoff computation so the two cannot disagree about which webhooks
|
||||
// are exempt from reaping.
|
||||
//
|
||||
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||
// and, defensively, the non-positive values that rows written before
|
||||
// the sentinel existed may still carry.
|
||||
// A negative value, which the forms refuse, is read the same way: as a
|
||||
// day count it would put the cutoff in the future, where it matches
|
||||
// every event the webhook has.
|
||||
func retainsForever(retentionDays int) bool {
|
||||
return retentionDays <= 0 ||
|
||||
retentionDays >= RetentionForeverDays
|
||||
return retentionDays <= 0
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
if w.RetainsForever() {
|
||||
return "forever"
|
||||
|
||||
@@ -2,8 +2,6 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,8 +45,7 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int {
|
||||
}
|
||||
|
||||
// newWebhookWithRetention creates a webhook through the ordinary Create
|
||||
// path, so the BeforeSave hook and the GORM column default both apply
|
||||
// exactly as they do in production.
|
||||
// path, as the new webhook form does.
|
||||
func newWebhookWithRetention(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
@@ -67,7 +64,7 @@ func newWebhookWithRetention(
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -75,31 +72,10 @@ func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||
wh := &database.Webhook{RetentionDays: 0}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
"a zero retention must be stored as the sentinel, "+
|
||||
"not replaced by the column default",
|
||||
)
|
||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: -5}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||
func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -110,10 +86,9 @@ func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||
assert.Equal(t, 7, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
|
||||
// fires on update as well as insert, via the same Save call the edit
|
||||
// handler makes.
|
||||
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day
|
||||
// count to 0 through the same Save call the edit handler makes.
|
||||
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -125,28 +100,7 @@ func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||
wh.RetentionDays = 0
|
||||
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place
|
||||
// the default lives twice: a struct tag cannot reference a constant, so
|
||||
// this asserts the tag and DefaultRetentionDays agree.
|
||||
func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
field, ok := reflect.TypeFor[database.Webhook]().
|
||||
FieldByName("RetentionDays")
|
||||
require.True(t, ok, "Webhook.RetentionDays must exist")
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"default:"+strconv.Itoa(database.DefaultRetentionDays),
|
||||
field.Tag.Get("gorm"),
|
||||
)
|
||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
|
||||
@@ -176,14 +130,6 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
|
||||
time.Duration(overCeiling*hoursPerDay)*time.Hour,
|
||||
"one day past the ceiling must overflow",
|
||||
)
|
||||
|
||||
assert.Less(
|
||||
t,
|
||||
database.MaxFiniteRetentionDays,
|
||||
database.RetentionForeverDays,
|
||||
"the sentinel sits above the ceiling and is only safe "+
|
||||
"because retain-forever webhooks skip the arithmetic",
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||
@@ -195,16 +141,8 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||
forever bool
|
||||
label string
|
||||
}{
|
||||
{
|
||||
"sentinel",
|
||||
database.RetentionForeverDays, true, testForeverLabel,
|
||||
},
|
||||
{
|
||||
"above sentinel",
|
||||
database.RetentionForeverDays + 1, true, testForeverLabel,
|
||||
},
|
||||
{"legacy zero", 0, true, testForeverLabel},
|
||||
{"legacy negative", -1, true, testForeverLabel},
|
||||
{"zero", 0, true, testForeverLabel},
|
||||
{"negative", -1, true, testForeverLabel},
|
||||
{"default", database.DefaultRetentionDays, false, "30 days"},
|
||||
{"one day", 1, false, "1 day"},
|
||||
}
|
||||
|
||||
@@ -243,7 +243,8 @@ func (r *RetentionReaper) reapWebhook(
|
||||
|
||||
// retentionCutoff returns the timestamp before which a webhook's
|
||||
// events have expired, and whether any cutoff applies at all. It
|
||||
// reports false for a retain-forever policy, so no DELETE is issued.
|
||||
// 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
|
||||
// defense in depth rather than decoration: a time.Duration is an int64
|
||||
@@ -253,7 +254,7 @@ func (r *RetentionReaper) reapWebhook(
|
||||
// row in the database: the sweep then deletes every event, delivery,
|
||||
// and delivery result, including ones created seconds ago. Rejecting
|
||||
// out-of-range input at the form is the primary guard; saturating here
|
||||
// means an old row, a migration, or a future call site cannot turn a
|
||||
// means a value that reaches the row by any other path cannot turn a
|
||||
// too-large retention into total data loss.
|
||||
func retentionCutoff(
|
||||
now time.Time,
|
||||
|
||||
@@ -85,41 +85,6 @@ func createWebhook(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -281,19 +246,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
||||
assertChainPresent(t, db, recent)
|
||||
}
|
||||
|
||||
// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
|
||||
// end-to-end retain-forever path: a webhook created the normal way with
|
||||
// a requested retention of 0 lands on the RetentionForeverDays
|
||||
// sentinel, and the reaper leaves its ancient events alone while still
|
||||
// reaping a finite-retention webhook in the very same sweep.
|
||||
func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the
|
||||
// end-to-end retain-forever path: a webhook created with a retention
|
||||
// of 0 reads back 0, and the reaper leaves its ancient events alone
|
||||
// while still reaping a finite-retention webhook in the very same
|
||||
// sweep.
|
||||
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
|
||||
foreverID := createWebhook(t, env.mainDB.DB(), 0)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
@@ -302,14 +267,12 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
env.mainDB.DB().Where("id = ?", foreverID).
|
||||
First(&stored).Error,
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
stored.RetentionDays,
|
||||
"a requested retention of 0 must persist as the sentinel",
|
||||
require.Zero(
|
||||
t, stored.RetentionDays,
|
||||
"a retention of 0 must be stored as 0",
|
||||
)
|
||||
|
||||
finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
|
||||
finiteID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
foreverDB, err := env.mgr.GetDB(foreverID)
|
||||
require.NoError(t, err)
|
||||
@@ -335,14 +298,10 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
// negative, so subtracting it moves the cutoff into the far future,
|
||||
// where "created_at < cutoff" matches every row: an event created a
|
||||
// moment ago, and its delivery and delivery result, were all deleted
|
||||
// on the first sweep. 200000 is inside that band and below the
|
||||
// retain-forever sentinel, so it is treated as a finite policy and
|
||||
// really does reach the arithmetic.
|
||||
// on the first sweep. 200000 is inside that band.
|
||||
//
|
||||
// The row is planted at the column level because such a value can no
|
||||
// longer be submitted through the form; the point of the test is that
|
||||
// a row from an older version, or a future call site, still cannot
|
||||
// trigger the wipe.
|
||||
// The form refuses such a value; the point of the test is that a row
|
||||
// written by any other path still cannot trigger the wipe.
|
||||
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -358,12 +317,6 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
database.MaxFiniteRetentionDays,
|
||||
"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(
|
||||
t, env.mainDB.DB(), overflowingRetentionDays,
|
||||
@@ -379,14 +332,15 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
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()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
// A legacy row written before the sentinel existed still carries a
|
||||
// literal 0; the <= 0 guard must keep honouring it.
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 0)
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), -1)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -118,9 +118,8 @@ const SQLiteFilePerm fs.FileMode = 0o600
|
||||
// SQLite still reports the absence rather than this function
|
||||
// materializing an empty database the caller asked not to create.
|
||||
//
|
||||
// Chmod of a file that already exists is what tightens a data
|
||||
// directory an earlier build left at 0644 — including a developer's
|
||||
// own scratch directory — without any migration machinery.
|
||||
// The mode is set on every open, whoever created the file, so a
|
||||
// database or sidecar found at a wider mode is tightened too.
|
||||
func reserveSQLiteFile(path string, create bool) error {
|
||||
if create {
|
||||
// gosec G304: the path is the database file the caller asked
|
||||
|
||||
@@ -46,7 +46,7 @@ CREATE TABLE webhooks (
|
||||
user_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
retention_days integer DEFAULT 30,
|
||||
retention_days integer,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
@@ -201,8 +201,7 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||
|
||||
// sweepTarget prunes the archive of a single database target.
|
||||
// A missing, empty, or "never" expiry parses as a zero duration
|
||||
// and is skipped entirely, so those archives keep exactly the
|
||||
// behaviour they had before the sweep existed.
|
||||
// and is skipped entirely, so those archives are never pruned.
|
||||
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||
expiry, err := parseArchiveExpiry(target.Config)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,8 +32,9 @@ func targetTotals(
|
||||
|
||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||
// write records for the webhook page's statistics: the time a delivery
|
||||
// finished, set only when it becomes delivered or failed, and one more
|
||||
// on its target's delivered or failed total.
|
||||
// finished, set only when it becomes delivered or failed and stored in
|
||||
// the same form as created_at, and one more on its target's delivered
|
||||
// or failed total.
|
||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -75,6 +76,16 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||
if tt.finished {
|
||||
require.NotNil(t, stored.FinishedAt)
|
||||
assert.False(t, stored.FinishedAt.Before(before))
|
||||
|
||||
var createdAt, finishedAt string
|
||||
|
||||
require.NoError(t, db.Raw(
|
||||
`SELECT CAST(created_at AS TEXT),
|
||||
CAST(finished_at AS TEXT)
|
||||
FROM deliveries WHERE id = ?`, d.ID,
|
||||
).Row().Scan(&createdAt, &finishedAt))
|
||||
assertStoredTime(t, createdAt)
|
||||
assertStoredTime(t, finishedAt)
|
||||
} else {
|
||||
assert.Nil(t, stored.FinishedAt)
|
||||
}
|
||||
|
||||
@@ -1701,8 +1701,11 @@ func writeDeliveryStatus(
|
||||
database.DeliveryStatusFailed,
|
||||
}).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"finished_at": time.Now(),
|
||||
"status": status,
|
||||
// GORM's clock, the one that stamps created_at. A bare
|
||||
// time.Now() carries a monotonic clock reading, which the
|
||||
// driver would store as an " m=+…" suffix.
|
||||
"finished_at": tx.NowFunc(),
|
||||
})
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
@@ -1906,12 +1909,13 @@ func (e *Engine) takeForRedispatch(
|
||||
return false
|
||||
}
|
||||
|
||||
// GORM's clock, as writeDeliveryStatus explains.
|
||||
res := webhookDB.
|
||||
Model(&database.Delivery{}).
|
||||
Where(
|
||||
"id = ? AND status = ?", deliveryID, eligible,
|
||||
).
|
||||
UpdateColumn("updated_at", time.Now())
|
||||
UpdateColumn("updated_at", webhookDB.NowFunc())
|
||||
|
||||
if res.Error != nil {
|
||||
e.log.Error(
|
||||
|
||||
@@ -2,11 +2,16 @@ package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -272,6 +277,154 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||
}
|
||||
|
||||
// seedHeldDelivery gives the setup's webhook an http target with
|
||||
// maxRetries, pointed at a receiver that keeps every request open until
|
||||
// the client gives up on it, and seeds one delivery to that target at
|
||||
// status. It returns the task that sends attempt attemptNum of the
|
||||
// delivery, and a channel that is closed when a request reaches the
|
||||
// receiver.
|
||||
func seedHeldDelivery(
|
||||
t *testing.T,
|
||||
s iSetup,
|
||||
maxRetries int,
|
||||
status database.DeliveryStatus,
|
||||
attemptNum int,
|
||||
) (delivery.Task, <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
arrived := make(chan struct{})
|
||||
|
||||
var once sync.Once
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
// The server notices the client hanging up only once the
|
||||
// body has been read; until then r.Context() never ends.
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
once.Do(func() { close(arrived) })
|
||||
|
||||
<-r.Context().Done()
|
||||
},
|
||||
))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "held",
|
||||
database.TargetTypeHTTP, cfg, maxRetries,
|
||||
)
|
||||
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
|
||||
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
|
||||
|
||||
bodyStr := event.Body
|
||||
|
||||
return iTask(
|
||||
d, event, s.WebhookID, targetID, "held", cfg,
|
||||
maxRetries, attemptNum, &bodyStr,
|
||||
), arrived
|
||||
}
|
||||
|
||||
// stopDuringAttempt waits until the receiver holds an attempt open, then
|
||||
// stops the engine through its real stop hook.
|
||||
func stopDuringAttempt(
|
||||
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the delivery attempt never reached the receiver")
|
||||
}
|
||||
|
||||
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
|
||||
}
|
||||
|
||||
// requireRequeued runs restart recovery on the stopped engine and
|
||||
// requires it to queue task's attempt again on ch.
|
||||
func requireRequeued(
|
||||
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID,
|
||||
)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
assert.Equal(t, task.DeliveryID, got.DeliveryID)
|
||||
assert.Equal(t, task.AttemptNum, got.AttemptNum)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("restart recovery did not queue the delivery again")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
|
||||
// test for a stop that turned an attempt in flight into a terminal
|
||||
// failure. Stopping the engine cancels the request; for a target with
|
||||
// no retries that was the delivery's only attempt, recorded as failed
|
||||
// with "context canceled", and nothing ever sent it again. The
|
||||
// delivery must instead stay pending, with no result and no count in
|
||||
// the attempt metrics, for restart recovery to send.
|
||||
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
reg := mIsolate(t, s)
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
task, arrived := seedHeldDelivery(
|
||||
t, s, 0, database.DeliveryStatusPending, 1,
|
||||
)
|
||||
|
||||
s.Engine.Notify([]delivery.Task{task})
|
||||
stopDuringAttempt(t, lc, arrived)
|
||||
|
||||
iAssertStatus(
|
||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
|
||||
)
|
||||
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
|
||||
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
|
||||
assert.Zero(t, mHTTPDurations(t, reg))
|
||||
|
||||
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
|
||||
}
|
||||
|
||||
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
|
||||
// for a target with retries, on its last attempt: the cancelled attempt
|
||||
// used to exhaust the retries and end the delivery failed. It must stay
|
||||
// retrying with only its earlier attempt recorded, and restart recovery
|
||||
// must send that last attempt again.
|
||||
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxRetries = 2
|
||||
|
||||
s := newISetup(t)
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
task, arrived := seedHeldDelivery(
|
||||
t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries,
|
||||
)
|
||||
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
|
||||
|
||||
s.Engine.ExportScheduleRetry(task, 0)
|
||||
stopDuringAttempt(t, lc, arrived)
|
||||
|
||||
iAssertStatus(
|
||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
|
||||
)
|
||||
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
|
||||
|
||||
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
|
||||
}
|
||||
|
||||
// deliverToArchive gives the setup's webhook a database target,
|
||||
// runs one delivery to it through the running engine, and returns
|
||||
// the target's ID and archive file path. The archive writer holds
|
||||
|
||||
@@ -117,6 +117,18 @@ func seedDelivery(
|
||||
return d
|
||||
}
|
||||
|
||||
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
|
||||
// is in the form GORM stores created_at in: the layout of
|
||||
// time.Time.String, with no " m=+…" monotonic clock reading after it.
|
||||
func assertStoredTime(t *testing.T, text string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := time.Parse(
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", text,
|
||||
)
|
||||
assert.NoError(t, err, "stored as %q", text)
|
||||
}
|
||||
|
||||
// httpDeliveryFixture holds the task and delivery needed
|
||||
// for HTTP delivery tests.
|
||||
type httpDeliveryFixture struct {
|
||||
|
||||
@@ -462,7 +462,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
||||
|
||||
// TestRedispatchStampsTheRow pins the cadence control: a stranded
|
||||
// delivery that has just been handed out is not selected again by the
|
||||
// next tick a minute later.
|
||||
// next tick a minute later. The stamp is stored in the same form as
|
||||
// created_at.
|
||||
func TestRedispatchStampsTheRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -493,4 +494,13 @@ func TestRedispatchStampsTheRow(t *testing.T) {
|
||||
"a re-dispatched delivery must be stamped so the "+
|
||||
"next tick does not select it again",
|
||||
)
|
||||
|
||||
var createdAt, updatedAt string
|
||||
|
||||
require.NoError(t, s.WebhookDB.Raw(
|
||||
`SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT)
|
||||
FROM deliveries WHERE id = ?`, d.ID,
|
||||
).Row().Scan(&createdAt, &updatedAt))
|
||||
assertStoredTime(t, createdAt)
|
||||
assertStoredTime(t, updatedAt)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ type Scheduler interface {
|
||||
// Engine.observeAttempt, alongside the DeliveryResult it records for
|
||||
// it. Deliver is also entered for attempts that never happen — an
|
||||
// open circuit breaker refuses one — so the count cannot be taken
|
||||
// from around this call.
|
||||
// from around this call. An attempt cut off by a stop is neither
|
||||
// counted nor recorded.
|
||||
type Target interface {
|
||||
Deliver(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -277,7 +277,11 @@ func (w *archiveWriter) write(
|
||||
}
|
||||
}
|
||||
|
||||
row.ArchivedAt = time.Now()
|
||||
// GORM's clock, the one that stamps created_at in the other
|
||||
// databases, so this is stored in the same form. A bare time.Now()
|
||||
// carries a monotonic clock reading, which the driver would store
|
||||
// as an " m=+…" suffix.
|
||||
row.ArchivedAt = w.db.NowFunc()
|
||||
|
||||
err := w.db.Create(&row).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -150,6 +150,13 @@ func TestArchiveWriter_WritesRow(t *testing.T) {
|
||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||
|
||||
var archivedAt string
|
||||
|
||||
require.NoError(t, w.DB().Raw(
|
||||
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
|
||||
).Row().Scan(&archivedAt))
|
||||
assertStoredTime(t, archivedAt)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
|
||||
@@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool {
|
||||
// one pair silently overwriting the other.
|
||||
//
|
||||
// An input with no pairs yields an empty map, which omitempty drops
|
||||
// from the stored config: a target configured with no headers keeps
|
||||
// the same config JSON it had before this field existed.
|
||||
// from the stored config, so a target configured with no headers
|
||||
// stores no headers key at all.
|
||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||
headers := make(map[string]string)
|
||||
|
||||
|
||||
@@ -57,7 +57,11 @@ type httpCore struct {
|
||||
// recorded and no circuit breaker is consulted. A positive
|
||||
// maxRetries gates the attempt on the circuit breaker and
|
||||
// schedules a backed-off retry on failure.
|
||||
//
|
||||
// ctx is the context attempt sends its request with. Stopping the
|
||||
// engine cancels it; see interruptedByStop.
|
||||
func (c *httpCore) deliver(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
@@ -66,21 +70,55 @@ func (c *httpCore) deliver(
|
||||
attempt func() attemptResult,
|
||||
) {
|
||||
if maxRetries == 0 {
|
||||
c.fireAndForget(webhookDB, d, attempt())
|
||||
c.fireAndForget(ctx, webhookDB, d, attempt())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.withRetry(
|
||||
webhookDB, d, task, sched, maxRetries, attempt,
|
||||
ctx, webhookDB, d, task, sched, maxRetries, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
// interruptedByStop reports whether an attempt failed because the
|
||||
// engine is stopping, which cancels the context its request is made
|
||||
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
|
||||
// breaker outcome, no status change, no count in the attempt metrics.
|
||||
// The delivery stays pending or retrying, and startup recovery sends
|
||||
// it again on the next start.
|
||||
//
|
||||
// A target's own timeout leaves that context alone, so its attempt is
|
||||
// still recorded as the failure it is.
|
||||
func (c *httpCore) interruptedByStop(
|
||||
ctx context.Context,
|
||||
d *database.Delivery,
|
||||
res attemptResult,
|
||||
) bool {
|
||||
if res.success || ctx.Err() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
c.eng.log.Warn(
|
||||
"delivery attempt interrupted by shutdown; not recorded, "+
|
||||
"it is sent again on the next start",
|
||||
"delivery_id", d.ID,
|
||||
"event_id", d.EventID,
|
||||
"target_id", d.TargetID,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *httpCore) fireAndForget(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
res attemptResult,
|
||||
) {
|
||||
if c.interruptedByStop(ctx, d, res) {
|
||||
return
|
||||
}
|
||||
|
||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||
|
||||
err := c.eng.recordResult(
|
||||
@@ -110,6 +148,7 @@ func (c *httpCore) fireAndForget(
|
||||
}
|
||||
|
||||
func (c *httpCore) withRetry(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
@@ -131,6 +170,10 @@ func (c *httpCore) withRetry(
|
||||
|
||||
res := attempt()
|
||||
|
||||
if c.interruptedByStop(ctx, d, res) {
|
||||
return
|
||||
}
|
||||
|
||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||
|
||||
err := c.eng.recordResult(
|
||||
@@ -395,7 +438,7 @@ func (t *httpTarget) Deliver(
|
||||
}
|
||||
|
||||
t.deliver(
|
||||
webhookDB, d, task, sched,
|
||||
ctx, webhookDB, d, task, sched,
|
||||
d.Target.MaxRetries, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
const RedactionMarker = "(redacted)"
|
||||
|
||||
// Redactor removes one target's own credential material from
|
||||
// text that target's remote peer chose: a delivery response
|
||||
// body, or a delivery error stored before the delivery path
|
||||
// learned to mask the URLs it embeds.
|
||||
// a delivery response body, whose text the target's remote
|
||||
// peer chose, and from a stored delivery error as a second
|
||||
// line behind the delivery path's own URL masking.
|
||||
//
|
||||
// It removes byte-identical echoes of strings taken from the
|
||||
// target's stored configuration, and nothing else. Anything
|
||||
|
||||
@@ -82,7 +82,7 @@ func (t *slackTarget) Deliver(
|
||||
}
|
||||
|
||||
t.deliver(
|
||||
webhookDB, d, task, sched,
|
||||
ctx, webhookDB, d, task, sched,
|
||||
d.Target.MaxRetries, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ import (
|
||||
//
|
||||
// The bound is enforced here and in SQL, because this page's
|
||||
// memory profile must not depend on a constant in another
|
||||
// package staying where it is, and because rows predating the
|
||||
// delivery engine's own cap or restored from an archive are
|
||||
// not covered by it at all.
|
||||
// package staying where it is, or on every stored row having
|
||||
// been written by the delivery engine under its own cap.
|
||||
//
|
||||
// It happens to equal that engine cap, so a row written by the
|
||||
// current engine reaches this one exactly and is never cut
|
||||
// twice. Nothing here may assume the two differ: see view.
|
||||
// engine reaches this one exactly and is never cut twice.
|
||||
// Nothing here may assume the two differ: see view.
|
||||
const maxRenderedResponseBytes = 4096
|
||||
|
||||
// deliveryResultColumns is the delivery attempt projection.
|
||||
@@ -40,9 +39,8 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
||||
//
|
||||
// Both Error and ResponseBody have been through the target's
|
||||
// Redactor. The engine already masks the URL out of the
|
||||
// errors it stores, so for errors this is a second line
|
||||
// covering rows written before it did; for response bodies it
|
||||
// is the only line, and its reach is what
|
||||
// errors it stores, so for errors this is a second line; for
|
||||
// response bodies it is the only line, and its reach is what
|
||||
// delivery.Redactor documents.
|
||||
type DeliveryResultView struct {
|
||||
AttemptNum int
|
||||
|
||||
@@ -16,21 +16,16 @@ import (
|
||||
//
|
||||
// An empty value yields fallback, which lets the create path apply the
|
||||
// default and the edit path leave the stored value unchanged. A value
|
||||
// of 0 is returned as 0 and is rewritten to the retain-forever
|
||||
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
||||
// or negative is refused rather than silently given a default.
|
||||
// of 0 means retain events forever and is returned as it is. Anything
|
||||
// unparseable or negative is refused rather than silently given a
|
||||
// default.
|
||||
//
|
||||
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
||||
// time.Duration, an int64 nanosecond count, so a day count above
|
||||
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
||||
// future, and deletes every event the webhook has. A finite value
|
||||
// above that ceiling is therefore refused, and the message names the
|
||||
// ceiling rather than implying the input was not a number.
|
||||
//
|
||||
// A value at or above the retain-forever sentinel is not out of range:
|
||||
// it is what the edit form pre-fills for a retain-forever webhook, so
|
||||
// submitting the form back unchanged has to keep meaning "forever"
|
||||
// rather than being rejected.
|
||||
// future, and deletes every event the webhook has. A value above that
|
||||
// ceiling is therefore refused, and the message names the ceiling
|
||||
// rather than implying the input was not a number.
|
||||
func parseRetentionDays(raw string, fallback int) (int, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -43,10 +38,6 @@ func parseRetentionDays(raw string, fallback int) (int, string) {
|
||||
"retain events forever."
|
||||
}
|
||||
|
||||
if v >= database.RetentionForeverDays {
|
||||
return database.RetentionForeverDays, ""
|
||||
}
|
||||
|
||||
if v > database.MaxFiniteRetentionDays {
|
||||
return 0, "Retention must be at most " +
|
||||
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
||||
|
||||
@@ -137,8 +137,7 @@ func onlyWebhook(
|
||||
}
|
||||
|
||||
// seedWebhookWithRetention inserts a webhook owned by the test user
|
||||
// with an exact stored retention value, bypassing Webhook.BeforeSave
|
||||
// via a column-level update so that legacy rows can be planted too.
|
||||
// with the given retention.
|
||||
func seedWebhookWithRetention(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
@@ -155,13 +154,6 @@ func seedWebhookWithRetention(
|
||||
t,
|
||||
db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Model(wh).
|
||||
Update("retention_days", retentionDays).Error,
|
||||
)
|
||||
|
||||
wh.RetentionDays = retentionDays
|
||||
|
||||
return *wh
|
||||
}
|
||||
@@ -225,10 +217,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core
|
||||
// regression test for the bug: the create form's 0 must reach the
|
||||
// database as the retain-forever sentinel rather than being replaced by
|
||||
// the column's default of 30.
|
||||
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever checks
|
||||
// that the create form's 0 reaches the database as 0, which means
|
||||
// retain forever.
|
||||
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -241,30 +232,55 @@ func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
||||
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.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
blank := ""
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
for name, retention := range map[string]*string{
|
||||
"blank": &blank,
|
||||
"omitted": nil,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.DefaultRetentionDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
env := setupSourceTest(t)
|
||||
|
||||
w := submitCreate(
|
||||
t, env.handlers, env.cookies, "defaulted", retention,
|
||||
)
|
||||
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
|
||||
@@ -290,7 +306,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
||||
)
|
||||
assert.NotContains(
|
||||
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"`)
|
||||
}
|
||||
@@ -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
|
||||
// validation failure hands the user's typing back, matching what the
|
||||
// edit form already does. Losing a long description to a mistyped
|
||||
@@ -480,11 +462,7 @@ func TestHandleSourceEditSubmit_ZeroRetentionPersistsForever(
|
||||
w := submitEdit(t, env, wh, "0")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
||||
}
|
||||
|
||||
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
||||
@@ -867,18 +845,15 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
||||
assert.Equal(t, oldName, stored.Name)
|
||||
}
|
||||
|
||||
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
||||
// the removed max="365" cap used to break: render the edit form for a
|
||||
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
||||
// by browser validation, then submit that pre-filled value straight
|
||||
// back and confirm the retention policy survives untouched.
|
||||
// TestSourceEditForm_ForeverWebhookRoundTrips renders the edit form for
|
||||
// a retain-forever webhook, confirms it pre-fills 0, then submits that
|
||||
// pre-filled value straight back and confirms the retention policy
|
||||
// survives untouched.
|
||||
func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(
|
||||
t, env.db, database.RetentionForeverDays,
|
||||
)
|
||||
wh := seedWebhookWithRetention(t, env.db, 0)
|
||||
|
||||
req := getRequest(
|
||||
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||
@@ -889,17 +864,12 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(
|
||||
t, body, `value="`+sentinel+`"`,
|
||||
t, body, `name="retention_days" value="0"`,
|
||||
"the edit form pre-fills the stored retention",
|
||||
)
|
||||
assert.NotContains(
|
||||
t, body, `max="365"`,
|
||||
"a max below the sentinel would block saving any edit",
|
||||
)
|
||||
// "Currently forever." is the rendered RetentionLabel, not the
|
||||
// static hint below the input, which says "Enter 0 to retain events
|
||||
// forever." A bare Contains of "forever" would pass for any
|
||||
@@ -910,29 +880,20 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
||||
}
|
||||
|
||||
// TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that
|
||||
// the retain-forever value is never rendered to the user as a raw day
|
||||
// count on either read-only view.
|
||||
func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
t *testing.T,
|
||||
) {
|
||||
// TestSourceListAndDetail_ShowForeverNotZeroDays checks that a
|
||||
// retention of 0 is shown as forever, never as a day count, on either
|
||||
// read-only view.
|
||||
func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(
|
||||
t, env.db, database.RetentionForeverDays,
|
||||
)
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
wh := seedWebhookWithRetention(t, env.db, 0)
|
||||
|
||||
listW := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceList().ServeHTTP(
|
||||
@@ -941,7 +902,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
|
||||
require.Equal(t, http.StatusOK, listW.Code)
|
||||
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()
|
||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||
@@ -954,5 +915,5 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
|
||||
require.Equal(t, http.StatusOK, detailW.Code)
|
||||
assert.Contains(t, detailW.Body.String(), "Retention: forever")
|
||||
assert.NotContains(t, detailW.Body.String(), sentinel)
|
||||
assert.NotContains(t, detailW.Body.String(), "Retention: 0 days")
|
||||
}
|
||||
|
||||
@@ -49,13 +49,6 @@ var (
|
||||
// working retry configuration over a typo. A default answers a
|
||||
// question that was not asked; it never answers one that was asked
|
||||
// badly.
|
||||
//
|
||||
// A target stored with a count above the ceiling before this
|
||||
// validation existed keeps rendering and keeps delivering — nothing
|
||||
// clamps the row. Re-saving it from the edit form does have to bring
|
||||
// it into range, because the form submits the pre-filled value back
|
||||
// and accepting it would be the ceiling not applying to the edit
|
||||
// path. The 400 names the ceiling, so the fix is one field.
|
||||
func parseMaxRetries(raw string, fallback int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -184,8 +184,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||
// retention copy to what the code does: the reaper permanently deletes
|
||||
// events past the cutoff, an empty field falls back to
|
||||
// DefaultRetentionDays, and 0 is rewritten to the retain-forever
|
||||
// sentinel by Webhook.BeforeSave.
|
||||
// DefaultRetentionDays, and 0 means retain forever.
|
||||
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -253,10 +252,7 @@ func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
||||
"blank means unchanged, not forever",
|
||||
)
|
||||
|
||||
forever := &database.Webhook{
|
||||
Name: "wh",
|
||||
RetentionDays: database.RetentionForeverDays,
|
||||
}
|
||||
forever := &database.Webhook{Name: "wh", RetentionDays: 0}
|
||||
forever.ID = "wh-2"
|
||||
|
||||
foreverBody := renderPage(
|
||||
|
||||
@@ -500,7 +500,7 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{30, "30 days"},
|
||||
{database.RetentionForeverDays, "forever"},
|
||||
{0, "forever"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -441,10 +441,9 @@ func (s *Session) Regenerate(
|
||||
//
|
||||
// Whichever comes first ends the session.
|
||||
//
|
||||
// A session that claims to be authenticated but carries no
|
||||
// timestamps predates this check; it is treated as expired so the
|
||||
// user re-authenticates rather than being granted an unbounded
|
||||
// session.
|
||||
// A session that claims to be authenticated but lacks the timestamp
|
||||
// a deadline is measured from is treated as expired, so the user
|
||||
// re-authenticates rather than being granted an unbounded session.
|
||||
func (s *Session) expired(sess *sessions.Session) bool {
|
||||
now := s.now()
|
||||
|
||||
|
||||
@@ -156,7 +156,8 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) {
|
||||
// The build target is the only place composing linker flags. If a
|
||||
// future edit drops either half, the binary silently reports "dev"
|
||||
// again (the defect this package exists for) or fails to link on
|
||||
// Alpine.
|
||||
// Alpine. If it stops passing GO_TAGS, the image's static binary links
|
||||
// glibc's getaddrinfo and dlopen again.
|
||||
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -164,6 +165,7 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
||||
|
||||
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
||||
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
||||
require.Contains(t, makefile, "-tags '$(GO_TAGS)'")
|
||||
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
||||
}
|
||||
|
||||
@@ -233,7 +235,9 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
||||
"a raw go build bypasses the Makefile's -X flag")
|
||||
require.Contains(t, dockerfile, "ARG VERSION")
|
||||
require.Contains(t, dockerfile,
|
||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||
`make build VERSION="$VERSION"`+
|
||||
` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+
|
||||
` GO_LDFLAGS='-extldflags "-static"'`)
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) string {
|
||||
|
||||
Reference in New Issue
Block a user