1 Commits
Author SHA1 Message Date
sneak f703b72ce0 Next (#364)
check / check (push) Successful in 3m59s
Reviewed-on: #364
2026-09-29 13:05:57 +02:00
134 changed files with 2178 additions and 7875 deletions
+8 -12
View File
@@ -1,20 +1,16 @@
# .git is sent so the build can derive the version it stamps into the binary
# (script/version). Its config, which can hold a remote URL carrying a
# credential and which `git describe` does not need, is left out of a
# directory context. A context sent as a tar is not filtered by this file, so
# it carries .git/config unless its sender leaves it out.
.git/config
# No tracked file may be listed here: git in the build would see it as
# deleted and mark the version -dirty.
#
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile.
.git/
bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context.
# Third-party browser assets are fetched and hash-verified inside the build by
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
# from supplying the bytes that get shipped. The script and its
# static/vendor.sha256 manifest stay in the context.
static/js/alpine.min.js
*.md
LICENSE
.editorconfig
.env
.env.*
*.db
+13 -7
View File
@@ -12,8 +12,9 @@ jobs:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with:
# The superseded-status step needs history to walk ancestors (it
# aborts on a shallow clone).
# The fingerprint step below needs history to find the last commit
# that touched the Docker build context, and the superseded-status
# step needs it to walk ancestors (it aborts on a shallow clone).
fetch-depth: 0
- name: Mark superseded run statuses
@@ -27,11 +28,16 @@ jobs:
run: script/ci-mark-superseded
- name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which
# invalidates the `COPY . .` layer of both check stages: a commit
# that was never linted, format-checked, tested and built cannot
# report success from cache.
run: git rev-parse HEAD > .ci-fingerprint
# `.dockerignore` keeps docs out of the build context, so a docs-only
# commit legitimately replays the whole image from cache and stays
# cheap. Every other commit writes a new fingerprint into the context,
# which invalidates the `COPY . .` layer of both check stages: a
# commit that was never linted, formatted-checked, tested and built
# cannot report success from cache.
run: |
set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
- name: Build Docker image (runs make check)
run: script/cibuild
+3 -2
View File
@@ -46,6 +46,7 @@ temp/
# CI cache barrier, written into the build context by the check workflow
.ci-fingerprint
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed.
# Third-party browser assets, fetched and hash-verified by
# script/fetch-assets against static/vendor.sha256. Not committed:
# REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js
Binary file not shown.
+20 -26
View File
@@ -12,8 +12,8 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, which
# holds the hash of the commit being checked (see
# Copy source code. In CI the context also carries .ci-fingerprint, whose
# value changes with every commit that touches the build context (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore.
@@ -38,13 +38,8 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
COPY --from=lint /src/go.sum /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
# A build context sent as a tar archive keeps its files' owners, and git
# refuses to read a checkout owned by another user. Trust this one
# whoever owns it.
RUN git config --system --add safe.directory /build
# suite executes.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
WORKDIR /build
@@ -56,26 +51,25 @@ RUN go mod download
# the lint stage above.
COPY . .
# Run tests and build. Both first run script/assets, which extracts Alpine.js
# from its tarball in 3p/.
# Fetch the third-party browser assets the UI serves. They are not committed
# (REPO_POLICIES.md forbids minified bundles in version control) and
# .dockerignore keeps any host copy out of the build context, so this step is
# the only way they enter the image. Each download is checked against a
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
# hashes against the bytes go:embed actually put in the binary.
RUN script/fetch-assets
# Run tests and build
RUN make test
# Version stamped into the binary: the VERSION build arg when one is
# given, otherwise what script/version derives from the .git the build
# context carries, so any `docker build .` of a clone stamps its commit.
# With neither, as from a source tarball, it is "unknown".
# Version stamped into the binary. .dockerignore excludes .git/, so
# nothing in this stage can derive it: script/docker resolves it on the
# host and passes it in. The default is what a bare `docker build .`
# with no --build-arg gets, and it names no tag the tree may not be at.
#
# Declared here, below the test step, so a changed version does not
# invalidate its cached layer.
ARG VERSION
# A context that carries .git must not stamp "unknown": that means git is
# missing here or could not read the checkout, and the image could not be
# traced back to its commit.
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
echo "version is unknown although the build context carries .git" >&2; \
exit 1; \
fi
# Declared here, below the test and asset steps, so a changed version
# does not invalidate their cached layers.
ARG VERSION=unknown
RUN make build VERSION="$VERSION"
+7 -7
View File
@@ -4,12 +4,12 @@
.DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version;
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
# how the Dockerfile passes its build arg in.
# override it (`make build VERSION=v1.2.3`) where git metadata is
# unavailable, which is how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it
@@ -28,7 +28,7 @@ setup:
@script/setup
assets:
@script/assets
@script/fetch-assets
test:
@script/test
@@ -45,13 +45,13 @@ fmt-check:
check:
@script/check
build: assets
build:
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build
./bin/webhooker
dev: assets
dev:
go run ./cmd/webhooker
deps:
+274 -397
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -40,6 +40,12 @@ duplicate. That is deliberate — the alternative is a silent lost
delivery — and the README says so under Rationale. It is not a defect
to re-file.
One caveat on reading a green check: a docs-only commit deliberately
replays from the layer cache
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
such a commit evidences a replay rather than an executed run. A code
commit invalidates the `COPY` layer and genuinely executes.
# Next Step
Clear the rest of the open 1.0.0 milestone
@@ -381,7 +387,7 @@ point of the branch.
- 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed the build-architecture global (#31)
(#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16)
+5 -9
View File
@@ -16,7 +16,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
@@ -178,10 +177,6 @@ func newApp() *fx.App {
healthcheck.New,
session.New,
handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New,
// The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot
@@ -192,10 +187,11 @@ func newApp() *fx.App {
// Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting
// or renaming a webhook or target reaches its archive
// files.
func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.WebhookEvictor so
// deleting a webhook releases its archive writer.
func(e *delivery.Engine) delivery.WebhookEvictor {
return e
},
server.New,
),
fx.Invoke(
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1
@@ -30,6 +29,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+66 -29
View File
@@ -75,11 +75,6 @@ const (
// internet-exposed endpoint.
defaultReceiverRateLimit = 120
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
// RFC 1918 private ranges, which a reverse proxy reaching the
// process over a Docker network or a private LAN connects from.
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// maxPort is the highest valid TCP port number. The lower
// bound (at least 1) is enforced by envPositiveInt.
maxPort = 65535
@@ -177,14 +172,13 @@ type Config struct {
// TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
// value replaces them. If any client can reach the process, or
// the proxy in front of it, from an RFC 1918 source address
// (directly, or through anything that can rewrite source
// addresses, such as NAT or a published container port), it
// must be set to the proxy's address alone, or every rate limit
// can be bypassed by those clients.
// only forwarded header read. It is empty unless
// TRUSTED_PROXIES is set, and empty means no peer is
// trusted: forwarded headers are then ignored entirely and
// clients are identified by the connection's own address.
// Members can choose their own rate-limit key, so this must
// name proxy hosts only, never a block that also covers
// clients.
TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target
@@ -196,13 +190,12 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local, the unspecified addresses
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
// link-local that disclose credentials or user data at a
// provider-fixed, non-public address; it is not exhaustive of
// every cloud's metadata address. See
// alwaysBlockedNetworks for the authoritative list and why
// each entry is on it.
// here. That set is link-local plus the cloud metadata
// endpoints outside it that disclose credentials or user data
// at a provider-fixed, non-public address; it is not
// exhaustive of every cloud's metadata address. See
// alwaysBlockedNetworks for the authoritative list and the
// criterion it is built from.
AllowedEgressCIDRs []netip.Prefix
params *ConfigParams
@@ -467,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
// envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value is read as defaultValue
// instead. A set value containing an unparseable entry is a hard
// error naming the key and the bad entry, so startup fails loudly
// rather than silently running with a list the operator did not
// intend.
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
// allowed). An unset, empty, or blank value yields an empty list. A
// set value containing an unparseable entry is a hard error naming
// the key and the bad entry, so startup fails loudly rather than
// silently running with a list the operator did not intend.
func envPrefixList(key string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
v = defaultValue
return nil, nil
}
var prefixes []netip.Prefix
@@ -689,12 +681,12 @@ func loadFromEnv() (*Config, error) {
return nil, err
}
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
if err != nil {
return nil, err
}
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
if err != nil {
return nil, err
}
@@ -768,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
)
}
// warnSharedRateLimitBucket logs a startup warning whenever
// TRUSTED_PROXIES is empty, in any environment.
//
// With no trusted proxies every rate limiter keys on the connecting
// peer's address. Whether that is harmless or dangerous depends on
// what is in front of the process, which this code cannot observe:
// with nothing in front, the peer is the client and the limits are
// per-client as intended; behind a reverse proxy the peer is the proxy
// for every request, so all clients share one bucket per limiter.
//
// The login endpoint no longer spends budget on arrival — it verifies
// credentials first and charges only failures — so a shared bucket
// cannot deny the operator a correct password. What it does collapse
// is the failure counting: one client's wrong passwords throttle
// everyone else's wrong passwords, and the receiver's limits become
// service-wide ceilings.
//
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
// behind a proxy every client shares one bucket in dev and prod alike.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if len(c.TrustedProxies) > 0 {
return
}
log.Warn(
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
"connecting peer's address. With nothing proxying to "+
"this process that is the client itself and the limits "+
"are per-client as intended. Behind a reverse proxy the "+
"peer is the proxy on every request, so all clients "+
"share one bucket per limit: the receiver limits become "+
"service-wide ceilings, and one client's failed logins "+
"throttle every other client's failed logins — a "+
"correct password still gets in. If anything proxies to "+
"this process, set TRUSTED_PROXIES to its address.",
"environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
)
}
// New creates a Config by reading environment variables.
//
//nolint:revive // lc parameter is required by fx even if unused.
@@ -813,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"hasMetricsAuth", s.MetricsAuthEnabled(),
)
s.warnSharedRateLimitBucket(log)
s.warnEgressAllowlist(log)
return s, nil
+101 -26
View File
@@ -124,11 +124,6 @@ func testEnvironmentConfigSuccess(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -277,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -370,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -411,7 +404,6 @@ func TestDefaultDataDir(t *testing.T) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -542,7 +534,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -560,11 +551,6 @@ func testReceiverRateLimitSuccess(
}
func TestTrustedProxies(t *testing.T) {
// Unset, the RFC 1918 private ranges are trusted, so a reverse
// proxy on a Docker network or a private LAN is covered without
// configuration.
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
tests := []struct {
name string
set bool
@@ -573,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
expected []string
}{
{
// The default must be "trust nobody": an empty list
// means forwarded headers are ignored, never that
// every peer may speak for the client.
name: caseUnsetUsesDefault,
set: false,
expected: defaultProxies,
expected: []string{},
},
{
name: "blank value uses default",
name: "blank value trusts nothing",
set: true,
value: " ",
expected: defaultProxies,
},
{
name: "set value replaces the default entirely",
set: true,
value: "203.0.113.7",
expected: []string{"203.0.113.7/32"},
expected: []string{},
},
{
name: caseValidValueParsed,
@@ -659,7 +642,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -773,7 +755,6 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -864,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
}
}
// TestSharedRateLimitBucketWarning covers the startup warning that
// tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver
// limits into service-wide ceilings and collapses login failure
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
// environment, because behind a proxy every client shares one bucket
// in dev and prod alike. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct {
name string
environment string
trustedProxies string
expectWarning bool
}{
{
name: "prod without trusted proxies warns",
environment: config.EnvironmentProd,
expectWarning: true,
},
{
name: "prod with trusted proxies is quiet",
environment: config.EnvironmentProd,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
{
name: "dev without trusted proxies warns",
environment: config.EnvironmentDev,
expectWarning: true,
},
{
name: "dev with trusted proxies is quiet",
environment: config.EnvironmentDev,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
if tt.trustedProxies == "" {
require.NoError(
t, os.Unsetenv("TRUSTED_PROXIES"),
)
} else {
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
},
))
require.NoError(
t,
config.WarnSharedRateLimitBucketForTest(log),
)
if !tt.expectWarning {
assert.Empty(t, buf.String())
return
}
logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "TRUSTED_PROXIES")
assert.Contains(t, logged, "share one bucket")
assert.Contains(
t, logged, "throttle every other client's failed logins",
)
// The warning must not claim a lockout the login
// endpoint no longer permits: credentials are verified
// before any budget is spent.
assert.Contains(
t, logged, "a correct password still gets in",
)
// The text must stay accurate for a developer with
// nothing in front of the process, where an empty
// list costs nothing.
assert.Contains(
t, logged, "nothing proxying to this process",
)
})
}
}
// metricsEnv describes what one subtest below puts in the
// environment for a single METRICS_ variable. A variable that is
// set to the empty string and one that is not set at all are
@@ -1017,7 +1093,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
+15
View File
@@ -6,6 +6,21 @@ import "log/slog"
// the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API.
// WarnSharedRateLimitBucketForTest loads a Config from the current
// environment and emits its startup warnings to log. The real logger
// writes to stdout, so this lets the warning's firing condition be
// asserted against a handler the test controls.
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnSharedRateLimitBucket(log)
return nil
}
// WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when
+18 -73
View File
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
deliveries []database.Delivery
results []database.DeliveryResult
depths []struct{ Depth int }
removed []database.TargetTotals
)
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
// The delivery engine: recovery and the retry sweep, the sweep for
// stranded pending deliveries, and the queue depth count.
@@ -123,80 +123,25 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention (reapExpired, deleteEvents): one batch of expired
// events, then their attempts, deliveries and the events.
var expired []string
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
Where("created_at < ?", cutoff).
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
"idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
Select("id").Where("event_id IN ?", ids),
).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)",
"idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
database.DeliveryStatusFailed).
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
"idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
// Retention's three deletes (reapExpired), whose subqueries are built
// afresh for each statement as it builds them.
expiredEventIDs := func() *gorm.DB {
return dry.Model(&database.Event{}).Select("id").
Where("created_at < ?", cutoff)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
// come from the index alone, and events received since a time.
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
since := time.Now()
var (
count int64
byTarget []struct{ TargetID string }
)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).Count(&count),
"idx_deliveries_status (status=? AND deleted_at=?)")
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").Find(&byTarget),
"COVERING INDEX idx_deliveries_status "+
"(status=? AND deleted_at=? AND finished_at>?)")
assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("created_at >= ?", since).Count(&count),
"idx_events_deleted_at_created_at "+
"(deleted_at=? AND created_at>?)")
assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
Select("id").Where("event_id IN (?)", expiredEventIDs()),
).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
assertPlanUses(t, db, dry.Unscoped().Where(
"event_id IN (?)", expiredEventIDs(),
).Delete(&database.Delivery{}),
"idx_deliveries_event_id (event_id=?)", byAge)
assertPlanUses(t, db, dry.Unscoped().Where(
"created_at < ?", cutoff,
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
}
// assertPlanUses asserts that SQLite's plan for a statement GORM built
-16
View File
@@ -5,7 +5,6 @@ import (
"io"
"log/slog"
"os"
"testing"
"time"
"go.uber.org/fx"
@@ -29,10 +28,6 @@ func NewTestRetentionReaper(
}
}
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx)
@@ -84,14 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
func DummyPasswordHashForTest() string {
return dummyPasswordHash()
}
// HashAtShippedCostForTest makes HashPassword hash at the shipped
// memory cost until t ends. t must not run in parallel with other
// tests, which would hash at that cost alongside it.
func HashAtShippedCostForTest(t *testing.T) {
t.Helper()
hashAtShippedCostInTest = true
t.Cleanup(func() { hashAtShippedCostInTest = false })
}
+2 -13
View File
@@ -1,10 +1,6 @@
package database
import (
"time"
"gorm.io/gorm"
)
import "gorm.io/gorm"
// DeliveryStatus represents the status of a delivery
type DeliveryStatus string
@@ -41,7 +37,7 @@ type Delivery struct {
BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column
@@ -49,13 +45,6 @@ type Delivery struct {
// gives.
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// FinishedAt is when the delivery became delivered or failed, and
// nil while it is pending or retrying. It and then TargetID end the
// status index, so the webhook page counts each target's deliveries
// that finished in a recent window by reading just that window from
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations
Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"`
-5
View File
@@ -31,11 +31,6 @@ type Event struct {
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored so the recent events list can show it without reading
// the body.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// 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
-99
View File
@@ -1,99 +0,0 @@
package database
import (
"fmt"
"time"
"gorm.io/gorm"
)
// The running totals in a webhook's event database keep the webhook
// page's lifetime figures right after retention has removed the rows
// they count, and let the page show them without counting every row.
// Each total changes in the transaction that writes or deletes the
// rows it counts.
// EventTotals is the single row counting a webhook's events: every
// event ever stored, how many of them retention has deleted, and when
// the newest arrived, which retention leaves as it is.
type EventTotals struct {
ID int64 `gorm:"primaryKey"`
Events int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
// LastEventAt is when the newest event arrived, or nil before the
// first.
LastEventAt *time.Time
}
// TableName names the table AddEventTotals updates.
func (EventTotals) TableName() string {
return "event_totals"
}
// TargetTotals is one row per target counting its deliveries: every
// delivery ever created, how many became delivered and how many
// failed, and how many deliveries and failed deliveries retention has
// deleted. The webhook's delivery figures are these rows summed.
type TargetTotals struct {
TargetID string `gorm:"type:uuid;primaryKey"`
Deliveries int64 `gorm:"not null"`
Delivered int64 `gorm:"not null"`
Failed int64 `gorm:"not null"`
DeliveriesRemoved int64 `gorm:"not null"`
FailedRemoved int64 `gorm:"not null"`
}
// TableName names the table AddTargetTotals updates.
func (TargetTotals) TableName() string {
return "target_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it
// counts.
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
err := tx.Exec(
`UPDATE event_totals SET
events = events + ?,
events_removed = events_removed + ?,
last_event_at = coalesce(?, last_event_at)`,
add.Events, add.EventsRemoved, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf("adding to event totals: %w", err)
}
return nil
}
// AddTargetTotals adds each count in add to the totals of the target
// add.TargetID names, creating its row the first time. Call it on the
// transaction that writes or deletes the deliveries it counts.
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
err := tx.Exec(
`INSERT INTO target_totals (target_id, deliveries, delivered,
failed, deliveries_removed, failed_removed)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (target_id) DO UPDATE SET
deliveries = deliveries + excluded.deliveries,
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
deliveries_removed =
deliveries_removed + excluded.deliveries_removed,
failed_removed = failed_removed + excluded.failed_removed`,
add.TargetID, add.Deliveries, add.Delivered,
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of target %s: %w", add.TargetID, err,
)
}
return nil
}
+1 -46
View File
@@ -1,58 +1,13 @@
package database
import (
"errors"
"fmt"
"gorm.io/gorm"
)
// MaxUsernameBytes is the longest username, in bytes, that a user may
// have. The same number appears in the check constraint on
// User.Username, because a struct tag cannot reference a constant.
//
// A login stores the username in the session cookie, and both
// securecookie and browsers refuse a cookie value past about 4096
// bytes. That value is the session base64-encoded twice, so it holds
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
// timestamp and the session's other values take about 270 of those: a
// username longer than about 2030 bytes can never log in. The limit is
// about half that, so the session can carry more values later without
// locking out an account whose username is already at the limit.
const MaxUsernameBytes = 1024
// ErrUsernameTooLong is returned when a user is saved with a username
// longer than MaxUsernameBytes.
var ErrUsernameTooLong = errors.New("username is too long")
// User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations
Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"`
}
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
// User is created or saved, so those calls get ErrUsernameTooLong rather
// than the database's constraint error. A column update such as
// Update("username", ...) is caught only by the check constraint, as is
// any path that writes the table without this model.
func (u *User) BeforeSave(_ *gorm.DB) error {
if len(u.Username) > MaxUsernameBytes {
return fmt.Errorf(
"%w: %d bytes, limit is %d",
ErrUsernameTooLong,
len(u.Username),
MaxUsernameBytes,
)
}
return nil
}
-65
View File
@@ -1,65 +0,0 @@
package database_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
// two-byte character. A check that counted characters rather than bytes
// would see half the length and let the one-byte-longer name through.
func usernameAtLimit() string {
return strings.Repeat("é", database.MaxUsernameBytes/2)
}
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(&database.User{
Username: usernameAtLimit() + "x",
Password: "hash",
}).Error
require.ErrorIs(t, err, database.ErrUsernameTooLong)
}
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(&database.User{
Username: usernameAtLimit(),
Password: "hash",
}).Error)
}
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
// SQL, as a path that bypassed User.BeforeSave would, so only the
// table's check constraint stands between it and an over-long
// username. Accepting the name at the limit and refusing the next byte
// also pins the constraint's number to MaxUsernameBytes.
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
require.NoError(t, db.Exec(
insert, uuid.New().String(), usernameAtLimit(), "hash",
).Error)
err := db.Exec(
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
+1 -2
View File
@@ -2,8 +2,7 @@ package database
// Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals) live in
// Event-tier models (Event, Delivery, DeliveryResult) live in
// per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
+1 -22
View File
@@ -9,7 +9,6 @@ import (
"math/big"
"strings"
"sync"
"testing"
"golang.org/x/crypto/argon2"
)
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
}
}
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
// HashPassword generates an Argon2id hash of the password
func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt
salt := make([]byte, config.SaltLen)
-33
View File
@@ -192,39 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
}
}
// TestHashPassword_ShippedParameters hashes and verifies through
// HashPassword at the shipped Argon2id parameters. Every other test
// hashes at the lower memory cost a test binary uses, so this is the
// one that keeps production hashing covered. One hash and one
// verification: each costs 64 MB.
//
//nolint:paralleltest // changes the hashing cost for the whole binary
func TestHashPassword_ShippedParameters(t *testing.T) {
database.HashAtShippedCostForTest(t)
password := "correct horse battery staple"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("hashing with the shipped parameters: %v", err)
}
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
if !strings.HasPrefix(hash, shipped) {
t.Errorf("hash = %q, want prefix %q", hash, shipped)
}
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in
+46 -111
View File
@@ -18,19 +18,6 @@ import (
// computation.
const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// reapBatchPause is how long retention waits after one batch before
// starting the next. A writer waiting for the write lock checks for it
// again after at most 100 ms, so a longer pause lets it in between two
// batches instead of only after the whole prune.
const reapBatchPause = 200 * time.Millisecond
// RetentionReaperParams holds the fx dependencies for the
// RetentionReaper.
type RetentionReaperParams struct {
@@ -200,15 +187,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
continue
}
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
r.reapWebhook(wh.ID, wh.RetentionDays)
}
}
// reapWebhook removes every expired event (and its dependents) from a
// single webhook's database, or as many as it reaches before ctx is
// cancelled.
// single webhook's database.
func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string,
retentionDays int,
) {
@@ -228,7 +213,7 @@ func (r *RetentionReaper) reapWebhook(
return
}
deleted, err := reapExpired(ctx, db, cutoff)
deleted, err := reapExpired(db, cutoff)
if err != nil {
r.log.Error(
"retention sweep: failed to reap expired events",
@@ -280,107 +265,57 @@ func retentionCutoff(
), true
}
// reapExpired hard-deletes the events older than cutoff, with their
// deliveries and delivery results, reapBatchSize events per
// transaction with reapBatchPause between transactions, until none is
// left. Once ctx is cancelled it returns after the batch in hand,
// leaving the rest to the next sweep, so stopping the app does not
// wait for a long prune. It returns the number of events deleted.
func reapExpired(
ctx context.Context, db *gorm.DB, cutoff time.Time,
) (int64, error) {
var total int64
for {
var eventIDs []string
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Unscoped().Model(&Event{}).
Where("created_at < ?", cutoff).
Limit(reapBatchSize).
Pluck("id", &eventIDs).Error
if err != nil {
return fmt.Errorf("selecting expired events: %w", err)
}
if len(eventIDs) == 0 {
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
select {
case <-ctx.Done():
return total, nil
case <-time.After(reapBatchPause):
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
// results, deliveries, and events associated with events older than
// cutoff. Deletes are unscoped so rows are physically removed rather
// than soft-deleted, reclaiming disk. It returns the number of events
// deleted.
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
// Fresh subqueries are built per statement to avoid reusing a
// mutated builder across executions.
expiredEventIDs := func() *gorm.DB {
return db.Model(&Event{}).
Select("id").
Where("event_id IN ?", eventIDs)).
Delete(&DeliveryResult{}).Error
if err != nil {
return fmt.Errorf("deleting expired delivery results: %w", err)
Where("created_at < ?", cutoff)
}
expiredDeliveryIDs := func() *gorm.DB {
return db.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
}
// 2. The events' deliveries, after counting them, and the failed
// ones among them, per target. The status is tested in the select
// list rather than the WHERE clause: there, SQLite would read every
// failed delivery the webhook has through the status index,
// instead of only these through the event_id index.
var removed []TargetTotals
err = tx.Unscoped().Model(&Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
// 1. Delivery results whose delivery belongs to an expired event.
res := db.Unscoped().
Where("delivery_id IN (?)", expiredDeliveryIDs()).
Delete(&DeliveryResult{})
if res.Error != nil {
return 0, fmt.Errorf(
"deleting expired delivery results: %w",
res.Error,
)
}
err = tx.Unscoped().
Where("event_id IN ?", eventIDs).
Delete(&Delivery{}).Error
if err != nil {
return fmt.Errorf("deleting expired deliveries: %w", err)
// 2. Deliveries belonging to an expired event.
del := db.Unscoped().
Where("event_id IN (?)", expiredEventIDs()).
Delete(&Delivery{})
if del.Error != nil {
return 0, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
}
// 3. The events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
// 3. The expired events themselves.
ev := db.Unscoped().
Where("created_at < ?", cutoff).
Delete(&Event{})
if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error)
return 0, fmt.Errorf(
"deleting expired events: %w",
ev.Error,
)
}
for i := range removed {
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
return ev.RowsAffected, nil
}
-410
View File
@@ -1,410 +0,0 @@
package database_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// readEventTotals reads a webhook database's row of event totals,
// asserting that it has exactly one.
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var rows []database.EventTotals
require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1)
return rows[0]
}
// readTargetTotals reads a webhook database's target totals, keyed by
// target.
func readTargetTotals(
t *testing.T, db *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
// database starts with one row of zero event totals and no target
// totals, that adding to a target twice adds to the one row, and that
// opening the database again keeps everything added.
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
fresh := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
assert.Empty(t, readTargetTotals(t, db))
first, second := uuid.New().String(), uuid.New().String()
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 2,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Deliveries: 2, Delivered: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Failed: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: second, Deliveries: 1,
}))
// Drop the cached connection so the next open reopens the file,
// as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
readEventTotals(t, db))
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
},
second: {TargetID: second, Deliveries: 1},
}, readTargetTotals(t, db))
}
// seedExpiredEvents stores count events created at the given time,
// each with a delivered delivery to one target and a failed delivery
// to the other, and one attempt for each delivery.
func seedExpiredEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
delivered, failed string,
) {
t.Helper()
events := make([]database.Event, count)
deliveries := make([]database.Delivery, 0, 2*count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].ID = uuid.New().String()
events[i].CreatedAt = createdAt
deliveries = append(deliveries,
database.Delivery{
EventID: events[i].ID,
TargetID: delivered,
Status: database.DeliveryStatusDelivered,
},
database.Delivery{
EventID: events[i].ID,
TargetID: failed,
Status: database.DeliveryStatusFailed,
},
)
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries {
results[i] = database.DeliveryResult{
DeliveryID: deliveries[i].ID, AttemptNum: 1,
}
}
require.NoError(t, db.CreateInBatches(results, 500).Error)
}
// seedBareEvents stores count events created at the given time, with
// no deliveries.
func seedBareEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
) {
t.Helper()
events := make([]database.Event, count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
// larger than one transaction's batch removes every expired event with
// its deliveries and delivery results, keeps the recent event, and
// adds what it removed to the event and target totals, so the totals
// within retention match the rows still stored.
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
expired := database.ExportReapBatchSize + 1
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour), delivered, failed)
// One recent event, delivered to the first target.
recent := seedEventChain(t, db, webhookID, time.Now())
require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", recent.deliveryID).
Update("target_id", delivered).Error)
// The totals storing those rows would have left.
n := int64(expired)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: n, Failed: n,
}))
env.reaper.ExportSweep(context.Background())
// Only the recent event's rows are left.
for _, model := range []any{
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
} {
var count int64
require.NoError(t, db.Model(model).Count(&count).Error)
assert.Equal(t, int64(1), count, "%T rows left", model)
}
assertChainPresent(t, db, recent)
eventTotals := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
}, eventTotals)
targetTotals := readTargetTotals(t, db)
assert.Equal(t, map[string]database.TargetTotals{
delivered: {
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
DeliveriesRemoved: n,
},
failed: {
TargetID: failed, Deliveries: n, Failed: n,
DeliveriesRemoved: n, FailedRemoved: n,
},
}, targetTotals)
// A sweep with nothing left to remove changes nothing.
env.reaper.ExportSweep(context.Background())
assert.Equal(t, eventTotals, readEventTotals(t, db))
assert.Equal(t, targetTotals, readTargetTotals(t, db))
}
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
// of several batches lets other writers in between its batches: an
// event stored once the first batch is deleted is stored while expired
// events are still left, not only after the prune has finished.
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Three batches of expired events, with nothing else stored: only
// the number of batches matters here.
expired := 3 * database.ExportReapBatchSize
seedBareEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour))
cutoff := time.Now().Add(-30 * 24 * time.Hour)
countExpired := func() int64 {
var count int64
require.NoError(t, db.Model(&database.Event{}).
Where("created_at < ?", cutoff).
Count(&count).Error)
return count
}
pruned := make(chan struct{})
go func() {
defer close(pruned)
env.reaper.ExportSweep(context.Background())
}()
t.Cleanup(func() { <-pruned })
// Every stored event is expired until the write below.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < int64(expired)
}, 10*time.Second, 10*time.Millisecond)
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
require.NoError(t, db.Create(event).Error)
assert.Positive(t, countExpired(),
"the event was stored only after the whole prune")
<-pruned
assert.Zero(t, countExpired())
var stored database.Event
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
}
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
// stopping the reaper during a prune of several batches returns
// between two batches, well inside the stop timeout, leaving the
// remaining expired events for the next sweep, and that the totals
// match the rows left.
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Two batches and one more of expired events, a few of them with a
// delivered and a failed delivery for the target totals to count.
// Most carry nothing else, to keep the test quick.
const withDeliveries = 10
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
delivered, failed)
seedBareEvents(t, db, webhookID,
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
n := int64(2*database.ExportReapBatchSize + 1)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: withDeliveries,
Delivered: withDeliveries,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: withDeliveries,
Failed: withDeliveries,
}))
env.reaper.ExportSetInterval(time.Millisecond)
env.reaper.ExportStart()
// Stop once the first batch is deleted. The stop lands in the pause
// after it, or at worst during the second batch, so at least the
// last event is left.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < n
}, 10*time.Second, 10*time.Millisecond)
// The app's stop timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, env.reaper.ExportStop(ctx))
var events int64
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
assert.Positive(t, events, "the stop waited for the whole prune")
eventTotals := readEventTotals(t, db)
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
targetTotals := readTargetTotals(t, db)
require.Len(t, targetTotals, 2)
for target, totals := range targetTotals {
var deliveries, failures int64
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ?", target).
Count(&deliveries).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ? AND status = ?",
target, database.DeliveryStatusFailed).
Count(&failures).Error)
assert.Equal(t, deliveries,
totals.Deliveries-totals.DeliveriesRemoved, target)
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
target)
}
}
+1 -15
View File
@@ -35,8 +35,7 @@ var errInvalidCachedDBType = errors.New(
// WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals).
// database containing Events, Deliveries, and DeliveryResults.
// Database connections are opened lazily and cached.
type WebhookDBManager struct {
dataDir string
@@ -296,7 +295,6 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{},
)
if err != nil {
_ = sqlDB.Close()
@@ -307,18 +305,6 @@ func (m *WebhookDBManager) openDB(
)
}
// A new database gets its row of event totals, all zero. Target
// totals rows are created by the first delivery to each target.
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w",
webhookID, err,
)
}
m.log.Info(
"opened per-webhook database",
"webhook_id", webhookID,
+12 -14
View File
@@ -8,7 +8,6 @@ import (
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle"
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
Logger *logger.Logger
}
// ArchiveSweeper periodically prunes expired rows from the
// archive databases of database targets that carry a positive
// expiry.
// ArchiveSweeper periodically prunes expired rows from
// per-webhook archive databases whose database target carries a
// positive expiry.
//
// Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an
// archive whose target has stopped receiving events would keep
// its expired rows forever. The sweep closes
// archive belonging to a webhook that has stopped receiving
// events would keep its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry
// is unset or "never".
//
@@ -156,7 +155,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
// soft-deleted along with it, so GORM's default scope already
// excludes them.
//
// A failure for one target is logged and the sweep continues,
// A failure for one webhook is logged and the sweep continues,
// matching how the write path already treats a prune error as
// non-fatal.
func (s *ArchiveSweeper) sweep(ctx context.Context) {
@@ -211,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
return
}
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
if err == nil {
return
}
// A writer evicted, or a target row gone, underneath the sweep
// means the operator deleted the target or its webhook while
// the sweep was walking the target list. That is an ordinary
// A writer evicted underneath the sweep means the operator
// deleted the webhook (or its last database target) while the
// sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an
// error line.
if errors.Is(err, errArchiveWriterEvicted) ||
errors.Is(err, gorm.ErrRecordNotFound) {
if errors.Is(err, errArchiveWriterEvicted) {
s.log.Debug(
"archive sweep: target deleted mid-sweep",
"archive sweep: writer evicted mid-sweep",
"webhook_id", target.WebhookID,
"target_id", target.ID,
)
+133 -143
View File
@@ -34,23 +34,18 @@ const (
sweepConcurrentWrites = 20
)
// archiveTestWebhookName is the name of every webhook
// seedDatabaseTarget creates. It is not safe in a file name as it
// stands, so every archive test goes through archiveNamePart.
const archiveTestWebhookName = "Sweep Test!"
// archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, a delivery
// engine owning the archive writer registry, the archive sweeper,
// and the data directory the archive files live in.
type archiveEnv struct {
// sweeperEnv bundles the pieces an archive sweep test drives:
// a main configuration database holding webhooks and targets, a
// delivery engine owning the archive writer registry, and the
// data directory the archive files live in.
type sweeperEnv struct {
sweeper *delivery.ArchiveSweeper
eng *delivery.Engine
mainDB *database.Database
dataDir string
}
func setupArchiveTest(t *testing.T) *archiveEnv {
func setupSweeperTest(t *testing.T) *sweeperEnv {
t.Helper()
dataDir := t.TempDir()
@@ -83,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
1,
)
return &archiveEnv{
return &sweeperEnv{
sweeper: delivery.NewTestArchiveSweeper(
mainDB, eng, log,
),
@@ -93,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
}
}
// archivePath returns where the engine keeps a database target's
// archive file, for the names seedDatabaseTarget gave it.
func (env *archiveEnv) archivePath(tgt *database.Target) string {
// archivePath returns where the engine keeps a webhook's
// archive file.
func (env *sweeperEnv) archivePath(webhookID string) string {
return filepath.Join(
env.dataDir,
delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID,
),
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
}
// seedDatabaseTarget creates a webhook with one database target
// carrying the given target config JSON, and returns the target.
func (env *archiveEnv) seedDatabaseTarget(
// carrying the given target config JSON, and returns the
// webhook id.
func (env *sweeperEnv) seedDatabaseTarget(
t *testing.T, configJSON string,
) *database.Target {
) string {
t.Helper()
wh := &database.Webhook{
UserID: uuid.New().String(),
Name: archiveTestWebhookName,
Name: "sweep-test",
}
require.NoError(
t,
@@ -122,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error,
)
return env.addDatabaseTarget(t, wh.ID, configJSON)
}
// addDatabaseTarget creates one more database target on an
// existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget(
t *testing.T, webhookID, configJSON string,
) *database.Target {
t.Helper()
tgt := &database.Target{
WebhookID: webhookID,
Name: "Archive",
WebhookID: wh.ID,
Name: "archive",
Type: database.TargetTypeDatabase,
Active: true,
Config: configJSON,
@@ -146,19 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
Create(tgt).Error,
)
return tgt
return wh.ID
}
// seedArchiveRows creates the archive file for a target and
// seedArchiveRows creates the archive file for a webhook and
// inserts one row per supplied archived-at timestamp, returning
// the archive path. The handle is closed before returning, so
// the archive is idle exactly as it would be with no traffic.
func (env *archiveEnv) seedArchiveRows(
t *testing.T, tgt *database.Target, archivedAt ...time.Time,
func (env *sweeperEnv) seedArchiveRows(
t *testing.T, webhookID string, archivedAt ...time.Time,
) string {
t.Helper()
path := env.archivePath(tgt)
path := env.archivePath(webhookID)
sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -177,7 +160,7 @@ func (env *archiveEnv) seedArchiveRows(
for i, at := range archivedAt {
row := delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: tgt.WebhookID,
WebhookID: webhookID,
Method: http.MethodPost,
Body: `{"seeded":true}`,
ArchivedAt: at,
@@ -260,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now()
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
now.Add(-48*time.Hour),
now.Add(-time.Minute),
)
@@ -304,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
}
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
// interleaving where a sweep tick has already listed a target
// when the target is deleted and its writer evicted. The sweep
// must not put a writer back into the registry: nothing would
// ever evict it again, which is precisely the leak this change
// exists to close.
// interleaving where a sweep tick has already listed a webhook's
// target when the webhook is deleted and its writer evicted. The
// sweep must not put a writer back into the registry: nothing
// would ever evict it again, which is precisely the leak this
// change exists to close.
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
t *testing.T,
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
// Prime the registry the way a delivery would, then evict as
// the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the target before
// in place: this is the tick that listed the webhook before
// the deletion committed.
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
_, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err)
env.eng.EvictTarget(tgt.ID)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.eng.EvictWebhook(webhookID)
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background())
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must never re-register a writer for a target "+
t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must never re-register a writer for a webhook "+
"whose registry entry has already been released",
)
}
// TestArchiveSweep_LeavesNoRegistryEntry states the same
// invariant in its general form: sweeping an archive whose
// target has no cached writer must not leave one behind, so the
// webhook has no cached writer must not leave one behind, so the
// registry keeps holding only writers a delivery created and an
// eviction can reach.
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute),
)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background())
@@ -366,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the sweep must release the registry entry it created",
)
}
@@ -381,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.sweeper.ExportSweep(context.Background())
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a delivery's writer must stay registered",
)
env.sweeper.ExportSweep(context.Background())
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must not drop a writer a delivery owns",
)
}
@@ -437,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID,
webhookID,
)
require.NoError(t, err)
require.True(
@@ -456,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// The delivery lands mid-sweep and adopts the entry.
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
adopted := env.eng.ExportArchiveWriterFor(webhookID)
require.NotNil(t, adopted)
require.True(
t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer",
)
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery leaves the archive handle open",
)
// The sweep finishes.
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable",
)
env.eng.EvictTarget(tgt.ID)
env.eng.EvictWebhook(webhookID)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the adopted writer must still be evictable",
)
assert.False(
@@ -492,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
)
}
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
// failure for one target does not abort the sweep for the
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
// failure for one webhook does not abort the sweep for the
// others: an unparseable expiry and an unreadable archive both
// have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
t *testing.T,
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
// Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order.
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
// webhook: targets come back in insertion order.
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows(
t, badConfig, time.Now().Add(-48*time.Hour),
t, badConfigID, time.Now().Add(-48*time.Hour),
)
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile(
env.archivePath(corrupt),
env.archivePath(corruptID),
[]byte("this is not a sqlite database"),
0o600,
))
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows(
t, healthy,
t, healthyID,
time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute),
)
@@ -529,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
assert.Equal(
t, []string{sweepRowNew},
archivedEventIDs(t, healthyPath),
"a failure for an earlier target must not stop the "+
"a failure for an earlier webhook must not stop the "+
"sweep from pruning the ones after it",
)
}
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
// of the two no-create guards. The first is the stat in
// sweepExpired; this one is the SQLite open mode, which is what
// sweepWebhook; this one is the SQLite open mode, which is what
// protects the window between that stat and the open. Flipping
// the sweep's mode to create-if-missing makes this fail.
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
@@ -572,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now()
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
now.Add(-48*time.Hour),
now.Add(-time.Minute),
)
@@ -611,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
w := delivery.NewExportArchiveWriter(
@@ -651,32 +640,35 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery must leave the archive handle open",
)
env.sweeper.ExportSweep(context.Background())
require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the delivery's registry entry must survive the sweep",
)
assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the sweep must leave the archive closed",
)
}
@@ -692,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
`{"expiry":""}`,
"",
} {
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, configJSON)
webhookID := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
time.Now().Add(-10000*time.Hour),
)
@@ -707,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
"config %q must keep rows forever", configJSON,
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"config %q must leave no registry entry behind",
configJSON,
)
@@ -730,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(webhookID)
seedUnmigratedArchive(t, path)
require.False(t, archiveTableExists(t, path))
@@ -776,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
}
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
// never conjures an archive: a database target that has never
// received an event must still have no archive file (nor SQLite
// sidecar) after a sweep, and no registry entry either.
// never conjures an archive: a webhook with a database target
// that has never received an event must still have no archive
// file (nor SQLite sidecar) after a sweep.
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(webhookID)
require.NoFileExists(t, path)
@@ -797,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
"the sweep must not create an archive file",
)
}
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind",
)
}
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
@@ -813,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err)
require.NoFileExists(t, path)
@@ -832,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
require.NoError(
t,
env.mainDB.DB().
Where("webhook_id = ?", tgt.WebhookID).
Where("webhook_id = ?", webhookID).
Delete(&database.Target{}).Error,
)
@@ -855,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
}
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// against writes through the target's writer mutex. Run under
// -race, an unsynchronised sweep would be caught here.
// against writes through the per-webhook writer mutex. Run
// under -race, an unsynchronised sweep would be caught here.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t)
@@ -875,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID
deliveries = append(
deliveries,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
),
)
}
@@ -904,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
wg.Wait()
assert.FileExists(t, env.archivePath(tgt))
assert.FileExists(t, env.archivePath(webhookID))
}
// TestArchiveSweeper_StopsCleanly proves the background loop
@@ -912,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
env.sweeper.ExportSetInterval(time.Millisecond)
@@ -940,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
lc := &recordingLifecycle{}
env.sweeper.ExportRegisterHooks(lc)
-177
View File
@@ -1,177 +0,0 @@
package delivery_test
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// targetTotals reads one target's totals from a webhook database, all
// zero when it has no row.
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Where("target_id = ?", targetID).
Find(&rows).Error)
if len(rows) == 0 {
return database.TargetTotals{TargetID: targetID}
}
return rows[0]
}
// 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.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
tests := []struct {
status database.DeliveryStatus
finished bool
delivered int64
failed int64
}{
{database.DeliveryStatusRetrying, false, 0, 0},
{database.DeliveryStatusDelivered, true, 1, 0},
{database.DeliveryStatusFailed, true, 0, 1},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
before := time.Now()
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, tt.status,
))
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, tt.status, stored.Status)
if tt.finished {
require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before))
} else {
assert.Nil(t, stored.FinishedAt)
}
assert.Equal(t, database.TargetTotals{
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
})
}
}
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
// delivery retention deleted while the engine still held it. Failing
// it afterwards writes no row, so it adds no failure either: retention
// has already counted what it removed.
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
require.NoError(t, db.Unscoped().
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, database.DeliveryStatusFailed,
))
assert.Equal(t, database.TargetTotals{TargetID: targetID},
targetTotals(t, db, targetID))
}
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
// delivery settled a second time, as recovery can do when a worker has
// settled it since recovery read it. Neither status writes over the
// first, and the totals do not move.
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
t *testing.T,
) {
t.Parallel()
finished := []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}
for _, first := range finished {
t.Run(string(first), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
// The delivery as recovery read it, before the worker
// settled it.
readBefore := d
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, first,
))
var settled database.Delivery
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
require.NotNil(t, settled.FinishedAt)
totals := targetTotals(t, db, targetID)
for _, again := range finished {
stale := readBefore
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &stale, again,
))
}
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, first, stored.Status)
require.NotNil(t, stored.FinishedAt)
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
assert.Equal(t, totals, targetTotals(t, db, targetID))
})
}
}
+29 -116
View File
@@ -14,7 +14,6 @@ import (
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
@@ -123,24 +122,21 @@ type Notifier interface {
Notify(tasks []Task)
}
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see ArchiveFileName).
// WebhookEvictor releases the delivery engine's per-webhook
// state for a webhook that no longer needs it — currently the
// cached archive writer of the database target, whose open
// file handle would otherwise outlive the webhook.
//
// It is deliberately separate from Notifier: archiving lifecycle
// is not notification, and a small interface keeps the handlers
// package free of any dependency on the engine's internals while
// staying trivially fakeable in tests.
// It is deliberately separate from Notifier and deliberately
// one method wide: archiving lifecycle is not notification, and
// a single-method interface keeps the handlers package free of
// any dependency on the engine's internals while staying
// trivially fakeable in tests.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no engine state.
type Archives interface {
// EvictWebhook never deletes an archive file. It is idempotent
// and is a no-op for a webhook with no engine state.
type WebhookEvictor interface {
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// EngineParams are the fx dependencies for the delivery
@@ -150,10 +146,8 @@ type EngineParams struct {
DB *database.Database
DBManager *database.WebhookDBManager
Globals *globals.Globals
Logger *logger.Logger
SSRFGuard *Guard
Metrics *metrics.Set
}
// Engine processes queued deliveries in the background
@@ -173,14 +167,10 @@ type Engine struct {
retryCh chan Task
workers int
// version is the running build's version, the one the web UI
// footer shows. userAgent puts it on every outbound request.
version string
// mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can
// gather what its own deliveries recorded.
// mtr is the delivery metric set. Production wires the
// process-wide one; a test can substitute a set registered on
// a private registry so its assertions are not disturbed by
// deliveries other tests are making at the same time.
mtr *metrics.Set
// targets maps each target type to its implementation.
@@ -191,7 +181,7 @@ type Engine struct {
httpTarget *httpTarget
// dbTarget is retained so the engine can reach the archive
// writer registry for eviction, renames and the idle sweep.
// writer registry for webhook eviction and the idle sweep.
dbTarget *databaseTarget
// inflight is the set of deliveries this engine currently owns.
@@ -214,8 +204,7 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers,
version: params.Globals.Version,
mtr: params.Metrics,
mtr: metrics.Default(),
}
e.initTargets(&http.Client{
@@ -260,44 +249,17 @@ func (e *Engine) Notify(tasks []Task) {
}
}
// EvictWebhook implements Archives. The cached archive writer of
// every database target of the webhook is dropped from the
// registry and its file handle closed. The archive files
// themselves are left on disk — they are long-term storage the
// operator owns.
// EvictWebhook implements WebhookEvictor. It releases the
// engine's per-webhook archiving state: the database target's
// cached archive writer is dropped from the registry and its
// file handle closed. The archive file itself is left on disk
// — it is long-term storage the operator owns.
func (e *Engine) EvictWebhook(webhookID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evictWebhook(webhookID)
}
// EvictTarget implements Archives. It is EvictWebhook for a single
// database target, and leaves the archive file on disk the same
// way.
func (e *Engine) EvictTarget(targetID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evict(targetID)
}
// Rename implements Archives. It renames a database target's
// archive file to ArchiveFileName(webhookName, targetName,
// targetID), under the lock the target's archive writes and the
// idle sweep take. It never replaces a file: if one already has the
// new name, the error is ErrArchiveNameTaken. The caller renames
// before it saves the new name: see databaseTarget.rename.
func (e *Engine) Rename(
targetID, webhookName, targetName string,
) error {
if e.dbTarget == nil {
return nil
}
return e.dbTarget.rename(targetID, webhookName, targetName)
e.dbTarget.evict(webhookID)
}
// ScheduleRetry schedules a task to be re-enqueued onto the
@@ -338,13 +300,6 @@ func (e *Engine) ScheduleRetry(
})
}
// userAgent is the User-Agent header of every http and slack
// delivery request: the program name and the running build's
// version.
func (e *Engine) userAgent() string {
return "webhooker/" + e.version
}
// registerHooks wires the engine's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored
// (see start for why the worker pool must not inherit it); the
@@ -411,8 +366,7 @@ func (e *Engine) start() {
// Once the pool has drained it closes the archive writers, so a
// clean stop leaves no archive -wal behind. Nothing else holds a
// writer for long by then: the archive sweeper stops before the
// engine, and deleting or renaming a webhook or target only closes
// or moves one. If the pool did
// engine, and deleting a webhook only closes one. If the pool did
// not drain in time, the writers are left open, as a kill would
// leave them. Closing them would wait for any write in progress,
// and a worker still running would then open new writers that
@@ -577,11 +531,6 @@ func (e *Engine) processRetryTask(
return
}
// Set before anything below can fail the delivery: the failure is
// added to this target's totals.
d.EventID = task.EventID
d.TargetID = task.TargetID
if d.Status != database.DeliveryStatusRetrying {
e.log.Debug(
"skipping retry for delivery "+
@@ -613,6 +562,8 @@ func (e *Engine) processRetryTask(
}
target := buildTargetFromTask(task)
d.EventID = task.EventID
d.TargetID = task.TargetID
d.Event = event
d.Target = target
@@ -1603,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
targetType database.TargetType,
status database.DeliveryStatus,
) error {
err := webhookDB.Transaction(func(tx *gorm.DB) error {
return writeDeliveryStatus(tx, d, status)
})
err := webhookDB.Model(d).
Update("status", status).Error
if err != nil {
return fmt.Errorf(
"updating delivery %s to status %s: %w",
@@ -1624,43 +1574,6 @@ func (e *Engine) updateDeliveryStatus(
return nil
}
// writeDeliveryStatus writes a delivery's new status. A delivery that
// becomes delivered or failed also gets the time it finished, and is
// added to its target's delivered or failed total. That write changes
// only a delivery not yet delivered or failed, and the total moves
// only when it changed a row: retention may have deleted the delivery
// while the engine was working on it, and a recovery path may settle
// a delivery that a worker has already settled.
func writeDeliveryStatus(
tx *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
if !status.Terminal() {
return tx.Model(d).Update("status", status).Error
}
res := tx.Model(d).
Where("status NOT IN ?", []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}).
Updates(map[string]any{
"status": status,
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
return database.AddTargetTotals(tx, add)
}
// settleStatus moves a delivery to its outcome status and reports a
// failed write through bookkeepingFailed, which leaves the row
// recoverable. It exists so the target call sites read as one
+10 -23
View File
@@ -2,6 +2,7 @@ package delivery_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
@@ -9,7 +10,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
@@ -272,35 +272,22 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
}
// 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
// the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
// deliverToArchive runs one delivery to a database target through
// the running engine and returns the webhook's archive file path.
// The archive writer holds the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) string {
t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
tgt := &database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
}
require.NoError(
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
)
deliveryID, task := seedLogTask(t, s)
task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase
s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID)
return tgt.ID, filepath.Join(
return filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
"archive-hook-archive-"+tgt.ID+".db",
fmt.Sprintf("archive-%s.db", s.WebhookID),
)
}
@@ -317,7 +304,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
lc := startEngineViaHook(t, s.Engine)
_, path := deliverToArchive(t, s)
path := deliverToArchive(t, s)
require.FileExists(
t, path+"-wal",
"an open archive should have a -wal for the stop to remove",
@@ -351,7 +338,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
lc := startEngineViaHook(t, s.Engine)
targetID, _ := deliverToArchive(t, s)
deliverToArchive(t, s)
release := make(chan struct{})
@@ -365,7 +352,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
require.True(
t, s.Engine.ExportArchiveHandleOpen(targetID),
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
"a stop that timed out must not close archive writers",
)
}
+34 -20
View File
@@ -57,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db
}
@@ -351,15 +348,23 @@ func TestDeliverDatabase_ImmediateSuccess(
db := testWebhookDB(t)
// The database target archives for real, so the engine needs
// the target in the main database and a data directory.
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
// The database target archives for real now, so the engine
// needs a webhook DB manager to locate the data directory.
e := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, tgt)
d := seedDatabaseTargetDelivery(t, db, event, "")
env.eng.ExportDeliverDatabase(db, d)
e.ExportDeliverDatabase(db, d)
var updated database.Delivery
@@ -1239,6 +1244,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType,
receivedHeaders.Get("Content-Type"),
)
assert.Equal(t,
"webhooker/1.0",
receivedHeaders.Get("User-Agent"),
)
}
// The event's stored inbound headers carry the same Content-Type the
@@ -1307,7 +1317,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event,
},
cfg,
"webhooker/dev",
)
assert.Equal(t,
@@ -1324,27 +1333,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
db := testWebhookDB(t)
// The database target archives for real, so the engine needs
// the target in the main database and a data directory.
env := setupArchiveTest(t)
archive := env.seedDatabaseTarget(t, "")
// The database target archives for real now, so the engine
// needs a webhook DB manager to locate the data directory.
e := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
tests := []struct {
name string
targetType database.TargetType
targetID string
wantStatus database.DeliveryStatus
}{
{
"database target",
database.TargetTypeDatabase,
archive.ID,
database.DeliveryStatusDelivered,
},
{
"log target",
database.TargetTypeLog,
uuid.New().String(),
database.DeliveryStatusDelivered,
},
}
@@ -1354,7 +1368,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
t.Parallel()
runRoutingSubtest(
t, db, env.eng, tt.targetType, tt.targetID,
t, db, e, tt.targetType,
tt.wantStatus,
)
})
@@ -1366,7 +1380,6 @@ func runRoutingSubtest(
db *gorm.DB,
e *delivery.Engine,
targetType database.TargetType,
targetID string,
wantStatus database.DeliveryStatus,
) {
t.Helper()
@@ -1374,7 +1387,8 @@ func runRoutingSubtest(
event := seedEvent(t, db, `{"routing":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
t, db, event.ID,
uuid.New().String(),
database.DeliveryStatusPending,
)
+31 -48
View File
@@ -9,7 +9,6 @@ import (
"net/url"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
@@ -41,6 +40,11 @@ const (
ExportPendingSweepMinAge = pendingSweepMinAge
)
// ExportIsBlockedIP exposes isBlockedIP for testing.
func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// NewTestGuard builds an SSRF Guard from an explicit egress
// allowlist, without going through config. Passing no prefixes
// yields the default guard, which blocks every private/reserved
@@ -66,11 +70,6 @@ func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks
}
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
func ExportBlockedPublicNetworks() []*net.IPNet {
return blockedPublicNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name)
@@ -83,9 +82,8 @@ func ExportApplyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
userAgent string,
) []string {
return applyRequestHeaders(req, event, cfg, userAgent)
return applyRequestHeaders(req, event, cfg)
}
// ExportTruncate exposes truncate for testing.
@@ -152,16 +150,6 @@ func (e *Engine) ExportDeliverSlack(
)
}
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
// target type, so no metric moves.
func (e *Engine) ExportUpdateDeliveryStatus(
webhookDB *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
return e.updateDeliveryStatus(webhookDB, d, "", status)
}
// ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task,
@@ -401,7 +389,7 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(client)
@@ -416,7 +404,7 @@ func NewTestEngineSmallRetry(
e := &Engine{
log: log,
retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(nil)
@@ -439,7 +427,7 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(client)
@@ -447,7 +435,8 @@ func NewTestEngineWithDB(
}
// ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a registry it holds.
// assert on collectors registered on a private registry instead of
// the process-wide ones every other test is also moving.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr
}
@@ -474,7 +463,7 @@ func NewTestCircuitBreaker(
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
// can exercise the per-webhook archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
@@ -549,12 +538,6 @@ func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
@@ -574,16 +557,16 @@ func (e *ExportArchiveWriter) Same(
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
// currently caches for a webhook, or nil when none is cached. It
// never creates one, so a test can hold a reference to the very
// writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor(
targetID string,
webhookID string,
) *ExportArchiveWriter {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID]
w, ok := e.dbTarget.writers[webhookID]
if !ok {
return nil
}
@@ -592,26 +575,26 @@ func (e *Engine) ExportArchiveWriterFor(
}
// ExportHasArchiveWriter reports whether the database target
// type currently caches an archive writer for a target.
// currently caches an archive writer for a webhook.
func (e *Engine) ExportHasArchiveWriter(
targetID string,
webhookID string,
) bool {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID]
_, ok := e.dbTarget.writers[webhookID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// writer for a webhook holds an open database handle. It
// returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen(
targetID string,
webhookID string,
) bool {
e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID]
w, ok := e.dbTarget.writers[webhookID]
e.dbTarget.mu.Unlock()
if !ok {
@@ -625,12 +608,12 @@ func (e *Engine) ExportArchiveHandleOpen(
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// archive file path of the cached writer for a webhook, so a
// test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter(
targetID string,
webhookID string,
) (string, error) {
w, err := e.dbTarget.writerFor(targetID)
w, err := e.dbTarget.writerFor(webhookID)
if err != nil {
return "", err
}
@@ -638,14 +621,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// ExportSweepWriterFor takes a webhook's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor(
targetID string,
webhookID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID)
w, created, err := e.dbTarget.sweepWriterFor(webhookID)
if err != nil {
return nil, false, err
}
@@ -656,9 +639,9 @@ func (e *Engine) ExportSweepWriterFor(
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
webhookID string, w *ExportArchiveWriter,
) {
e.dbTarget.releaseSweepWriter(targetID, w.w)
e.dbTarget.releaseSweepWriter(webhookID, w.w)
}
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
+3 -2
View File
@@ -35,8 +35,9 @@ const (
)
// mIsolate gives the setup's engine a metric set registered on a
// registry this test holds, so its exact assertions can gather from
// it.
// private registry. The process-wide collectors are moved by every
// other delivery test running in parallel, so exact assertions are
// only possible against a registry this test owns.
func mIsolate(
t *testing.T, s iSetup,
) *prometheus.Registry {
-1
View File
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
"Content-Type": testContentType,
},
},
"webhooker/dev",
)
assert.Equal(t,
+32 -104
View File
@@ -25,64 +25,36 @@ var (
errNoIPs = errors.New(
"hostname resolved to no IP addresses",
)
// ErrBlockedPrivateOrReservedIP reports an address in the
// default blocklist's private and reserved ranges,
// blockedNetworks.
ErrBlockedPrivateOrReservedIP = errors.New(
"blocked private or reserved address",
)
// errBlockedPublicMetadata reports a public address on the
// default blocklist, one in blockedPublicNetworks.
errBlockedPublicMetadata = errors.New(
"blocked cloud metadata address",
errBlockedIP = errors.New(
"blocked private, reserved or cloud metadata address",
)
errBlockedMetadata = errors.New(
"blocked link-local, cloud instance metadata or " +
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
"blocked link-local or cloud instance metadata " +
"address: ALLOWED_EGRESS_CIDRS cannot open it",
)
errInvalidScheme = errors.New(
"only http and https are allowed",
)
)
// blockedNetworks and blockedPublicNetworks together are the
// default blocklist: the private and reserved IP ranges, plus
// the public cloud metadata addresses, that are blocked to
// prevent SSRF attacks. An operator can permit specific blocks
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
//
// blockedNetworks holds the private and reserved IP ranges.
// blockedNetworks is the default blocklist: the private and
// reserved IP ranges, plus the public cloud metadata addresses,
// that are blocked to prevent SSRF attacks. An operator can
// permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet
// blockedPublicNetworks holds the default blocklist's public
// addresses, kept apart from blockedNetworks so that they are
// refused as cloud metadata addresses, never as private or
// reserved ones.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything; it goes
// in this list. A provider's other public addresses are not
// refused, since reaching them can be legitimate and no list of
// them could be complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can
// open, so a supplied CIDR that covers one still leaves it
// blocked. An entry is here for one of two reasons: it is a
// metadata endpoint (the link-local blocks and the cloud
// instance metadata endpoints that live outside them), or it is
// an unspecified address. Reaching a metadata endpoint is
// credential or user-data theft rather than delivery to an
// internal service.
// open: the link-local blocks and the cloud instance metadata
// endpoints that live outside them. Reaching one is credential
// or user-data theft rather than delivery to an internal
// service, so a supplied CIDR that covers such an address still
// leaves it blocked.
//
// Inclusion criterion for metadata endpoints — one belongs here
// only if BOTH hold, and every metadata entry below satisfies
// both:
// Inclusion criterion — an address belongs here only if BOTH
// hold, and every entry below satisfies both:
//
// 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose.
@@ -93,8 +65,8 @@ var blockedPublicNetworks []*net.IPNet
// not cheaply rotated.
//
// Both halves are load-bearing, so use them to refuse a
// metadata candidate and say why. An endpoint disclosing only
// the operator's own inventory (instance id, region, disks, NICs)
// candidate and say why. An endpoint disclosing only the
// operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either —
@@ -109,21 +81,12 @@ var blockedPublicNetworks []*net.IPNet
// when it clears both halves. Nothing in this list can be
// reopened, so putting a public address here leaves the operator
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
// exists to remove. Default-block it in blockedPublicNetworks
// instead, which an allowlist can override.
// exists to remove. Default-block it in blockedNetworks instead,
// which an allowlist can override.
//
// This is a criterion, not an enumeration of every metadata
// address in existence.
//
// The unspecified addresses 0.0.0.0 and :: are here for a
// separate reason: they disclose nothing, but no host can have
// either, and on Linux a connection to one reaches this host's
// own loopback. Listing them means an allowlist reaches loopback
// only through an entry that covers a loopback address
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
// else lives at either address, so refusing them costs nothing.
//
// Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against
@@ -143,49 +106,23 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() {
blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4",
// IPv6 loopback.
"::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7",
// IPv6 link-local.
"fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
})
blockedPublicNetworks = mustParseCIDRs([]string{
// Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32",
})
@@ -242,14 +179,6 @@ func init() {
// allowlist from opening it.
"192.0.0.192/32",
// The unspecified addresses, each of which reaches this
// host's loopback on Linux.
//
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
"0.0.0.0/32",
// IPv6 unspecified address.
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
@@ -289,6 +218,13 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
return false
}
// isBlockedIP checks whether an IP address falls within
// the default blocklist, before any operator allowlist is
// considered.
func isBlockedIP(ip net.IP) bool {
return matchesAny(blockedNetworks, ip)
}
// Guard makes every SSRF decision in the process.
//
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
@@ -386,12 +322,10 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy:
//
// 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local, a
// cloud metadata endpoint at a non-public address, or an
// unspecified address.
// consulted, so no configured CIDR reaches link-local or a
// cloud metadata endpoint at a non-public address.
// 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default
// blocklist, becomes reachable.
// network becomes reachable.
// 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) {
@@ -404,15 +338,9 @@ func (g *Guard) checkIP(ip net.IP) error {
return nil
}
if matchesAny(blockedNetworks, ip) {
if isBlockedIP(ip) {
return fmt.Errorf(
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
)
}
if matchesAny(blockedPublicNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedPublicMetadata,
"target IP %s: %w", ip, errBlockedIP,
)
}
+12 -131
View File
@@ -23,10 +23,6 @@ const (
metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// linkLocalIPv4 is the IPv4 link-local block, which holds
// metadataIP.
linkLocalIPv4 = "169.254.0.0/16"
// loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists
// loopback.
@@ -168,13 +164,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, and the
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
// on Linux, so no allowlist reaches any of them. Every guard
// below names a CIDR that covers its target — including
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
// operator would really list — and the address must stay
// refused anyway, on both the validation and the delivery path.
// rather than delivery to an internal service, so no allowlist
// reaches one. Every guard below names a CIDR that covers its
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
// CGNAT blocks an operator would really list — and the address
// must stay refused anyway, on both the validation and the
// delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel()
@@ -220,17 +215,15 @@ type metadataAlwaysRefusedCase struct {
}
// metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address (link-local, the cloud metadata endpoints and
// the unspecified addresses) together with an allowlist entry
// that would otherwise reach it. Split by family of address only
// to stay under the function-length limit.
// blocked address together with an allowlist entry that would
// otherwise reach it. Split by family of address only to stay
// under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, unspecifiedRefusedCases()...)
return append(cases, encodedMetadataRefusedCases()...)
}
// linkLocalRefusedCases covers the link-local blocks, including
@@ -244,7 +237,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
},
{
name: "whole link-local block",
allow: linkLocalIPv4,
allow: "169.254.0.0/16",
target: metadataURL,
},
{
@@ -370,23 +363,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
}
}
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set.
@@ -436,9 +412,6 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
"WireServer must be refused by the default blocklist, "+
"which an allowlist can override",
)
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
"WireServer is public, not private or reserved",
)
assertDialRefused(t, defaultGuard, target)
@@ -523,7 +496,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
want := []string{
// IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others.
linkLocalIPv4,
"169.254.0.0/16",
// IPv6 link-local.
"fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may
@@ -544,10 +517,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24.
"192.0.0.192/32",
// The IPv4 and IPv6 unspecified addresses, each of
// which reaches this host's loopback on Linux.
"0.0.0.0/32",
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
@@ -557,94 +526,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
assert.Equal(t, want, got)
}
// TestDefaultBlocklist_PinnedSet pins each list of the default
// blocklist on its own, the private and reserved ranges in
// blockedNetworks and the public addresses in
// blockedPublicNetworks, so moving an entry from one list to the
// other fails it. For the first address of each entry it then
// checks that the default guard refuses it, and that listing the
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
// set holds that address.
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
t.Parallel()
// public marks an entry of blockedPublicNetworks; every other
// entry belongs in blockedNetworks.
tests := []struct {
cidr string
public bool
reopenable bool
}{
{cidr: "127.0.0.0/8", reopenable: true},
{cidr: "10.0.0.0/8", reopenable: true},
{cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false},
// Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
{cidr: "198.18.0.0/15", reopenable: true},
{cidr: "198.51.100.0/24", reopenable: true},
{cidr: "203.0.113.0/24", reopenable: true},
{cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true},
}
wantPrivate := make([]string, 0, len(tests))
wantPublic := make([]string, 0, len(tests))
for _, tt := range tests {
if tt.public {
wantPublic = append(wantPublic, tt.cidr)
} else {
wantPrivate = append(wantPrivate, tt.cidr)
}
}
gotPrivate := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedNetworks() {
gotPrivate = append(gotPrivate, n.String())
}
gotPublic := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedPublicNetworks() {
gotPublic = append(gotPublic, n.String())
}
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(tt.cidr)
ip := net.IP(prefix.Addr().AsSlice())
require.Error(t,
delivery.NewTestGuard().ExportCheckIP(ip),
"the default guard must refuse %s", ip,
)
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
if tt.reopenable {
assert.NoError(t, err, "listing %s must open it", tt.cidr)
} else {
assert.Error(t, err, "listing %s must not open it", tt.cidr)
}
})
}
}
// requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop
// exercising a blocked range.
+4 -42
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -56,14 +56,12 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
"failed to parse IP %s", tt.ip,
)
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
assert.Equal(t,
tt.blocked,
refused,
"default guard refuses %s = %v, want %v",
delivery.ExportIsBlockedIP(ip),
"isBlockedIP(%s) = %v, want %v",
tt.ip,
refused,
delivery.ExportIsBlockedIP(ip),
tt.blocked,
)
})
@@ -101,42 +99,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
}
}
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel()
+88 -195
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
@@ -12,75 +11,22 @@ import (
"sneak.berlin/go/webhooker/internal/database"
)
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// databaseTarget is a no-retry target that archives the full
// inbound event into the target's own archive SQLite file, separate
// from the per-webhook event database. The event is already
// persisted in the per-webhook event DB by the time delivery runs;
// the database target additionally writes a durable long-term copy
// into the file ArchiveFileName names and then records a single
// attempt whose outcome reflects whether the archive write
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
// and expiry semantics.
// databaseTarget is a no-retry target that archives the
// full inbound event into a per-webhook archive SQLite file,
// separate from the per-webhook event database. The event is
// already persisted in the per-webhook event DB by the time
// delivery runs; the database target additionally writes a
// durable long-term copy into archive-{webhookID}.db and then
// records a single attempt whose outcome reflects whether the
// archive write succeeded. See archiveWriter for the
// close/reopen, auto-recreate, and expiry semantics.
type databaseTarget struct {
eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
}
// ArchiveFileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func ArchiveFileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Deliver implements Target. It archives the event, then
// records one successful attempt and marks the delivery
// delivered. An archiving error fails the delivery: the
@@ -146,7 +92,7 @@ func (t *databaseTarget) Deliver(
)
}
// archive writes the full event as a row into the target's
// archive writes the full event as a row into the webhook's
// archive database, honouring the optional per-target expiry
// parsed from the target config JSON.
func (t *databaseTarget) archive(d *database.Delivery) error {
@@ -160,7 +106,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err
}
w, err := t.writerFor(d.TargetID)
w, err := t.writerFor(webhookID)
if err != nil {
return err
}
@@ -178,31 +124,30 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return w.write(row, expiry)
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
// writerFor returns the archiveWriter for a webhook, creating
// and caching it on first use. Each webhook has one writer so
// its close/reopen debounce state is shared across concurrent
// deliveries. The archive file lives beside the per-webhook
// event database in the data directory.
func (t *databaseTarget) writerFor(
targetID string,
webhookID string,
) (*archiveWriter, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if !ok {
var err error
w, err = t.newWriter(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return nil, err
}
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
t.writers[targetID] = w
w, ok := t.writers[webhookID]
if !ok {
w = newArchiveWriter(path, t.eng.log)
t.writers[webhookID] = w
}
// A delivery claims the entry: even if the idle sweep created
@@ -214,39 +159,40 @@ func (t *databaseTarget) writerFor(
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
// prune a webhook through, together with whether the sweep itself
// created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// concurrently with the webhook's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor(
targetID string,
webhookID string,
) (*archiveWriter, bool, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return nil, false, err
}
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
w, ok := t.writers[webhookID]
if ok {
return w, false, nil
}
w = newArchiveWriter(path, t.eng.log)
w.sweepOwned = true
t.writers[targetID] = w
t.writers[webhookID] = w
return w, true, nil
}
@@ -263,95 +209,57 @@ func (t *databaseTarget) sweepWriterFor(
// delivery that adopted the writer keeps a registered, evictable
// one.
func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter,
webhookID string, w *archiveWriter,
) {
t.mu.Lock()
defer t.mu.Unlock()
cur, ok := t.writers[targetID]
cur, ok := t.writers[webhookID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(t.writers, targetID)
delete(t.writers, webhookID)
}
// newWriter builds the writer for a database target's archive. The
// file lives beside the webhook's event database in the data
// directory and is named for the webhook and the target as the main
// database has them now; from then on only rename changes the name
// the writer uses. It does not touch the archive file.
func (t *databaseTarget) newWriter(
targetID string,
) (*archiveWriter, error) {
// archivePath returns the archive file path for a webhook: it
// lives beside the per-webhook event database in the data
// directory. It does not touch the filesystem.
func (t *databaseTarget) archivePath(
webhookID string,
) (string, error) {
if t.eng.dbManager == nil {
return nil, errArchiveNoDataDir
return "", errArchiveNoDataDir
}
var target database.Target
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
err := t.eng.database.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
return filepath.Join(
dir, fmt.Sprintf("archive-%s.db", webhookID),
), nil
}
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
name := ArchiveFileName(
target.Webhook.Name, target.Name, target.ID,
)
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
w.webhookID = target.WebhookID
return w, nil
}
// rename moves a database target's archive file to the name for
// webhookName and targetName. It goes through the target's writer,
// so the move holds the lock that writes and the idle sweep take,
// and later writes use the new name.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict drops a database target's archive writer from the registry
// and closes its handle, so a deleted target does not leave a
// writer (and an open archive handle within its debounce window)
// alive for the process lifetime.
// evict drops a webhook's archive writer from the registry and
// closes its handle, so a deleted webhook does not leave a
// writer (and an open archive handle within its debounce
// window) alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// webhooks while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (t *databaseTarget) evict(targetID string) {
// Eviction is idempotent and silent for a webhook with no
// writer, which is the common case: a webhook with no database
// target never creates one. It never deletes the archive file.
func (t *databaseTarget) evict(webhookID string) {
t.mu.Lock()
w, ok := t.writers[targetID]
w, ok := t.writers[webhookID]
if ok {
delete(t.writers, targetID)
delete(t.writers, webhookID)
}
t.mu.Unlock()
@@ -362,43 +270,15 @@ func (t *databaseTarget) evict(targetID string) {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// evictWebhook evicts, exactly as evict does, the writer of every
// database target of a webhook.
func (t *databaseTarget) evictWebhook(webhookID string) {
t.mu.Lock()
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict
// does for one target. The engine calls it at shutdown, once its
// does for one webhook. The engine calls it at shutdown, once its
// workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
@@ -415,25 +295,38 @@ func (t *databaseTarget) evictAll() {
}
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
// sweepWebhook prunes one webhook's archive of rows older than
// expiry, without requiring a write. It returns nil (nothing to
// do) when the archive file does not exist, so a sweep never
// creates an archive for a webhook that has a database target
// but has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// the prune is done, so a sweep racing a webhook deletion cannot
// resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive(
targetID string, expiry time.Duration,
func (t *databaseTarget) sweepWebhook(
webhookID string, expiry time.Duration,
) error {
w, created, err := t.sweepWriterFor(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return err
}
// Check before taking a writer at all: a webhook whose
// archive has never been created gets no writer, no handle,
// and no file.
if !fileExists(path) {
return nil
}
w, created, err := t.sweepWriterFor(webhookID)
if err != nil {
return err
}
if created {
defer t.releaseSweepWriter(targetID, w)
defer t.releaseSweepWriter(webhookID, w)
}
return w.sweepExpired(expiry)
+17 -97
View File
@@ -4,10 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
@@ -43,7 +41,7 @@ const (
var (
// errArchiveMissingWebhookID is returned when an event to
// archive has no webhook id to record in its archive row.
// archive has no webhook id to key its archive file on.
errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id",
)
@@ -63,19 +61,13 @@ var (
)
// errArchiveWriterEvicted is returned when a writer that has
// been evicted (its target or its webhook was deleted) is used
// again. An evicted writer is no longer in the registry, so
// reopening its file would leak a handle nothing owns.
// been evicted (its webhook was deleted, or its last database
// target was removed) is used again. An evicted writer is no
// longer in the registry, so reopening its file would leak a
// handle nothing owns.
errArchiveWriterEvicted = errors.New(
"archive writer has been evicted",
)
// ErrArchiveNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced.
ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name",
)
)
// databaseTargetConfig is the optional per-target JSON config
@@ -88,7 +80,7 @@ type databaseTargetConfig struct {
}
// archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a
// per-webhook archive database for long-term retention. It is a
// self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention.
type archivedEvent struct {
@@ -178,8 +170,8 @@ func ValidateArchiveExpiry(expiry string) error {
return nil
}
// archiveWriter owns one database target's archive SQLite file.
// It serialises writes, and after each write closes and reopens
// archiveWriter owns one per-webhook archive SQLite file. It
// serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the
@@ -195,21 +187,16 @@ type archiveWriter struct {
reopens int
// evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the
// file again: nothing holds it any more, so a reopen would
// leak the handle for the process lifetime.
// per-webhook registry. Its handle is closed and it must
// never open the file again: nothing holds it any more, so a
// reopen would leak the handle for the process lifetime.
evicted bool
// webhookID is the webhook the archive's target belongs to,
// so deleting the webhook can find its writers. It is set
// when the writer is created and never changes.
webhookID string
// sweepOwned marks a registry entry that the idle sweep
// created because no writer was cached for the target. The
// created because no writer was cached for the webhook. The
// sweep removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry
// for a target that has been deleted. A delivery that adopts
// for a webhook that has been deleted. A delivery that adopts
// the writer clears the flag, handing the entry to the
// registry proper.
//
@@ -398,78 +385,11 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
return nil
}
// rename gives the archive file a new name in the same directory,
// and the writer uses the file under that name from now on. The
// handle is closed first, which folds the -wal into the .db; any
// -wal or -shm still beside the file (left by a crash) is moved with
// it, because SQLite finds them by name. A missing file is not an
// error: the operator may have moved it away, and the next write
// creates it under the new name.
//
// If a file already has the new name, nothing is moved and the
// error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the
// archive is never split across two names.
func (w *archiveWriter) rename(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
path := filepath.Join(filepath.Dir(w.path), name)
if path == w.path {
return nil
}
suffixes := []string{"", "-wal", "-shm"}
for _, suffix := range suffixes {
if fileExists(path + suffix) {
return fmt.Errorf(
"%w: %s", ErrArchiveNameTaken, name+suffix,
)
}
}
w.close()
for i, suffix := range suffixes {
err := os.Rename(w.path+suffix, path+suffix)
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
for _, moved := range suffixes[:i] {
backErr := os.Rename(path+moved, w.path+moved)
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", path+moved,
"to", w.path+moved,
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
)
}
w.path = path
return nil
}
// evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, because its target
// or its webhook was deleted, or at shutdown. The archive FILE is
// deliberately left on disk: it is long-term storage an operator
// may still want.
// called when the writer leaves the registry, either because the
// webhook was deleted or because its last database target was
// removed. The archive FILE is deliberately left on disk: it is
// long-term storage an operator may still want.
func (w *archiveWriter) evict() {
w.mu.Lock()
defer w.mu.Unlock()
+77 -87
View File
@@ -17,109 +17,85 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
// deliverTo archives one event to a database target, leaving the
// target's writer cached with its handle open.
func deliverTo(
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
// evictTestEngine builds an engine backed by a temporary data
// directory and returns it along with that directory.
func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
t.Helper()
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
dataDir := t.TempDir()
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
eng := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
return eng, dataDir
}
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets
// from the registry and closes their open handles, rather than
// leaving them alive for the process lifetime, and leaves another
// webhook's writer alone.
// a webhook drops its archive writer from the registry and
// closes the open archive handle, rather than leaving both
// alive for the process lifetime.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
eng, dataDir := evictTestEngine(t)
for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
eng.ExportDeliverDatabase(webhookDB, d)
webhookID := event.WebhookID
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, eng.ExportHasArchiveWriter(webhookID),
"a delivery should have cached an archive writer",
)
require.True(
t, eng.ExportArchiveHandleOpen(webhookID),
"the writer should hold an open handle after a write",
)
}
env.eng.EvictWebhook(first.WebhookID)
eng.EvictWebhook(webhookID)
for _, tgt := range []*database.Target{first, second} {
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(webhookID),
"eviction should remove the registry entry",
)
assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, eng.ExportArchiveHandleOpen(webhookID),
"eviction should close the archive handle",
)
archivePath := filepath.Join(
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
assert.FileExists(
t, env.archivePath(tgt),
t, archivePath,
"eviction must not delete the archive file",
)
}
assert.True(
t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone",
)
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one
// database target leaves the writer of another target of the same
// webhook in place.
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed)
deliverTo(t, env, kept)
env.eng.EvictTarget(doomed.ID)
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists(
t, env.archivePath(doomed),
"eviction must not delete the archive file",
)
assert.True(
t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone",
)
}
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook or target that never had an
// archive writer, and that repeating it does not panic.
// for the common case of a webhook that never had a database
// target, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
eng, _ := evictTestEngine(t)
assert.NotPanics(t, func() {
env.eng.EvictWebhook("no-such-webhook")
env.eng.EvictWebhook("no-such-webhook")
env.eng.EvictTarget("no-such-target")
env.eng.EvictTarget("no-such-target")
eng.EvictWebhook("no-such-webhook")
eng.EvictWebhook("no-such-webhook")
})
assert.False(
t, env.eng.ExportHasArchiveWriter("no-such-target"),
t, eng.ExportHasArchiveWriter("no-such-webhook"),
"eviction must not create a writer",
)
}
@@ -313,14 +289,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
// Prime the registry so the test can hold the very writer the
// eviction is about to detach.
deliverTo(t, env, tgt)
eng.ExportDeliverDatabase(webhookDB, d)
w := env.eng.ExportArchiveWriterFor(tgt.ID)
w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
@@ -330,7 +309,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex.
race.awaitFirstWrite()
env.eng.EvictWebhook(tgt.WebhookID)
eng.EvictWebhook(event.WebhookID)
sawEvicted, otherErr := race.wait()
@@ -345,33 +324,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"the registry entry must stay gone",
)
}
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
// does not break archiving for a target that is still alive: a
// does not break archiving for a webhook that is still alive: a
// subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
deliverTo(t, env, tgt)
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
env.eng.EvictWebhook(tgt.WebhookID)
eng.ExportDeliverDatabase(webhookDB, d)
require.True(
t, eng.ExportHasArchiveWriter(event.WebhookID),
)
// A fresh delivery for the same target gets a brand new
eng.EvictWebhook(event.WebhookID)
// A fresh delivery for the same webhook gets a brand new
// writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt)
second := seedDatabaseTargetDelivery(
t, webhookDB, event, "",
)
eng.ExportDeliverDatabase(webhookDB, second)
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"a later delivery should recreate the writer",
)
}
@@ -383,16 +370,19 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
deliverTo(t, env, tgt)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
w := env.eng.ExportArchiveWriterFor(tgt.ID)
eng.ExportDeliverDatabase(webhookDB, d)
w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
require.NoError(t, env.eng.ExportStop(context.Background()))
require.NoError(t, eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0)
@@ -405,7 +395,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
"a refused write must not reopen the archive",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"the stop should empty the registry",
)
+40 -307
View File
@@ -4,12 +4,13 @@ import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -73,18 +74,25 @@ func removeArchiveFiles(t *testing.T, path string) {
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
// a database target marks the delivery delivered and archives
// the full event into the target's own archive file.
// the full event into a separate per-webhook archive file.
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
dataDir := t.TempDir()
dbMgr := database.NewTestWebhookDBManager(dataDir)
e := delivery.NewTestEngineWithDB(
nil, dbMgr,
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
env.eng.ExportDeliverDatabase(webhookDB, d)
e.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery
@@ -97,7 +105,8 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
)
archivePath := filepath.Join(
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
)
assert.FileExists(t, archivePath)
@@ -279,31 +288,31 @@ func TestParseArchiveExpiry(t *testing.T) {
}
}
// seedDatabaseTargetDelivery seeds a pending delivery of an event
// to a database target and returns the in-memory delivery the
// target handler is invoked with.
// seedDatabaseTargetDelivery seeds a pending delivery for a
// database target with the given config JSON and returns the
// in-memory delivery the target handler is invoked with.
func seedDatabaseTargetDelivery(
t *testing.T,
webhookDB *gorm.DB,
event database.Event,
tgt *database.Target,
config string,
) *database.Delivery {
t.Helper()
dlv := seedDelivery(
t, webhookDB, event.ID, tgt.ID,
t, webhookDB, event.ID, uuid.New().String(),
database.DeliveryStatusPending,
)
d := &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: tgt.Name,
Name: "test-db",
Type: database.TargetTypeDatabase,
Config: tgt.Config,
Config: config,
},
}
d.ID = dlv.ID
@@ -321,14 +330,22 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
dataDir := t.TempDir()
e := delivery.NewTestEngineWithDB(
nil, database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":false}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"nonsense"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
e.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery
@@ -356,7 +373,10 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
)
assert.NoFileExists(t,
env.archivePath(tgt),
filepath.Join(
dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
),
"no archive file should exist for a failed config",
)
}
@@ -380,290 +400,3 @@ func TestValidateArchiveExpiry(t *testing.T) {
)
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
delivery.ArchiveFileName(tc.webhook, tc.target, id),
)
})
}
}
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
require.FileExists(t, oldPath)
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
+2 -7
View File
@@ -442,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
)
}
originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(),
)
originScoped := applyRequestHeaders(req, event, cfg)
client := t.clientForRequest(cfg, originScoped)
@@ -564,13 +562,10 @@ func isForwardableHeader(name string) bool {
// Content-Type goes out once: a Content-Type configured on the target
// wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded.
//
// userAgent is set last, over any configured or inbound User-Agent.
func applyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
userAgent string,
) []string {
if event.ContentType != "" {
req.Header.Set(
@@ -585,7 +580,7 @@ func applyRequestHeaders(
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("User-Agent", "webhooker/1.0")
// A Content-Type configured on the target describes the body
// being sent rather than the sender. A 307/308 preserves the
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", t.eng.userAgent())
req.Header.Set("User-Agent", "webhooker/1.0")
resp, doErr := executeHTTPRequest(t.client, req)
durationMs := time.Since(start).Milliseconds()
-32
View File
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
assert.Zero(t, s.Engine.ExportInflightHeld())
}
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
// that the failure of a retry abandoned because its target is gone is
// added to that target's own totals, not to a row with no target.
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
var hits atomic.Int64
task, targetID := tRetryChainSetup(
t, s, "gone-counted", &hits,
)
require.NoError(t, s.MainDB.Delete(
&database.Target{}, "id = ?", targetID,
).Error)
s.Engine.ExportProcessRetryTask(
context.Background(), &task,
)
var rows []database.TargetTotals
require.NoError(t, s.WebhookDB.Find(&rows).Error)
assert.Equal(t, []database.TargetTotals{
{TargetID: targetID, Failed: 1},
}, rows)
}
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
// mutation check: a liveness check that refused every retry would pass
// the test above and break every retry there is.
-91
View File
@@ -1,91 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Both the http and the slack target send webhooker/ and the version
// in Globals, the value the web UI footer shows. A User-Agent
// configured on the target or carried in by the sender does not
// replace it.
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
t.Parallel()
const want = "webhooker/1.2.3-test"
userAgents := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
userAgents <- r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
g := &globals.Globals{Version: "1.2.3-test"}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
e := delivery.New(lc, delivery.EngineParams{
Globals: g,
Logger: log,
// httptest listens on loopback, which the default guard
// refuses.
SSRFGuard: delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
),
Metrics: metrics.New(prometheus.NewRegistry()),
})
statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(),
&delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"User-Agent": "configured/1"},
},
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
require.Len(t, userAgents, 1, "the http target sent no request")
assert.Equal(t, want, <-userAgents, "http target")
db := testWebhookDB(t)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending,
)
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", string(slackCfg),
))
require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target")
}
-4
View File
@@ -137,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
+1 -1
View File
@@ -7,7 +7,7 @@
// SQL — parameters and all — for every statement that returns an
// error, including gorm.ErrRecordNotFound. Two of this service's
// lookups miss by design on unauthenticated routes: the entrypoint
// lookup on /h/{uuid}, whose path segment the client picks
// lookup on /webhook/{uuid}, whose path segment the client picks
// outright, and the user lookup behind the login form, whose username
// the client picks outright. Under the default logger each of those
// misses printed an unbounded, attacker-chosen string, at no level the
+31 -62
View File
@@ -2,56 +2,19 @@ package handlers
import (
"net/http"
"net/url"
"strconv"
"strings"
"unicode"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/middleware"
)
// loginDestination returns where a successful login sends the
// browser: next when it is a path on this site, otherwise "/", which
// leads to the webhook list.
//
// A browser reads "//host" as another site, reads "\" as "/", and
// drops tabs and newlines before reading at all. So the value must
// start with exactly one "/" and hold no "\" or control character
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
// is checked after percent-decoding, so an encoded form of any of
// these is refused too.
func loginDestination(next string) string {
if len(next) > middleware.MaxNextBytes {
return "/"
}
decoded, err := url.PathUnescape(next)
if err != nil ||
!strings.HasPrefix(decoded, "/") ||
strings.HasPrefix(decoded, "//") ||
strings.Contains(decoded, `\`) ||
strings.ContainsFunc(decoded, unicode.IsControl) {
return "/"
}
return next
}
// HandleLoginPage returns a handler for the login page (GET)
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
next := loginDestination(
r.URL.Query().Get(middleware.NextParam),
)
// Check if already logged in
sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect( //nolint:gosec // checked by loginDestination
w, r, next, http.StatusSeeOther,
)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
@@ -59,7 +22,6 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
// Render login page
data := map[string]any{
tmplKeyError: "",
tmplKeyNext: next,
}
h.renderTemplate(w, r, "login.html", data)
@@ -74,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
@@ -115,13 +77,8 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"user_id", user.ID,
)
// The form value is the client's to set, so it is checked
// again here rather than trusted from the rendered page.
http.Redirect( //nolint:gosec // checked by loginDestination
w, r,
loginDestination(r.PostFormValue(middleware.NextParam)),
http.StatusSeeOther,
)
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
@@ -134,9 +91,6 @@ func (h *Handlers) renderLoginError(
) {
data := map[string]any{
tmplKeyError: msg,
tmplKeyNext: loginDestination(
r.PostFormValue(middleware.NextParam),
),
}
w.WriteHeader(status)
@@ -149,10 +103,9 @@ func (h *Handlers) renderLoginError(
// The credential check runs BEFORE any rate-limit budget is
// consulted, and only a failed check spends budget. That is what
// keeps the single administrative path reachable: behind the reverse
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
// it, every client shares one bucket, so a limiter spent on arrival
// lets any stranger deny the operator's own correct password
// indefinitely.
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
// client shares one bucket, so a limiter spent on arrival lets any
// stranger deny the operator's own correct password indefinitely.
//
// Verifying first means every login POST costs an Argon2id hash, so
// the work is taken under a bounded number of verification slots.
@@ -212,7 +165,11 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.serverError(w, r, "failed to verify password", err)
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err
}
@@ -284,14 +241,24 @@ func (h *Handlers) createAuthenticatedSession(
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.serverError(w, r, "failed to get session", err)
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.serverError(w, r, "failed to regenerate session", err)
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
@@ -300,7 +267,11 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess)
if err != nil {
h.serverError(w, r, "failed to save session", err)
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
@@ -333,9 +304,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
)
}
http.Redirect(
w, r, withNotice("/pages/login", signedOut),
http.StatusSeeOther,
)
// Redirect to login page
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
}
}
+6 -232
View File
@@ -25,7 +25,7 @@ const (
// sharedProxyPeer is the whole point of this file. Production is
// required to run behind a TLS-terminating reverse proxy, and
// when TRUSTED_PROXIES does not cover it every client — attacker
// TRUSTED_PROXIES defaults to empty, so every client — attacker
// and operator alike — reaches the process from the proxy's
// address and shares one rate-limit bucket. Both parties in
// these tests therefore use the same RemoteAddr.
@@ -115,11 +115,11 @@ func floodFailures(
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
//
// The attacker and the operator share one rate-limit bucket, because
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
// cover it, every client keys on the proxy's address. The attacker
// floods the operator's own username — a single-admin product has a
// predictable one — far past the failure limit. The operator must
// still be able to log in with the correct password.
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
// client keys on the proxy's address. The attacker floods the
// operator's own username — a single-admin product has a predictable
// one — far past the failure limit. The operator must still be able
// to log in with the correct password.
//
// This fails if credentials stop being verified ahead of the limiter.
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
@@ -453,229 +453,3 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session",
)
}
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
// returns to is client-chosen, so anything that is not a path on this
// site, plain or percent-encoded, must land on "/", the webhook list.
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
cases := []struct{ next, want string }{
{"/hook/abc/events?page=2", "/hook/abc/events?page=2"},
{"", "/"},
{"https://evil.example/", "/"},
{"https%3A%2F%2Fevil.example%2F", "/"},
{"//evil.example/", "/"},
{"%2F%2Fevil.example/", "/"},
{"/%2Fevil.example/", "/"},
{`/\evil.example/`, "/"},
{"%2F%5Cevil.example/", "/"},
{"/%5Cevil.example/", "/"},
{`/a/../\evil.example/`, "/"},
{"/\t/evil.example/", "/"},
{"/%09/evil.example/", "/"},
{"/\n/evil.example/", "/"},
{"/%0A/evil.example/", "/"},
{"/\r/evil.example/", "/"},
{"/%0D/evil.example/", "/"},
{"/%00/evil.example/", "/"},
{"/%7F/evil.example/", "/"},
{"%252F%252Fevil.example/", "/"},
{"https%253A%252F%252Fevil.example%252F", "/"},
{"/" + strings.Repeat("a", 4096), "/"},
}
for _, c := range cases {
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", operatorPassword)
form.Set("next", c.next)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong
// password the login page is shown again with the same next, so the
// next attempt still returns to the page that was asked for.
func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", "wrong")
form.Set("next", "/hook/abc")
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(
t, w.Body.String(), `name="next" value="/hook/abc"`,
)
}
// loginPageGet renders the login page as a GET with the given next
// value and cookies.
func loginPageGet(
h *handlers.Handlers, next string, cookies []*http.Cookie,
) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet,
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleLoginPage().ServeHTTP(w, req)
return w
}
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
// itself: its form carries the requested page only when it is a path
// on this site, and a browser already logged in goes straight there,
// or to "/" when it is not.
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
)
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
assert.Contains(
t, loginPageGet(h, "/hook/abc", nil).Body.String(),
`name="next" value="/hook/abc"`,
)
assert.Contains(
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
`name="next" value="/"`,
)
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
cases := []struct{ next, want string }{
{"/hook/abc", "/hook/abc"},
{"//evil.example/", "/"},
{`/\evil.example/`, "/"},
}
for _, c := range cases {
w := loginPageGet(h, c.next, cookies)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
// page offers no link to the login page.
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := loginPageGet(h, "", nil)
require.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500.
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
username := strings.Repeat("a", database.MaxUsernameBytes)
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: username,
Password: hash,
}).Error)
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
assert.Equal(t, http.StatusSeeOther, w.Code)
}
+67 -39
View File
@@ -11,37 +11,72 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
// The outcomes of a replay POST, as the notice codes its redirect
// carries. noticeFor holds the line each one shows.
// replayOutcomeParam is the query parameter the replay POST redirects
// with and the event log page reads its banner from.
const replayOutcomeParam = "replay"
// replayOutcomeCode is the outcome of a replay POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type replayOutcomeCode string
const (
// replayQueued reports that a new delivery was created and handed
// to the delivery engine.
replayQueued noticeCode = "replay-queued"
replayQueued replayOutcomeCode = "queued"
// replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event.
replayTargetDeleted noticeCode = "replay-target-deleted"
replayTargetDeleted replayOutcomeCode = "target-deleted"
// replayTargetMissing reports a target id that names no row at
// all, deleted or otherwise.
replayTargetMissing noticeCode = "replay-target-missing"
replayTargetMissing replayOutcomeCode = "target-missing"
// replayTargetInactive reports a target the operator has
// deactivated. A deactivated target receives no new deliveries, so
// a replay to it would be a delivery they switched off.
replayTargetInactive noticeCode = "replay-target-inactive"
replayTargetInactive replayOutcomeCode = "target-inactive"
// replayNotTerminal reports a delivery the engine has not finished
// with.
replayNotTerminal noticeCode = "replay-not-terminal"
replayNotTerminal replayOutcomeCode = "not-terminal"
// replayInFlight reports that an earlier replay of this event to
// this target is still running.
replayInFlight noticeCode = "replay-in-flight"
replayInFlight replayOutcomeCode = "in-flight"
)
// replayOutcome returns the banner the event log page shows for an
// outcome code, and whether the replay was queued. An unrecognised
// code yields no banner.
func replayOutcome(code string) (string, bool) {
switch replayOutcomeCode(code) {
case replayQueued:
return "Replay queued: a new delivery was created against " +
"the target's current configuration.", true
case replayTargetDeleted:
return "Not replayed: the target this delivery was for has " +
"been deleted. Recreate the target, then replay.", false
case replayTargetMissing:
return "Not replayed: the target this delivery was for no " +
"longer exists.", false
case replayTargetInactive:
return "Not replayed: the target this delivery was for is " +
"deactivated. Activate it, then replay.", false
case replayNotTerminal:
return "Not replayed: this delivery has not finished yet.",
false
case replayInFlight:
return "Not replayed: a delivery of this event to this " +
"target is already in flight.", false
default:
return "", false
}
}
// HandleDeliveryReplay re-sends a finished delivery's event to its
// target.
//
@@ -70,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -87,14 +124,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook,
) {
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
h.serverError(w, "failed to get webhook database", err)
return
}
@@ -105,14 +142,14 @@ func (h *Handlers) replayDelivery(
}
if !original.Status.Terminal() {
redirectToEventLog(w, r, webhook, replayNotTerminal)
h.finishReplay(w, r, webhook, replayNotTerminal)
return
}
target, code := h.replayTarget(webhook.ID, original.TargetID)
if target == nil {
redirectToEventLog(w, r, webhook, code)
h.finishReplay(w, r, webhook, code)
return
}
@@ -136,7 +173,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return nil, false
}
@@ -158,14 +195,14 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, r, "failed to count in-flight deliveries", err,
w, "failed to count in-flight deliveries", err,
)
return
}
if inFlight > 0 {
redirectToEventLog(w, r, webhook, replayInFlight)
h.finishReplay(w, r, webhook, replayInFlight)
return
}
@@ -175,7 +212,7 @@ func (h *Handlers) queueReplay(
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, r, "failed to load event for replay", err)
h.serverError(w, "failed to load event for replay", err)
return
}
@@ -185,7 +222,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, r, "failed to create replay delivery", err,
w, "failed to create replay delivery", err,
)
return
@@ -203,7 +240,7 @@ func (h *Handlers) queueReplay(
"delivery_id", task.DeliveryID,
)
redirectToEventLog(w, r, webhook, replayQueued)
h.finishReplay(w, r, webhook, replayQueued)
}
// replayTarget loads the delivery's target as it stands now.
@@ -216,7 +253,7 @@ func (h *Handlers) queueReplay(
// with the returned code saying why.
func (h *Handlers) replayTarget(
webhookID, targetID string,
) (*database.Target, noticeCode) {
) (*database.Target, replayOutcomeCode) {
var target database.Target
err := h.db.DB().Unscoped().Where(
@@ -262,9 +299,8 @@ func countInFlightDeliveries(
return count, err
}
// createReplayDelivery writes the new pending delivery row, adds it to
// its target's totals in the same transaction, and returns the task
// that carries it to the delivery engine.
// createReplayDelivery writes the new pending delivery row and returns
// the task that carries it to the delivery engine.
//
// The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would
@@ -283,16 +319,7 @@ func createReplayDelivery(
Status: database.DeliveryStatusPending,
}
err := webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, database.TargetTotals{
TargetID: dlv.TargetID, Deliveries: 1,
})
})
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return delivery.Task{}, err
}
@@ -326,16 +353,17 @@ func replayBody(body string) *string {
return &body
}
// redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice and
// the page number the form submitted.
func redirectToEventLog(
// finishReplay redirects back to the event log the replay was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishReplay(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code noticeCode,
code replayOutcomeCode,
) {
dest := withNotice("/hook/"+webhook.ID+"/events", code)
dest := "/source/" + webhook.ID + "/logs?" +
replayOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
+10 -10
View File
@@ -138,7 +138,7 @@ func postReplay(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/deliveries/"+
"/source/"+webhookID+"/deliveries/"+
deliveryID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-queued",
"/source/"+wh.ID+"/logs?replay=queued",
w.Header().Get("Location"),
)
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
"/source/"+wh.ID+"/logs?replay=target-deleted",
w.Header().Get("Location"),
)
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
"/source/"+wh.ID+"/logs?replay=target-missing",
missing.Header().Get("Location"),
)
}
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-queued",
"/source/"+wh.ID+"/logs?replay=queued",
first.Header().Get("Location"),
)
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
"/source/"+wh.ID+"/logs?replay=in-flight",
second.Header().Get("Location"),
)
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
"/source/"+wh.ID+"/logs?replay=not-terminal",
pending.Header().Get("Location"),
)
}
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(
t, body,
`action="/hook/`+wh.ID+`/deliveries/`+
`action="/source/`+wh.ID+`/deliveries/`+
original.ID+`/replay"`,
)
assert.Contains(t, body, `method="POST"`)
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(t, body, ">Replay<")
refused := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?notice=replay-target-deleted",
t, h, sess, wh.ID, "?replay=target-deleted",
)
assert.Contains(t, refused, "alert-error")
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
// An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?notice=made-up",
t, h, sess, wh.ID, "?replay=made-up",
)
assert.NotContains(t, unknown, "alert-error")
-53
View File
@@ -1,53 +0,0 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string,
) {
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
h.serverError(w, "failed to get webhook database", err)
return
}
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil {
h.serverError(w, r, "failed to read event body", err)
h.serverError(w, "failed to read event body", err)
return
}
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the
// response is a clean 404.
if !found {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
+4 -4
View File
@@ -64,8 +64,8 @@ func fetchEventBody(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+url.PathEscape(sourceID)+
"/events/"+url.PathEscape(eventID)+"/body",
"/source/"+url.PathEscape(sourceID)+
"/logs/"+url.PathEscape(eventID)+"/body",
nil,
)
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page := renderSourceLogsPage(t, h, sess, big.ID)
assert.Contains(
t, page,
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
)
small := seedWebhook(t, db)
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page = renderSourceLogsPage(t, h, sess, small.ID)
assert.NotContains(
t, page,
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
)
}
+62 -13
View File
@@ -3,6 +3,7 @@ package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/google/uuid"
@@ -10,19 +11,43 @@ import (
"sneak.berlin/go/webhooker/internal/database"
)
// The outcomes of a resubmit POST, as the notice codes its redirect
// carries. noticeFor holds the line each one shows.
// resubmitOutcomeParam is the query parameter the resubmit POST
// redirects with and the event log page reads its banner from.
const resubmitOutcomeParam = "resubmit"
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type resubmitOutcomeCode string
const (
// resubmitQueued reports that a new event was stored and its
// deliveries handed to the delivery engine.
resubmitQueued noticeCode = "resubmit-queued"
resubmitQueued resubmitOutcomeCode = "queued"
// resubmitNoTargets reports a source with no active targets. The
// new event is stored either way, exactly as a received event
// with no targets is.
resubmitNoTargets noticeCode = "resubmit-no-targets"
resubmitNoTargets resubmitOutcomeCode = "no-targets"
)
// resubmitOutcome returns the banner the event log page shows for an
// outcome code, and whether the resubmit was queued. An unrecognised
// code yields no banner.
func resubmitOutcome(code string) (string, bool) {
switch resubmitOutcomeCode(code) {
case resubmitQueued:
return "Resubmitted: a new event was created from the stored " +
"one and queued to every active target.", true
case resubmitNoTargets:
return "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
true
default:
return "", false
}
}
// resubmitSource is the stored event a resubmit copies. Its body is
// read as bytes rather than as a string so the copy is byte-identical
// to what was received, whatever the payload's encoding.
@@ -74,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
@@ -95,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
h.serverError(w, "failed to get webhook database", err)
return
}
@@ -122,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(),
)
if err != nil {
h.serverError(w, r, "failed to load event to resubmit", err)
h.serverError(w, "failed to load event to resubmit", err)
return
}
@@ -130,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed.
if !found {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -182,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to query targets", err)
h.serverError(w, "failed to query targets", err)
return
}
@@ -200,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets,
)
if err != nil {
h.serverError(w, r, "failed to store resubmitted event", err)
h.serverError(w, "failed to store resubmitted event", err)
return
}
@@ -220,5 +245,29 @@ func (h *Handlers) queueResubmit(
code = resubmitNoTargets
}
redirectToEventLog(w, r, webhook, code)
h.finishResubmit(w, r, webhook, code)
}
// finishResubmit redirects back to the event log the resubmit was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/source/" + webhook.ID + "/logs?" +
resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
// headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
+6 -7
View File
@@ -65,7 +65,7 @@ func postResubmit(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
)
@@ -204,7 +204,6 @@ func assertEventCopy(
assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
assert.Equal(t, original.ContentType, fresh.ContentType)
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
assert.Equal(t, original.WebhookID, fresh.WebhookID)
@@ -282,7 +281,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+
"one is in flight",
@@ -436,7 +435,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
"an inactive target is skipped, not an error",
)
@@ -482,7 +481,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
"/source/"+wh.ID+"/logs?resubmit=no-targets",
w.Header().Get("Location"),
)
@@ -598,7 +597,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
)
assert.Contains(
t, body,
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
"the log must offer the resubmit action per event",
)
}
+2 -37
View File
@@ -1,14 +1,10 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"net/http/httptest"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -67,43 +63,12 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, page,
w, webhook, nil, page,
)
return views
}
// WebhookStatsForTest returns the figures the statistics pane on a
// webhook's page shows, from the webhook's entrypoints and targets
// loaded as that page loads them.
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
var entrypoints []database.Entrypoint
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
var targets []database.Target
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
return s.loadWebhookStats(webhookID, entrypoints, targets)
}
// FinishedByTargetForTest exposes finishedByTarget for use in the
// handlers_test package.
func FinishedByTargetForTest(
webhookDB *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
return finishedByTarget(webhookDB, since)
}
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a
// template of its own.
@@ -157,5 +122,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
return s.buildDatabaseTargetConfig(w, expiry)
}
+1 -1
View File
@@ -306,7 +306,7 @@ func postWebhook(
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x",
context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader("{}"),
)
+33 -122
View File
@@ -12,7 +12,6 @@ import (
"net/http"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
@@ -29,7 +28,7 @@ const (
// maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20
// recentEventLimit is the number of recent events to show.
recentEventLimit = 50
recentEventLimit = 20
// paginationPerPage is the number of items per page.
paginationPerPage = 25
@@ -37,9 +36,6 @@ const (
tmplKeyError = "Error"
// tmplKeyWebhook is the template data key for a webhook.
tmplKeyWebhook = "Webhook"
// tmplKeyNext is the template data key for the page to return
// to after login.
tmplKeyNext = "Next"
)
// errInvalidPassword is returned when a password does not match.
@@ -63,10 +59,8 @@ type HandlersParams struct {
Session *session.Session
Middleware *middleware.Middleware
Notifier delivery.Notifier
Archives delivery.Archives
Evictor delivery.WebhookEvictor
SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
}
// Handlers provides HTTP handler methods for all application
@@ -80,7 +74,7 @@ type Handlers struct {
session *session.Session
mw *middleware.Middleware
notifier delivery.Notifier
archives delivery.Archives
evictor delivery.WebhookEvictor
mtr *metrics.Set
templates map[string]*template.Template
@@ -97,23 +91,18 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared
// base, htmlheader, navbar and notice templates, and with any further
// files the page includes. The page file must be listed first so that
// its root action ({{template "base" .}}) becomes the template set's
// entry point.
func parsePageTemplate(
pageFile string, included ...string,
) *template.Template {
files := append([]string{
// base, htmlheader, and navbar templates. The page file must be
// listed first so that its root action ({{template "base" .}})
// becomes the template set's entry point.
func parsePageTemplate(pageFile string) *template.Template {
return template.Must(
template.ParseFS(
templates.Templates,
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
"notice.html",
}, included...)
return template.Must(
template.ParseFS(templates.Templates, files...),
),
)
}
@@ -132,8 +121,8 @@ func New(
s.session = params.Session
s.mw = params.Middleware
s.notifier = params.Notifier
s.archives = params.Archives
s.mtr = params.Metrics
s.evictor = params.Evictor
s.mtr = metrics.Default()
s.ssrf = params.SSRFGuard
// Parse all page templates once at startup
@@ -142,11 +131,10 @@ func New(
"profile.html": parsePageTemplate("profile.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
"source_detail.html": parsePageTemplate("source_detail.html"),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -158,16 +146,6 @@ func New(
return s, nil
}
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths, the
// CSRF middleware for a refused form, and each admin page route
// group's recoverer for a panic.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
@@ -185,78 +163,15 @@ func (s *Handlers) respondJSON(
}
}
// serverError logs an error and answers with the 500 error page.
// serverError logs an error and sends a 500 response.
func (s *Handlers) serverError(
w http.ResponseWriter, r *http.Request, msg string, err error,
w http.ResponseWriter, msg string, err error,
) {
s.log.Error(msg, "error", err)
s.renderError(w, r, http.StatusInternalServerError)
}
// renderError answers with status and the error page: the normal
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
// No notice: one would say an action worked above a page saying
// the request failed.
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
}, nil)
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// UserInfo represents user information for templates
@@ -270,7 +185,6 @@ type templateDataWrapper struct {
User *UserInfo
CSRFToken string
Version string
Notice *notice
Data any
}
@@ -310,20 +224,14 @@ func (s *Handlers) renderTemplate(
"template not found",
"template", pageTemplate,
)
s.renderError(w, r, http.StatusInternalServerError)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
}
// pageData adds the fields the shared layout renders to a page's own
// data. The layout shows the notice, when there is one, above the
// page.
func (s *Handlers) pageData(
r *http.Request, data any, pageNotice *notice,
) any {
userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r)
@@ -337,18 +245,19 @@ func (s *Handlers) pageData(
m["User"] = userInfo
m["CSRFToken"] = csrfToken
m["Version"] = version
m["Notice"] = pageNotice
s.executeTemplate(w, tmpl, m)
return m
return
}
return templateDataWrapper{
wrapper := templateDataWrapper{
User: userInfo,
CSRFToken: csrfToken,
Version: version,
Notice: pageNotice,
Data: data,
}
s.executeTemplate(w, tmpl, wrapper)
}
// executeTemplate renders the template into a buffer and writes to
@@ -361,7 +270,6 @@ func (s *Handlers) pageData(
// this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
) {
@@ -372,7 +280,10 @@ func (s *Handlers) executeTemplate(
s.log.Error(
"failed to execute template", "error", err,
)
s.renderError(w, r, http.StatusInternalServerError)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
+12 -100
View File
@@ -3,7 +3,6 @@ package handlers_test
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
@@ -21,7 +20,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
)
@@ -53,77 +51,23 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
return out
}
// recordingArchives is a delivery.Archives that records what it
// was asked to do, so a test can prove that a deletion or rename
// path reached the delivery engine. After FailRenames, every
// rename of that target fails with the given error.
type recordingArchives struct {
// recordingEvictor is a delivery.WebhookEvictor that records
// the webhook ids it was asked to evict, so a test can prove
// that a deletion path reached the delivery engine.
type recordingEvictor struct {
mu sync.Mutex
evicted []string
evictedTargets []string
renames []archiveRename
renameErrs map[string]error
}
// errInjectedRename is the failure a test hands FailRenames.
var errInjectedRename = errors.New("injected rename failure")
// errNameTaken is what the delivery engine returns when a file
// already has an archive's new name, here archive-taken.db.
var errNameTaken = fmt.Errorf(
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
)
// archiveRename is one recorded Rename call.
type archiveRename struct {
TargetID string
WebhookName string
TargetName string
}
func (r *recordingArchives) EvictWebhook(webhookID string) {
func (r *recordingEvictor) EvictWebhook(webhookID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.evicted = append(r.evicted, webhookID)
}
func (r *recordingArchives) EvictTarget(targetID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.evictedTargets = append(r.evictedTargets, targetID)
}
func (r *recordingArchives) Rename(
targetID, webhookName, targetName string,
) error {
r.mu.Lock()
defer r.mu.Unlock()
r.renames = append(r.renames, archiveRename{
TargetID: targetID,
WebhookName: webhookName,
TargetName: targetName,
})
return r.renameErrs[targetID]
}
// FailRenames makes every later rename of targetID fail with err.
func (r *recordingArchives) FailRenames(targetID string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.renameErrs == nil {
r.renameErrs = map[string]error{}
}
r.renameErrs[targetID] = err
}
// Evicted returns a copy of the recorded webhook ids.
func (r *recordingArchives) Evicted() []string {
func (r *recordingEvictor) Evicted() []string {
r.mu.Lock()
defer r.mu.Unlock()
@@ -133,28 +77,6 @@ func (r *recordingArchives) Evicted() []string {
return out
}
// EvictedTargets returns a copy of the recorded target ids.
func (r *recordingArchives) EvictedTargets() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.evictedTargets))
copy(out, r.evictedTargets)
return out
}
// Renames returns a copy of the recorded renames.
func (r *recordingArchives) Renames() []archiveRename {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]archiveRename, len(r.renames))
copy(out, r.renames)
return out
}
func newTestApp(
t *testing.T,
targets ...any,
@@ -163,10 +85,6 @@ func newTestApp(
return fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -185,14 +103,12 @@ func newTestApp(
func(n *recordingNotifier) delivery.Notifier {
return n
},
func() *recordingArchives {
return &recordingArchives{}
func() *recordingEvictor {
return &recordingEvictor{}
},
func(r *recordingArchives) delivery.Archives {
func(r *recordingEvictor) delivery.WebhookEvictor {
return r
},
metrics.NewRegistry,
metrics.New,
middleware.New,
delivery.NewGuard,
handlers.New,
@@ -260,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
assert.Equal(t, http.StatusSeeOther, w2.Code)
assert.Equal(
t, "/hooks", w2.Header().Get("Location"),
t, "/sources", w2.Header().Get("Location"),
)
}
@@ -391,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500",
)
assert.NotContains(
t, w.Body.String(), partialPageMarker,
assert.Equal(
t, "Internal server error\n", w.Body.String(),
"the response must carry no part of the aborted page",
)
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+2 -2
View File
@@ -5,13 +5,13 @@ import (
)
// HandleIndex returns a handler for the root path that redirects
// based on authentication state: authenticated users go to /hooks
// based on authentication state: authenticated users go to /sources
// (the dashboard), unauthenticated users go to the login page.
func (s *Handlers) HandleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
return
}
+10 -8
View File
@@ -4,7 +4,7 @@ package handlers_test
// this package reach a value an UNAUTHENTICATED client picks outright
// and of a length it picks outright:
//
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
// path segment matched no stored entrypoint and so is bounded by
// nothing;
// - the failed-login DEBUG lines, whose username is a form field.
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
// route pattern.
func receiverRouter(h *handlers.Handlers) *chi.Mux {
router := chi.NewRouter()
router.Post("/h/{uuid}", h.HandleWebhook())
router.Post("/webhook/{uuid}", h.HandleWebhook())
return router
}
// postReceiver sends one POST at /h/<segment>.
// postReceiver sends one POST at /webhook/<segment>.
//
// RawPath is cleared after parsing so chi routes on the decoded path
// and the handler sees the raw bytes rather than their percent-escaped
@@ -210,7 +210,7 @@ func postReceiver(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/h/"+url.PathEscape(segment),
"/webhook/"+url.PathEscape(segment),
strings.NewReader(""),
)
req.URL.RawPath = ""
@@ -339,9 +339,11 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the
// whole username, markers and fill name included, stays within
// database.MaxUsernameBytes.
const storedFillBytes = 960
// session cookie a successful login writes stays inside
// securecookie's 4 KB limit: the cookie is written BEFORE the
// "user logged in" line, so an 8 KB username answers 500 and never
// reaches it.
const storedFillBytes = 1024
// storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end.
@@ -507,7 +509,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
http.StatusServiceUnavailable,
postLoginAtPath(
t, h,
"/hook/"+url.PathEscape(
"/source/"+url.PathEscape(
oversizedFill(fill),
)+"/login",
),
-21
View File
@@ -1,21 +0,0 @@
package handlers
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// HandleMetrics returns the Prometheus scrape handler for the
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
// runtime and process collectors register on. It is what
// promhttp.Handler builds for the global default registry, including
// the promhttp_metric_handler_* series that count scrapes, pointed at
// that registry instead.
func (s *Handlers) HandleMetrics() http.HandlerFunc {
reg := s.params.Registry
return promhttp.InstrumentMetricHandler(
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
).ServeHTTP
}
-109
View File
@@ -1,109 +0,0 @@
package handlers
import "net/http"
// noticeParam is the query parameter an action's redirect carries its
// notice code in.
const noticeParam = "notice"
// noticeCode names one of the fixed lines noticeFor knows. An action
// redirects with the code rather than the line, so nothing a client
// puts in the URL reaches the page: a code noticeFor does not know
// shows nothing.
type noticeCode string
// The codes of the actions on the webhook pages and of signing out.
// Replay's codes, with the reasons a replay can be refused, and
// resubmit's codes are defined beside those actions.
const (
webhookCreated noticeCode = "webhook-created"
webhookSaved noticeCode = "webhook-saved"
webhookDeleted noticeCode = "webhook-deleted"
entrypointAdded noticeCode = "entrypoint-added"
entrypointDeleted noticeCode = "entrypoint-deleted"
entrypointActivated noticeCode = "entrypoint-activated"
entrypointDeactivated noticeCode = "entrypoint-deactivated"
targetAdded noticeCode = "target-added"
targetSaved noticeCode = "target-saved"
targetDeleted noticeCode = "target-deleted"
targetActivated noticeCode = "target-activated"
targetDeactivated noticeCode = "target-deactivated"
signedOut noticeCode = "signed-out"
)
// notice is the line templates/notice.html shows above a page to say
// what an action did.
type notice struct {
Text string
// Failed shows the line as an error: the action was refused.
Failed bool
}
// noticeFor returns the notice the request's URL names, or nil when it
// names none or an unknown code.
func noticeFor(r *http.Request) *notice {
n, ok := map[noticeCode]notice{
webhookCreated: {Text: "Webhook created."},
webhookSaved: {Text: "Webhook saved."},
webhookDeleted: {Text: "Webhook deleted."},
entrypointAdded: {Text: "Entrypoint added."},
entrypointDeleted: {Text: "Entrypoint deleted."},
entrypointActivated: {Text: "Entrypoint activated."},
entrypointDeactivated: {Text: "Entrypoint deactivated."},
targetAdded: {Text: "Target added."},
targetSaved: {Text: "Target saved."},
targetDeleted: {Text: "Target deleted."},
targetActivated: {Text: "Target activated."},
targetDeactivated: {Text: "Target deactivated."},
signedOut: {Text: "Signed out."},
replayQueued: {
Text: "Replay queued: a new delivery was created " +
"against the target's current configuration.",
},
replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " +
"has been deleted. Recreate the target, then replay.",
Failed: true,
},
replayTargetMissing: {
Text: "Not replayed: the target this delivery was for " +
"no longer exists.",
Failed: true,
},
replayTargetInactive: {
Text: "Not replayed: the target this delivery was for " +
"is deactivated. Activate it, then replay.",
Failed: true,
},
replayNotTerminal: {
Text: "Not replayed: this delivery has not finished yet.",
Failed: true,
},
replayInFlight: {
Text: "Not replayed: a delivery of this event to this " +
"target is already in flight.",
Failed: true,
},
resubmitQueued: {
Text: "Resubmitted: a new event was created from the " +
"stored one and queued to every active target.",
},
resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
},
}[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok {
return nil
}
return &n
}
// withNotice returns path with code added as its notice.
func withNotice(path string, code noticeCode) string {
return path + "?" + noticeParam + "=" + string(code)
}
+28 -16
View File
@@ -1,6 +1,7 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi"
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w,
r,
sessionUsername,
// PostFormValue, not FormValue: the credential must
// come from the body, never from the query string.
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes the
// error page itself and returns handled=false, signalling the caller
// to display on the profile page. On an internal failure it writes a
// 500 response itself and returns handled=false, signalling the caller
// to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) {
// This endpoint verifies one password and hashes another, at
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held
// across both hashes.
release, ok := h.mw.BeginPasswordVerification(r.Context())
release, ok := h.mw.BeginPasswordVerification(ctx)
if !ok {
h.log.Warn("password verification capacity exhausted")
h.renderError(w, r, http.StatusServiceUnavailable)
http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return "", "", false
}
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error
if err != nil {
h.serverError(
w, r, "failed to load user for password change", err,
w, "failed to load user for password change", err,
)
return "", "", false
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password,
)
if err != nil {
h.serverError(w, r, "failed to verify password", err)
h.serverError(w, "failed to verify password", err)
return "", "", false
}
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword)
if err != nil {
h.serverError(w, r, "failed to hash new password", err)
h.serverError(w, "failed to hash new password", err)
return "", "", false
}
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword,
).Error
if err != nil {
h.serverError(w, r, "failed to update password", err)
h.serverError(w, "failed to update password", err)
return "", "", false
}
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return "", "", false
}
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error.
sess, err := h.session.Get(r)
if err != nil {
h.serverError(w, r, "failed to get session", err)
h.serverError(w, "failed to get session", err)
return "", "", false
}
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
h.renderError(w, r, http.StatusInternalServerError)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false
}
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
h.renderError(w, r, http.StatusInternalServerError)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false
}
// Only allow users to act on their own profile.
if requestedUsername != sessionUsername {
h.renderError(w, r, http.StatusForbidden)
http.Error(w, "Forbidden", http.StatusForbidden)
return "", "", false
}
+3 -10
View File
@@ -88,8 +88,6 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Account Information")
assert.NotContains(t, w.Body.String(), "Account Type")
}
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
@@ -128,9 +126,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session
var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app := newTestApp(t, &log, &cfg, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -141,7 +137,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
r.Use(mw.CSRF())
r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true
@@ -162,10 +158,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
"handler must not be reached for unauthenticated request",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fuser%2Ftestuser",
w.Header().Get("Location"),
)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
// passwordChangeRequest builds a POST request to the password-change
-293
View File
@@ -1,293 +0,0 @@
package handlers
import (
"net/http"
"slices"
"strconv"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// recentEventColumns is the recent events list's projection. It
// leaves out the body, for the reason maxRenderedBodyBytes gives,
// and reads its size from body_bytes, recorded when the event was
// stored.
const recentEventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, body_bytes"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
// bodies, which the list does not show.
const recentAttemptColumns = "delivery_id, status_code, created_at"
// RecentEventView is one row of the recent events list on a
// webhook's page.
type RecentEventView struct {
Method string
ContentType string
// ResubmittedFromID names the event this one was copied from,
// empty for an event that arrived on the receiver.
ResubmittedFromID string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp the page shows on hover.
Received string
ReceivedUTC string
// Size is the size of the stored body.
Size string
// ProcessingTime is how long the event's slowest delivery
// took; see processingTime.
ProcessingTime string
// Status is what the webhook's HTTP target answered, and
// StatusClass its colour; see targetStatus. Both are empty
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
}
// recentEventRow is one row of recentEventColumns.
type recentEventRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
BodyBytes uint64
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
// when the attempt's result was recorded, which is when the attempt
// finished.
type recentAttemptRow struct {
DeliveryID string
StatusCode int
CreatedAt time.Time
}
// singleHTTPTargetID returns the ID of the webhook's HTTP target
// when it has exactly one, and "" when it has none or several.
func singleHTTPTargetID(targets []database.Target) string {
id := ""
count := 0
for i := range targets {
if targets[i].Type == database.TargetTypeHTTP {
id = targets[i].ID
count++
}
}
if count != 1 {
return ""
}
return id
}
// loadRecentEvents loads the webhook's recentEventLimit newest
// events for its page, newest first. statusTargetID is the
// webhook's only HTTP target, or "" when the list shows no status.
func loadRecentEvents(
webhookDB *gorm.DB, webhookID, statusTargetID string,
) ([]RecentEventView, error) {
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
Find(&rows).Error
if err != nil {
return nil, err
}
eventIDs := make([]string, len(rows))
for i := range rows {
eventIDs[i] = rows[i].ID
}
// Oldest first, so an event's last delivery to a target is its
// newest: a replay adds a delivery rather than changing the
// earlier one.
var deliveries []database.Delivery
err = webhookDB.
Select("id, event_id, target_id, status, created_at").
Where("event_id IN ?", eventIDs).
Order("created_at ASC").
Find(&deliveries).Error
if err != nil {
return nil, err
}
byEvent := make(map[string][]database.Delivery, len(rows))
deliveryIDs := make([]string, len(deliveries))
for i := range deliveries {
eventID := deliveries[i].EventID
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
deliveryIDs[i] = deliveries[i].ID
}
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
if err != nil {
return nil, err
}
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
byEvent[rows[i].ID], attempts, statusTargetID,
)
}
return views, nil
}
// loadRecentAttempts loads the recorded attempts of the listed
// events' deliveries, keyed by delivery ID, each delivery's in
// attempt order. The IDs go in chunks for the reason
// deliveryIDChunkSize gives.
func loadRecentAttempts(
webhookDB *gorm.DB, deliveryIDs []string,
) (map[string][]recentAttemptRow, error) {
byDelivery := make(map[string][]recentAttemptRow)
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
var rows []recentAttemptRow
err := webhookDB.Model(&database.DeliveryResult{}).
Select(recentAttemptColumns).
Where("delivery_id IN ?", chunk).
Order("attempt_num ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
for i := range rows {
id := rows[i].DeliveryID
byDelivery[id] = append(byDelivery[id], rows[i])
}
}
return byDelivery, nil
}
// view projects a loaded row for rendering. deliveries is the
// event's deliveries, oldest first, and attempts their recorded
// attempts keyed by delivery ID.
func (r *recentEventRow) view(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
Received: humanize.Time(r.CreatedAt),
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
}
if r.ResubmittedFromID != nil {
v.ResubmittedFromID = *r.ResubmittedFromID
}
if statusTargetID != "" {
v.Status, v.StatusClass = targetStatus(
deliveries, attempts, statusTargetID,
)
}
return v
}
// processingTime is how long the event's slowest delivery took,
// from being queued to its last recorded attempt, time spent
// waiting between retries included. A delivery is queued when its
// event is received, or when an operator replays it, so a replay
// is timed from the replay rather than from the event's arrival.
// It is "in progress" while any delivery is pending or retrying,
// and empty for an event with no deliveries.
func processingTime(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
) string {
if len(deliveries) == 0 {
return ""
}
var slowest time.Duration
for i := range deliveries {
if !deliveries[i].Status.Terminal() {
return "in progress"
}
tries := attempts[deliveries[i].ID]
if len(tries) == 0 {
continue
}
last := tries[len(tries)-1].CreatedAt
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
}
return slowest.Round(time.Millisecond).String()
}
// targetStatus is what the target answered for the event, and the
// colour to show it in: the HTTP status code of the last attempt of
// the event's newest delivery to the target. Without a code it is
// "no response" when that attempt failed before a response
// arrived, the delivery's status ("pending") before any attempt,
// and "not sent" when the event has no delivery to the target.
func targetStatus(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
targetID string,
) (string, string) {
newest := -1
for i := range deliveries {
if deliveries[i].TargetID == targetID {
newest = i
}
}
if newest < 0 {
return "not sent", "text-gray-400"
}
tries := attempts[deliveries[newest].ID]
if len(tries) == 0 {
return string(deliveries[newest].Status), "text-gray-400"
}
code := tries[len(tries)-1].StatusCode
switch {
case code == 0:
return "no response", "text-red-600"
case code >= http.StatusInternalServerError:
return strconv.Itoa(code), "text-red-600"
case code >= http.StatusBadRequest:
return strconv.Itoa(code), "text-yellow-600"
case code >= http.StatusMultipleChoices:
return strconv.Itoa(code), "text-gray-500"
case code >= http.StatusOK:
return strconv.Itoa(code), "text-green-600"
default:
return strconv.Itoa(code), "text-gray-500"
}
}
-362
View File
@@ -1,362 +0,0 @@
package handlers_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// statusTitle marks the status column's cell in a recent events
// row; it is absent from the page when the column is not shown.
const statusTitle = `title="HTTP status from the HTTP target"`
// recentEventsFixture is one started app and a webhook whose
// recent events list a test fills.
type recentEventsFixture struct {
h *handlers.Handlers
sess *session.Session
db *database.Database
webhook *database.Webhook
webhookDB *gorm.DB
}
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
t.Helper()
f := &recentEventsFixture{}
var dbMgr *database.WebhookDBManager
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
f.webhook = seedWebhook(t, f.db)
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
require.NoError(t, err)
f.webhookDB = webhookDB
return f
}
func (f *recentEventsFixture) render(t *testing.T) string {
t.Helper()
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
}
// event records an event received at receivedAt, with its body's
// size as the receiver records it.
func (f *recentEventsFixture) event(
t *testing.T, contentType, body string, receivedAt time.Time,
) *database.Event {
t.Helper()
event := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: body,
BodyBytes: int64(len(body)),
ContentType: contentType,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// delivery records a delivery of the event to the target, queued
// when the event was received.
func (f *recentEventsFixture) delivery(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
) *database.Delivery {
t.Helper()
return f.deliveryQueuedAt(
t, event, targetID, status, event.CreatedAt,
)
}
// deliveryQueuedAt records a delivery of the event to the target,
// queued at queuedAt, as a replay is.
func (f *recentEventsFixture) deliveryQueuedAt(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
queuedAt time.Time,
) *database.Delivery {
t.Helper()
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: status,
}
dlv.CreatedAt = queuedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(dlv).Error)
return dlv
}
// attempt records one attempt of the delivery that finished took
// after the delivery was queued, with HTTP status code (0 for no
// response).
func (f *recentEventsFixture) attempt(
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
) {
t.Helper()
result := &database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: code,
}
result.CreatedAt = dlv.CreatedAt.Add(took)
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(result).Error)
}
// statusCell is the status column's cell as the page renders it.
func statusCell(class, text string) string {
return `<span class="font-medium ` + class + `" ` + statusTitle +
`>` + text + `</span>`
}
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
// headed "50 Most Recent Events" and holds the 50 newest events,
// newest first, and not one more.
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
base := time.Now().Add(-time.Hour)
for i := range 51 {
f.event(
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
}
body := f.render(t)
assert.Contains(t, body, ">50 Most Recent Events</h2>")
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
assert.NotContains(t, body, "application/x-recent-00")
assert.Contains(t, body, "application/x-recent-01")
assert.Less(
t,
strings.Index(body, "application/x-recent-50"),
strings.Index(body, "application/x-recent-49"),
)
}
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
// time relative with the UTC timestamp on hover, its body size,
// and its processing time once every delivery has finished.
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
receivedAt := time.Now().Add(-210 * time.Second).
UTC().Truncate(time.Second)
done := f.event(
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
)
f.attempt(
t,
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
0, 1500*time.Millisecond,
)
waiting := f.event(t, "text/plain", "{}", receivedAt)
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
body := f.render(t)
assert.Contains(
t, body,
`<span title="`+receivedAt.Format(time.DateTime)+
` UTC">3 minutes ago</span>`,
)
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
assert.Contains(t, body, ">1.5s</span>")
assert.Contains(t, body, ">in progress</span>")
}
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
// webhook with exactly one HTTP target shows, colour-coded, what
// that target answered for each event. The log target beside it
// does not count against "exactly one".
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
for _, code := range []int{204, 302, 404, 503, 0} {
dlv := f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusDelivered,
)
f.attempt(t, dlv, code, time.Second)
}
f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusPending,
)
f.event(t, contentTypeJSON, "{}", now)
// A replay is a newer delivery, and its answer is the one shown.
replayed := f.event(t, contentTypeJSON, "{}", now)
f.attempt(t, f.delivery(
t, replayed, target.ID, database.DeliveryStatusFailed,
), 502, time.Second)
f.attempt(t, f.deliveryQueuedAt(
t, replayed, target.ID, database.DeliveryStatusDelivered,
now.Add(time.Minute),
), 200, time.Second)
body := f.render(t)
assert.Contains(t, body, statusCell("text-green-600", "204"))
assert.Contains(t, body, statusCell("text-gray-500", "302"))
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
assert.Contains(t, body, statusCell("text-red-600", "503"))
assert.Contains(t, body, statusCell("text-red-600", "no response"))
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
assert.Contains(t, body, statusCell("text-green-600", "200"))
assert.NotContains(t, body, ">502<")
}
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
// status column is absent when the webhook has no HTTP target or
// more than one.
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
t *testing.T,
) {
t.Parallel()
cases := map[string][]database.TargetType{
"none": {database.TargetTypeLog},
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
}
for name, types := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
event := f.event(t, contentTypeJSON, "{}", time.Now())
for _, tt := range types {
target := seedTarget(t, f.db, f.webhook.ID, tt)
f.attempt(t, f.delivery(
t, event, target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
}
body := f.render(t)
assert.Contains(t, body, `title="Body size"`)
assert.NotContains(t, body, statusTitle)
})
}
}
// TestHandleWebhook_RecordsBodySize proves the receiver records the
// body's size in bytes, not characters, with the event it stores.
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
seedEntrypoint(t, f.db, f.webhook.ID)
// Two bytes per character.
body := strings.Repeat("é", 1024)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x",
strings.NewReader(body),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, int64(2048), stored.BodyBytes)
}
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
// list cannot be loaded the page answers with an error, rather than
// an empty list claiming the webhook has no events.
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
f.attempt(t, f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
// The attempts are the list's last query, so its events and
// deliveries have already loaded when it fails.
require.NoError(t, f.webhookDB.Exec(
"DROP TABLE delivery_results",
).Error)
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(t, w.Body.String(), "No events received yet.")
}
+87 -68
View File
@@ -15,7 +15,6 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
@@ -80,10 +79,6 @@ func seedTarget(
// from a delete statement.
var errInjectedDelete = errors.New("injected delete failure")
// errInjectedSave is the failure failSaveOnTable reports from a
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an entrypoint for a webhook.
func seedEntrypoint(
t *testing.T,
@@ -151,42 +146,19 @@ func failDeleteOnTable(
)
}
// failSaveOnTable is failDeleteOnTable for saves: every update of
// an existing row in the named table fails.
func failSaveOnTable(
t *testing.T,
db *database.Database,
table string,
) {
t.Helper()
require.NoError(t, db.DB().Callback().Update().
Before("gorm:update").
Register(
"test:fail_save_"+table,
func(tx *gorm.DB) {
if tx.Statement.Table == table {
_ = tx.AddError(errInjectedSave)
}
},
),
)
}
// archivePathFor returns the archive database path the
// delivery engine would use for a database target: beside the
// webhook's event database in the data directory.
// delivery engine would use for a webhook: beside the webhook's
// event database in the data directory.
func archivePathFor(
t *testing.T,
mgr *database.WebhookDBManager,
wh *database.Webhook,
tgt *database.Target,
webhookID string,
) string {
t.Helper()
return filepath.Join(
filepath.Dir(mgr.DBPath(wh.ID)),
delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
filepath.Dir(mgr.DBPath(webhookID)),
"archive-"+webhookID+".db",
)
}
@@ -223,8 +195,8 @@ func postRequest(
// TestHandleSourceDelete_EvictsArchiveWriter proves that
// deleting a webhook reaches the delivery engine and releases
// the webhook's archive writers, exercised through the real
// deletion handler rather than by calling the engine directly.
// the webhook's archive writer, exercised through the real
// deletion handler rather than by calling the evictor directly.
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
t.Parallel()
@@ -232,7 +204,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -248,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -282,10 +254,9 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
// Place an archive file where the delivery engine would.
archivePath := archivePathFor(t, mgr, wh, tgt)
archivePath := archivePathFor(t, mgr, wh.ID)
require.NoError(
t,
writeArchivePlaceholder(archivePath),
@@ -296,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -352,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -366,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
assert.Empty(
t, w.Header().Get("Location"),
"a failed deletion must not redirect to /hooks",
"a failed deletion must not redirect to /sources",
)
assert.Equal(
@@ -431,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -440,9 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
)
assert.Equal(t, "/sources", w.Header().Get("Location"))
assert.Equal(
t, int64(0),
@@ -466,17 +435,68 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
}
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// database target releases that target's archive writer and no
// other: the webhook's other database target keeps its own.
func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
// proves that removing the last database target releases the
// archive writer.
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(
t, db, wh.ID, database.TargetTypeDatabase,
)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: tgt.ID,
},
)
w := httptest.NewRecorder()
h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, []string{wh.ID}, ev.Evicted(),
"removing the last database target should evict",
)
}
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
// proves that deleting one of several database targets leaves
// the still-needed archive writer alone: the surviving target
// keeps archiving to the same file, so the writer must stay.
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -495,7 +515,7 @@ func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
@@ -507,17 +527,17 @@ func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, []string{doomed.ID}, ev.EvictedTargets(),
"deleting a database target should evict its writer",
assert.Empty(
t, ev.Evicted(),
"a second database target still needs the writer",
)
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
}
// TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
// a target id from the URL that is not a target of the webhook
// deletes nothing and so evicts nothing.
func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves
// that deleting a target of an unrelated type leaves a
// still-needed archive writer alone: the webhook's database
// target is untouched, so its writer must stay.
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
t *testing.T,
) {
t.Parallel()
@@ -526,7 +546,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -535,20 +555,19 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
elsewhere := seedTarget(
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
)
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: elsewhere.ID,
paramTargetID: other.ID,
},
)
w := httptest.NewRecorder()
@@ -557,7 +576,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty(
t, ev.EvictedTargets(),
"another webhook's target must not be evicted",
t, ev.Evicted(),
"a surviving database target must keep its writer",
)
}
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+f.webhook,
"/source/"+f.webhook,
nil,
)
req.Host = host
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
assert.Equal(
t,
tc.scheme+"://"+host+"/h/"+fixture.path,
tc.scheme+"://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL(
t, host, forwardedProto(tc.header),
),
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
assert.Equal(
t,
"https://"+host+"/h/"+fixture.path,
"https://"+host+"/webhook/"+fixture.path,
got,
"a connection this process terminated with TLS "+
"outranks a header claiming plaintext",
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
assert.Equal(
t,
"https://"+host+"/h/"+fixture.path,
"https://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL(
t, host, forwardedProto("HTTPS"),
),
+4 -53
View File
@@ -62,27 +62,10 @@ func renderSourceDetailPage(
) string {
t.Helper()
w := serveSourceDetailPage(t, h, sess, webhookID)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// serveSourceDetailPage runs the real source detail handler for a
// webhook and returns its response, whatever its status.
func serveSourceDetailPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID,
"/source/"+webhookID,
nil,
)
@@ -104,7 +87,9 @@ func serveSourceDetailPage(
w := httptest.NewRecorder()
h.HandleSourceDetail().ServeHTTP(w, req)
return w
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// TestHandleSourceDetail_MasksSlackWebhookURL is the
@@ -241,37 +226,3 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak")
}
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
// page's maximum width at 108rem (1728 px), half again the 72rem of
// max-w-6xl that the webhook list and the event log use, so an
// entrypoint URL fits on one line in a 1920-pixel window; and the
// wrapping of its title row, so the buttons beside the title do not
// push a phone-width window into scrolling sideways.
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(
t, body,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
)
assert.Contains(
t, body,
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
)
}
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
"/source/"+webhookID+"/targets/"+targetID+"/delete",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
+1 -1
View File
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID+"/events"+query,
"/source/"+webhookID+"/logs"+query,
nil,
)
+149 -210
View File
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook)
if err != nil {
h.serverError(w, r, "failed to create webhook", err)
h.serverError(w, "failed to create webhook", err)
return
}
@@ -322,8 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
)
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -381,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -408,23 +415,16 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID,
).Find(&targets)
var events []RecentEventView
var events []database.Event
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
events, err = loadRecentEvents(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, r, "failed to load recent events", err)
return
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
if dbErr == nil {
webhookDB.Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Limit(
recentEventLimit,
).Find(&events)
}
}
@@ -450,7 +450,6 @@ func (h *Handlers) renderSourceDetail(
"Targets": delivery.NewTargetViews(targets),
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
}
h.renderTemplate(w, r, "source_detail.html", data)
@@ -476,7 +475,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -511,7 +510,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -520,7 +519,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -550,7 +551,6 @@ func (h *Handlers) applyWebhookEdit(
return
}
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
@@ -573,50 +573,15 @@ func (h *Handlers) applyWebhookEdit(
webhook.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without
// reading the main database again.
targets, err := h.renameWebhookArchives(
webhook.ID, oldName, webhook.Name,
)
if err == nil {
err = h.db.DB().Save(webhook).Error
}
err := h.db.DB().Save(webhook).Error
if err != nil {
restoreErr := h.renameArchives(targets, oldName)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: "Not saved: " + err.Error() +
". Move that archive out of the data directory, " +
"its .db together with any -wal and -shm beside " +
"it, then save again.",
}
w.WriteHeader(http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
h.serverError(w, r, "failed to update webhook", err)
h.serverError(w, "failed to update webhook", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -640,7 +605,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -667,7 +632,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, r, "failed to delete webhook", err)
h.serverError(w, "failed to delete webhook", err)
return
}
@@ -693,15 +658,13 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
h.serverError(
w, r, "failed to delete webhook event database", err,
w, "failed to delete webhook event database", err,
)
return
}
http.Redirect(
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
}
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
@@ -746,11 +709,11 @@ func (h *Handlers) commitWebhookDeletion(
return tx.Commit().Error
}
// evictArchiveWriter asks the delivery engine to drop the cached
// archive writers of a webhook's database targets, closing their
// archive file handles.
// evictArchiveWriter asks the delivery engine to drop its
// cached archive writer for a webhook, closing the archive file
// handle.
//
// The archive database files are NOT deleted. Unlike the event
// The archive database file is NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
@@ -758,72 +721,50 @@ func (h *Handlers) commitWebhookDeletion(
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.archives == nil {
if h.evictor == nil {
return
}
h.archives.EvictWebhook(webhookID)
h.evictor.EvictWebhook(webhookID)
}
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
// target, and leaves its archive file on disk for the same reason.
// A target that is not a database target has no writer, and
// evicting it does nothing.
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
if h.archives == nil {
return
}
h.archives.EvictTarget(targetID)
}
// renameWebhookArchives renames the archive file of every database
// target of a webhook from the webhook name oldName to newName,
// keeping each target's own name. It does nothing when the name is
// unchanged. It returns the targets it read, so that a failed edit can
// move those same archives back with renameArchives.
func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string,
) ([]database.Target, error) {
if h.archives == nil || oldName == newName {
return nil, nil
}
var targets []database.Target
// evictArchiveWriterIfUnused releases a webhook's archive
// writer once the webhook has no database target left to feed
// it.
//
// It is called after any child resource of a webhook is
// deleted, and is correct without knowing which kind was: it
// evicts only when no database target remains, so deleting one
// of several database targets — or deleting an unrelated
// target type — leaves a still-needed writer alone. When no
// database target ever existed there is no writer and eviction
// is a no-op. Soft-deleted targets are excluded by GORM's
// default scope, so the row just deleted is not counted.
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
var remaining int64
err := h.db.DB().
Model(&database.Target{}).
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Find(&targets).Error
Count(&remaining).Error
if err != nil {
return nil, err
}
return targets, h.renameArchives(targets, newName)
}
// renameArchives renames the archive file of each of the given
// database targets to the webhook name webhookName, keeping each
// target's own name. It tries every target even after one fails, so
// that moving the archives back after a failed edit leaves none under
// the new name, and returns every failure joined.
func (h *Handlers) renameArchives(
targets []database.Target, webhookName string,
) error {
var errs []error
for i := range targets {
err := h.archives.Rename(
targets[i].ID, webhookName, targets[i].Name,
h.log.Error(
"failed to count remaining database targets",
"webhook_id", webhookID,
"error", err,
)
if err != nil {
errs = append(errs, err)
}
return
}
return errors.Join(errs...)
if remaining > 0 {
return
}
h.evictArchiveWriter(webhookID)
}
// ownedWebhook resolves the request's sourceID parameter to a
@@ -861,7 +802,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return database.Webhook{}, false
}
@@ -883,7 +824,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, r, "failed to load targets", err)
h.serverError(w, "failed to load targets", err)
return
}
@@ -891,7 +832,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, page,
w, webhook, targets, page,
)
if !ok {
return
@@ -902,9 +843,24 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
totalPages++
}
// The banner a replay or resubmit POST redirected back
// with. The message comes from a fixed set keyed by the
// outcome code, never from the query string itself.
replayMsg, replayOK := replayOutcome(
r.URL.Query().Get(replayOutcomeParam),
)
resubmitMsg, resubmitOK := resubmitOutcome(
r.URL.Query().Get(resubmitOutcomeParam),
)
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"ReplayMessage": replayMsg,
"ReplayQueued": replayOK,
"ResubmitMessage": resubmitMsg,
"ResubmitQueued": resubmitOK,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
@@ -986,7 +942,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
page int,
@@ -1000,7 +955,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, r, "failed to get webhook database", err,
w, "failed to get webhook database", err,
)
return nil, 0, false
@@ -1037,7 +992,7 @@ func (h *Handlers) loadEventsWithDeliveries(
)
if err != nil {
h.serverError(
w, r, "failed to load delivery attempts", err,
w, "failed to load delivery attempts", err,
)
return nil, 0, false
@@ -1046,7 +1001,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, r, "failed to count event resubmissions", err,
w, "failed to count event resubmissions", err,
)
return nil, 0, false
@@ -1269,7 +1224,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -1278,7 +1233,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -1294,14 +1251,13 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, r, "failed to create entrypoint", err)
h.serverError(w, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
}
@@ -1326,7 +1282,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -1335,7 +1291,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -1355,7 +1313,7 @@ func (h *Handlers) processTargetCreate(
//
// Every field here is read with PostFormValue, not FormValue.
// FormValue falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record.
@@ -1406,14 +1364,13 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, r, "failed to create target", err)
h.serverError(w, "failed to create target", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -1471,7 +1428,7 @@ type targetFormInput struct {
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and the
// request line, unlike the body, is what logs, proxies, Referer
// headers and error trackers record. The headers field is under the
@@ -1501,7 +1458,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
return h.buildDatabaseTargetConfig(w, in.Expiry)
case database.TargetTypeLog:
return "", nil
default:
@@ -1551,7 +1508,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err
}
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
@@ -1573,7 +1530,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err
}
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
return marshalTargetConfig(w, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
}
@@ -1613,23 +1570,11 @@ func (h *Handlers) validateTargetURL(
"url", delivery.MaskURL(targetURL),
"error", err,
)
msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how
// to allow it. Other refusals never do: link-local, the
// unspecified addresses and the unconditional metadata
// addresses cannot be opened, and the default
// blocklist's public addresses, which listing does open,
// hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " +
"setting allows named networks (see \"Allowing " +
"egress to your own network\" in the README)."
}
http.Error(w, msg, http.StatusBadRequest)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
return err
}
@@ -1639,14 +1584,16 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func (h *Handlers) marshalTargetConfig(
func marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
@@ -1662,7 +1609,6 @@ func (h *Handlers) marshalTargetConfig(
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
@@ -1681,8 +1627,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err
}
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
return marshalTargetConfig(
w, map[string]any{"expiry": expiry},
)
}
@@ -1692,33 +1638,30 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
entrypointDeleted,
)
}
// HandleTargetDelete handles deleting a target. A deleted
// database target's archive writer is evicted and its handle
// closed; the archive file is left on disk.
// HandleTargetDelete handles deleting a target. Deleting the
// last database target of a webhook leaves its archive writer
// with nothing to write, so the writer is evicted and its
// handle closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictTargetArchiveWriter,
targetDeleted,
h.evictArchiveWriterIfUnused,
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect, which carries done as
// its notice.
// optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(childID string),
done noticeCode,
afterDelete func(webhookID string),
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
@@ -1739,7 +1682,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
@@ -1749,20 +1692,22 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.serverError(w, r, errMsg, result.Error)
h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
// Only for a row this webhook really had: the id came from
// the URL and may name another webhook's child.
if afterDelete != nil && result.RowsAffected > 0 {
afterDelete(childID)
if afterDelete != nil {
afterDelete(webhook.ID)
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
@@ -1773,7 +1718,7 @@ func (h *Handlers) deleteChildResource(
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) (bool, error) {
func(webhookID, childID string) error {
var ep database.Entrypoint
err := h.db.DB().Where(
@@ -1781,15 +1726,14 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
childID, webhookID,
).First(&ep).Error
if err != nil {
return false, err
return err
}
ep.Active = !ep.Active
return ep.Active, h.db.DB().Save(&ep).Error
return h.db.DB().Save(&ep).Error
},
"failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
)
}
@@ -1797,7 +1741,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource(
"targetID",
func(webhookID, childID string) (bool, error) {
func(webhookID, childID string) error {
var tgt database.Target
err := h.db.DB().Where(
@@ -1805,27 +1749,23 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
childID, webhookID,
).First(&tgt).Error
if err != nil {
return false, err
return err
}
tgt.Active = !tgt.Active
return tgt.Active, h.db.DB().Save(&tgt).Error
return h.db.DB().Save(&tgt).Error
},
"failed to toggle target",
targetActivated, targetDeactivated,
)
}
// toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook. toggleFn returns
// the new state, and the redirect carries activated or deactivated as
// its notice to match.
// state of a child resource belonging to a webhook.
func (h *Handlers) toggleChildResource(
idParam string,
toggleFn func(webhookID, childID string) (bool, error),
toggleFn func(webhookID, childID string) error,
errMsg string,
activated, deactivated noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
@@ -1846,26 +1786,25 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return
}
active, err := toggleFn(webhook.ID, childID)
err = toggleFn(webhook.ID, childID)
if err != nil {
h.serverError(w, r, errMsg, err)
h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
done := deactivated
if active {
done = activated
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
+8 -195
View File
@@ -105,7 +105,7 @@ func submitCreate(
form.Set("retention_days", *retention)
}
req := formRequest("/hooks/new", cookies, form, nil)
req := formRequest("/sources/new", cookies, form, nil)
w := httptest.NewRecorder()
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -187,7 +187,6 @@ func storedRetentionDays(
type sourceTestEnv struct {
handlers *handlers.Handlers
db *database.Database
archives *recordingArchives
cookies []*http.Cookie
}
@@ -200,9 +199,7 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
var db *database.Database
var archives *recordingArchives
app := newTestApp(t, &h, &sess, &db, &archives)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -210,7 +207,6 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
return &sourceTestEnv{
handlers: h,
db: db,
archives: archives,
cookies: authenticatedCookies(
t, sess, sourceTestUserID, "sourceuser",
),
@@ -269,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
w := httptest.NewRecorder()
env.handlers.HandleSourceCreate().ServeHTTP(
w, getRequest(t, "/hooks/new", env.cookies, nil),
w, getRequest(t, "/sources/new", env.cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
@@ -406,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
form.Set("description", description)
form.Set("retention_days", "nonsense")
req := formRequest("/hooks/new", env.cookies, form, nil)
req := formRequest("/sources/new", env.cookies, form, nil)
w := httptest.NewRecorder()
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -434,7 +430,7 @@ func submitEdit(
form.Set("retention_days", retention)
req := formRequest(
"/hook/"+wh.ID+"/edit",
"/source/"+wh.ID+"/edit",
env.cookies,
form,
map[string]string{sourceIDParam: wh.ID},
@@ -502,189 +498,6 @@ func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
}
// renamedWebhookName is the name the rename tests give a webhook.
const renamedWebhookName = "Renamed"
// TestHandleSourceEditSubmit_RenamesArchives proves that a save
// that keeps the webhook's name renames nothing, and that renaming a
// webhook renames the archive of each of its database targets and
// asks nothing of its other targets.
func TestHandleSourceEditSubmit_RenamesArchives(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty(t, env.archives.Renames())
wh.Name = renamedWebhookName
w = submitEdit(t, env, wh, "")
require.Equal(t, http.StatusSeeOther, w.Code)
assert.ElementsMatch(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{second.ID, renamedWebhookName, second.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedRenameKeepsTheName proves that a
// webhook whose archive cannot be renamed keeps its stored name, so
// the name on disk and the name in the UI do not part, and that the
// handler puts back what it may already have moved.
func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(tgt.ID, errInjectedRename)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{tgt.ID, renamedWebhookName, tgt.Name},
{tgt.ID, oldName, tgt.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedSaveRenamesBack proves that when
// the archive is renamed but the new name cannot be saved, the
// archive is renamed back to the stored name and the stored name
// stays.
func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "webhooks")
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{tgt.ID, renamedWebhookName, tgt.Name},
{tgt.ID, oldName, tgt.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves
// that when a webhook has three database targets and only the middle
// one's archive cannot be renamed, the stored name stays and both
// others are renamed back, the last one included: the move back does
// not stop at the target it cannot rename. The handler reaches the
// targets in the order they were created, which the exact sequence
// below pins, so the refused target always comes before the last.
func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
middle := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
last := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(middle.ID, errNameTaken)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{middle.ID, renamedWebhookName, middle.Name},
{last.ID, renamedWebhookName, last.Name},
{first.ID, oldName, first.Name},
{middle.ID, oldName, middle.Name},
{last.ID, oldName, last.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
// already has an archive's new name, the edit is refused with an
// error naming that file, and the webhook keeps its stored name.
func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(tgt.ID, errNameTaken)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db")
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
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
@@ -699,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
)
req := getRequest(
t, "/hook/"+wh.ID+"/edit", env.cookies,
t, "/source/"+wh.ID+"/edit", env.cookies,
map[string]string{sourceIDParam: wh.ID},
)
w := httptest.NewRecorder()
@@ -754,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
listW := httptest.NewRecorder()
env.handlers.HandleSourceList().ServeHTTP(
listW, getRequest(t, "/hooks", env.cookies, nil),
listW, getRequest(t, "/sources", env.cookies, nil),
)
require.Equal(t, http.StatusOK, listW.Code)
@@ -765,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
env.handlers.HandleSourceDetail().ServeHTTP(
detailW,
getRequest(
t, "/hook/"+wh.ID, env.cookies,
t, "/source/"+wh.ID, env.cookies,
map[string]string{sourceIDParam: wh.ID},
),
)
@@ -76,11 +76,11 @@ func postTargetCreate(
router := chi.NewRouter()
router.Use(mw.Logging())
router.Post(
"/hook/{sourceID}/targets",
"/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
target := "/hook/" + webhookID + "/targets"
target := "/source/" + webhookID + "/targets"
if query != "" {
target += "?" + query
}
@@ -114,7 +114,7 @@ func postTargetCreate(
// regression test for the ingress leak. r.FormValue falls back to the
// query string when a field is absent from the POST body, so
//
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/...
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
//
// with an empty url field used to create a working target from a value
// carried on the request line — where logs, proxies, Referer headers
+7 -54
View File
@@ -1,7 +1,6 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi"
@@ -48,7 +47,7 @@ type targetEditView struct {
//
// This page is the one place the full destination URL and header
// values are shown. It is reachable only through the
// /hook/{sourceID} route group, which supplies RequireAuth and
// /source/{sourceID} route group, which supplies RequireAuth and
// NoCache, and only for a target of a webhook the session's user
// owns; masking (delivery.TargetView) is unchanged everywhere else.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
@@ -89,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
@@ -151,69 +152,21 @@ func (h *Handlers) applyTargetEdit(
target.MaxRetries = retries
}
oldName := target.Name
target.Name = name
target.Config = configJSON
// A new name renames the archive file before it is saved (see
// delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored.
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
if err == nil {
err = h.db.DB().Save(target).Error
}
if err != nil {
restoreErr := h.renameTargetArchive(
target, webhook.Name, name, oldName,
)
if restoreErr != nil {
h.log.Error(
"failed to rename archive back",
"target_id", target.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
http.Error(
w,
"Not saved: "+err.Error()+
". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+
"it, then save again.",
http.StatusConflict,
)
return
}
h.serverError(w, r, "failed to update target", err)
h.serverError(w, "failed to update target", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
// renameTargetArchive renames a database target's archive file from
// the target name oldName to newName. It does nothing when the name
// is unchanged; other target types have no archive.
func (h *Handlers) renameTargetArchive(
target *database.Target,
webhookName, oldName, newName string,
) error {
if h.archives == nil || oldName == newName ||
target.Type != database.TargetTypeDatabase {
return nil
}
return h.archives.Rename(target.ID, webhookName, newName)
}
// renderTargetEdit renders the target edit page with an optional
// error message.
func (h *Handlers) renderTargetEdit(
@@ -267,7 +220,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
http.NotFound(w, r)
return database.Webhook{}, nil, false
}
+9 -106
View File
@@ -42,15 +42,15 @@ const (
func targetRouter(env *sourceTestEnv) *chi.Mux {
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/targets",
"/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/edit",
"/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(),
)
router.Post(
"/hook/{sourceID}/targets/{targetID}/edit",
"/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEditSubmit(),
)
@@ -117,7 +117,7 @@ func seedHTTPTarget(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
"/source/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
@@ -188,7 +188,7 @@ func submitTargetEdit(
) *httptest.ResponseRecorder {
return serveTarget(
env, http.MethodPost,
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
"/source/"+webhookID+"/targets/"+targetID+"/edit",
form,
)
}
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets/"+target.ID+
"/source/"+webhook.ID+"/targets/"+target.ID+
"/edit?url="+url.QueryEscape(editReplacedURL)+
"&headers="+url.QueryEscape(editAuthHeader),
form,
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
get := serveTarget(
env, http.MethodGet,
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, get.Code)
@@ -630,105 +630,8 @@ func assertWebhookOfAnotherUser404s(
w := serveTarget(
env, http.MethodGet,
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// renamedTargetName is the name the rename tests give a target.
const renamedTargetName = "Long Term"
// TestHandleTargetEditSubmit_RenamesArchive proves that renaming a
// database target renames its archive, that a save that keeps the
// name renames nothing, that a target of another type has no archive
// to rename, and that a target whose archive cannot be renamed keeps
// its stored name. When a file already has the archive's new name,
// the edit is refused with an error naming that file.
func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
rename := url.Values{"name": {renamedTargetName}}
w := submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t,
[]archiveRename{{archive.ID, wh.Name, renamedTargetName}},
env.archives.Renames(),
)
w = submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"a save that keeps the name renames nothing",
)
httpWebhook, httpTarget := seedHTTPTarget(t, env, "", "")
w = submitTargetEdit(
env, httpWebhook.ID, httpTarget.ID,
editForm(editOriginalURL, "", ""),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"an HTTP target has no archive to rename",
)
again := url.Values{"name": {"Again"}}
env.archives.FailRenames(archive.ID, errInjectedRename)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
"a target whose archive was not renamed keeps its name",
)
env.archives.FailRenames(archive.ID, errNameTaken)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
)
}
// TestHandleTargetEditSubmit_FailedSaveRenamesBack proves that when a
// database target's archive is renamed but the new name cannot be
// saved, the archive is renamed back to the stored name and the
// stored name stays.
func TestHandleTargetEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "targets")
form := url.Values{}
form.Set("name", renamedTargetName)
w := submitTargetEdit(env, wh.ID, archive.ID, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, archive.Name, storedTarget(t, env, archive.ID).Name,
)
assert.Equal(
t,
[]archiveRename{
{archive.ID, wh.Name, renamedTargetName},
{archive.ID, wh.Name, archive.Name},
},
env.archives.Renames(),
)
}
@@ -1,116 +0,0 @@
package handlers_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// privateRefusalHint is the sentence that tells an operator a private
// destination is refused on purpose, and how to allow one.
const privateRefusalHint = "Private and reserved addresses are " +
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
"allows named networks (see \"Allowing egress to your own " +
"network\" in the README)."
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
// target types that take a URL, on add and on edit.
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
targetTypes := []database.TargetType{
database.TargetTypeHTTP,
database.TargetTypeSlack,
}
for _, targetType := range targetTypes {
t.Run(string(targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/hook/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "private")
form.Set("type", string(targetType))
form.Set("url", editBlockedURL)
added := serveTarget(
env, http.MethodPost, targetsPath, form,
)
assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains(
t, added.Body.String(), privateRefusalHint,
)
form.Set("url", editOriginalURL)
created := serveTarget(
env, http.MethodPost, targetsPath, form,
)
require.Equal(
t, http.StatusSeeOther, created.Code,
created.Body.String(),
)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
form.Set("url", editBlockedURL)
edited := submitTargetEdit(
env, webhook.ID, targets[0].ID, form,
)
assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains(
t, edited.Body.String(), privateRefusalHint,
)
})
}
}
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
// setting opens a link-local address, and Azure's WireServer hands out
// VM credentials, so neither refusal points at the setting.
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
metadataURLs := map[string]string{
"link-local": "http://169.254.169.254/latest/meta-data/",
"wireserver": "http://168.63.129.16/?comp=versions",
}
for name, metadataURL := range metadataURLs {
t.Run(name, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "metadata")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", metadataURL)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.NotContains(
t, w.Body.String(), privateRefusalHint,
)
})
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ func createWithRetries(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets",
"/source/"+webhook.ID+"/targets",
createRetriesForm(retries),
)
+10 -55
View File
@@ -54,7 +54,8 @@ func renderPage(
}
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
// label to "Webhooks" and its link to the webhook list at /hooks.
// label to "Webhooks". The /sources route is deliberately unchanged, so
// the assertion targets the link text rather than the href.
func TestNavbarUsesWebhookTerminology(t *testing.T) {
t.Parallel()
@@ -94,11 +95,15 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
t, body, ">Sources<",
"no user-visible element may still be labelled Sources",
)
assert.Contains(t, body, `href="/hooks"`)
assert.Contains(
t, body, `href="/sources"`,
"the /sources route itself must not change",
)
}
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
// its back link to the webhook page at /hook/{id}.
// its back link. The link's href still points at /source/{id}, which is
// intentional: only user-visible copy changes.
func TestEditPageUsesWebhookTerminology(t *testing.T) {
t.Parallel()
@@ -125,57 +130,7 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
assert.Contains(t, body, "Edit Webhook")
assert.NotContains(t, body, ">Sources<")
assert.Contains(t, body, `href="/hook/wh-1"`)
}
// TestEventLogPageIsCalledFullEventLog pins the one name the event log
// page at /hook/{id}/events goes by: both links to it on the webhook
// page, and its own heading, read "Full Event Log".
func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: source_detail.html calls
// Webhook.RetentionLabel, a pointer method. Both pages only range
// over their lists, and a list left out renders as empty, so the
// lists are left out.
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID
detailBody := renderPage(
t, h, sess, "source_detail.html", map[string]any{
dataKeyWebhook: webhook,
},
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-secondary">Full Event Log</a>`,
"the button at the top of the webhook page",
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
"the link under recent events",
)
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook,
"TotalEvents": int64(0),
})
assert.Contains(
t, logBody,
`<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>`,
)
assert.Contains(t, body, `href="/source/wh-1"`)
}
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
@@ -328,7 +283,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
t, body,
`<code id="entrypoint-url-ep-1"`,
)
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
assert.Contains(
t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`,
+11 -43
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.receiverError(w, "failed to serialize headers", err)
h.serverError(w, "failed to serialize headers", err)
return
}
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil {
h.receiverError(w, "failed to query targets", err)
h.serverError(w, "failed to query targets", err)
return
}
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
"path = ?", entrypointUUID,
).First(&entrypoint)
if result.Error != nil {
// The receiver is unauthenticated and /h/{uuid}
// The receiver is unauthenticated and /webhook/{uuid}
// matches any single segment, so this value is entirely
// client-chosen on exactly the branch where the lookup
// failed. DEBUG is off by default; the cap is what keeps
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets,
)
if err != nil {
h.receiverError(w, "failed to store webhook event", err)
h.serverError(w, "failed to store webhook event", err)
return
}
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an
@@ -243,7 +230,6 @@ func (s eventSource) event() *database.Event {
Method: s.Method,
Headers: s.HeadersJSON,
Body: string(s.Body),
BodyBytes: int64(len(s.Body)),
ContentType: s.ContentType,
ResubmittedFromID: s.ResubmittedFromID,
}
@@ -266,12 +252,11 @@ func requestEventSource(
}
}
// createAndFanOut writes the event and one pending delivery per target,
// and adds them to the webhook's running totals, in a single
// transaction, then hands the tasks to the delivery engine. It is the
// only path by which an event and its deliveries are created, so a
// resubmitted event is retried, SSRF-guarded and circuit-broken
// exactly as a received one is.
// createAndFanOut writes the event and one pending delivery per target
// in a single transaction, then hands the tasks to the delivery
// engine. It is the only path by which an event and its deliveries are
// created, so a resubmitted event is retried, SSRF-guarded and
// circuit-broken exactly as a received one is.
//
// The tasks are returned as well as queued, so a caller can report how
// many targets the event went to.
@@ -311,15 +296,6 @@ func (h *Handlers) createAndFanOut(
return nil, nil, err
}
err = database.AddEventTotals(tx, database.EventTotals{
Events: 1, LastEventAt: &event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, nil, fmt.Errorf(
@@ -378,9 +354,8 @@ func (h *Handlers) finishWebhookResponse(
}
// buildDeliveryTasks creates one pending delivery per target in the
// transaction, adds each to its target's totals, and returns the tasks
// for the delivery engine. The caller owns the transaction and rolls
// it back on error.
// transaction and returns the tasks for the delivery engine. The
// caller owns the transaction and rolls it back on error.
func buildDeliveryTasks(
tx *gorm.DB,
event *database.Event,
@@ -404,13 +379,6 @@ func buildDeliveryTasks(
)
}
err = database.AddTargetTotals(tx, database.TargetTotals{
TargetID: targets[i].ID, Deliveries: 1,
})
if err != nil {
return nil, err
}
tasks = append(tasks, delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
-259
View File
@@ -1,259 +0,0 @@
package handlers
import (
"fmt"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// The spans of the two recent windows the statistics pane reports on:
// the last 10 minutes and the last 24 hours.
const (
shortWindow = 10 * time.Minute
longWindow = 24 * time.Hour
)
// percent turns a fraction into a percentage.
const percent = 100
// WebhookStats holds the figures in the statistics pane at the top of
// the webhook page.
type WebhookStats struct {
Entrypoints int
ActiveEntrypoints int
Targets int
ActiveTargets int
// Lifetime counts every event, delivery and failure the webhook
// has had, and WithinRetention those still stored.
Lifetime Counts
WithinRetention Counts
// InProgress counts the deliveries still pending or retrying.
InProgress int64
// LastEventAt is when the newest event arrived, or nil when none
// has. Retention does not change it.
LastEventAt *time.Time
Last10Minutes RecentWindow
Last24Hours RecentWindow
}
// Counts holds a number of events, of deliveries and of failed
// deliveries.
type Counts struct {
Events int64
Deliveries int64
Failures int64
}
// RecentWindow holds what happened in one recent window: the events
// received in it, and the deliveries that became delivered or failed in
// it.
type RecentWindow struct {
Events int64
Delivered int64
Failed int64
}
// TargetFinished is how many of one target's deliveries became
// delivered, and how many failed, in a recent window.
type TargetFinished struct {
TargetID string
Delivered int64
Failed int64
}
// FailurePercent is the share of the deliveries finished in the window
// that failed, or a dash when none finished. Deliveries still pending
// or retrying are not counted either way.
func (w RecentWindow) FailurePercent() string {
finished := w.Delivered + w.Failed
if finished == 0 {
return "—"
}
return fmt.Sprintf(
"%.1f%%", percent*float64(w.Failed)/float64(finished),
)
}
// loadWebhookStats gathers the figures for the statistics pane from the
// webhook's entrypoints and targets, as the page has already loaded
// them, and from its event database. It returns nil, and logs why, when
// the event database cannot be read.
func (h *Handlers) loadWebhookStats(
webhookID string,
entrypoints []database.Entrypoint,
targets []database.Target,
) *WebhookStats {
stats := &WebhookStats{
Entrypoints: len(entrypoints),
Targets: len(targets),
}
for i := range entrypoints {
if entrypoints[i].Active {
stats.ActiveEntrypoints++
}
}
for i := range targets {
if targets[i].Active {
stats.ActiveTargets++
}
}
// Opening an event database that does not exist would create it,
// and it would hold nothing to count.
if !h.dbMgr.DBExists(webhookID) {
return stats
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err == nil {
err = readEventStats(webhookDB, time.Now(), stats)
}
if err != nil {
h.log.Error(
"failed to read webhook statistics",
"webhook_id", webhookID,
"error", err,
)
return nil
}
return stats
}
// readEventStats fills in the figures that come from the webhook's
// event database. None of them reads every stored row: the totals are
// one row for the events and one per target for the deliveries, and
// every other figure is read from an index, over only the rows it
// counts.
func readEventStats(
db *gorm.DB, now time.Time, stats *WebhookStats,
) error {
err := readTotals(db, stats)
if err != nil {
return err
}
err = db.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).
Count(&stats.InProgress).Error
if err != nil {
return fmt.Errorf("counting deliveries in progress: %w", err)
}
stats.Last10Minutes, err = readRecentWindow(
db, now.Add(-shortWindow),
)
if err != nil {
return err
}
stats.Last24Hours, err = readRecentWindow(
db, now.Add(-longWindow),
)
return err
}
// readTotals fills in the lifetime and within-retention figures, and
// when the last event arrived, from the running totals: the events'
// row, and the targets' rows summed.
func readTotals(db *gorm.DB, stats *WebhookStats) error {
var events database.EventTotals
err := db.Take(&events).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
var targets []database.TargetTotals
err = db.Find(&targets).Error
if err != nil {
return fmt.Errorf("reading target totals: %w", err)
}
stats.Lifetime.Events = events.Events
stats.WithinRetention.Events = events.Events - events.EventsRemoved
stats.LastEventAt = events.LastEventAt
for _, t := range targets {
stats.Lifetime.Deliveries += t.Deliveries
stats.Lifetime.Failures += t.Failed
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
}
return nil
}
// readRecentWindow counts the events received, and the deliveries that
// became delivered or failed, since the given time.
func readRecentWindow(
db *gorm.DB, since time.Time,
) (RecentWindow, error) {
var w RecentWindow
err := db.Model(&database.Event{}).
Where("created_at >= ?", since).
Count(&w.Events).Error
if err != nil {
return w, fmt.Errorf("counting recent events: %w", err)
}
byTarget, err := finishedByTarget(db, since)
if err != nil {
return w, err
}
for _, f := range byTarget {
w.Delivered += f.Delivered
w.Failed += f.Failed
}
return w, nil
}
// finishedByTarget counts, for each target, the deliveries that became
// delivered and those that failed since the given time, in one query
// over just that window of the deliveries' status index. A target with
// neither is left out.
func finishedByTarget(
db *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
var byTarget []TargetFinished
err := db.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").
Find(&byTarget).Error
if err != nil {
return nil, fmt.Errorf(
"counting deliveries finished by target: %w", err,
)
}
return byTarget, nil
}
-569
View File
@@ -1,569 +0,0 @@
package handlers_test
import (
"net/http"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
func statsEntrypoint(
t *testing.T, db *database.Database, webhookID string, active bool,
) string {
t.Helper()
ep := &database.Entrypoint{
WebhookID: webhookID,
Path: uuid.New().String(),
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
return ep.Path
}
// statsDelivery returns an event's delivery to a target.
func statsDelivery(
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
) database.Delivery {
t.Helper()
var d database.Delivery
require.NoError(t, webhookDB.Where(
"event_id = ? AND target_id = ?", eventID, targetID,
).First(&d).Error)
return d
}
// statsFinish settles a delivery as the delivery engine does: its
// final status and the time it finished, and one more on its target's
// delivered or failed total, in one transaction.
func statsFinish(
t *testing.T,
webhookDB *gorm.DB,
d database.Delivery,
status database.DeliveryStatus,
at time.Time,
) {
t.Helper()
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Model(&database.Delivery{}).
Where("id = ?", d.ID).
Updates(map[string]any{"status": status, "finished_at": at}).
Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, add)
}))
}
// statsAge moves an event's arrival back to the given time.
func statsAge(
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
) {
t.Helper()
require.NoError(t, webhookDB.Model(&database.Event{}).
Where("id = ?", eventID).
Update("created_at", at).Error)
}
// statsTargetTotals reads a webhook database's target totals, keyed by
// target.
func statsTargetTotals(
t *testing.T, webhookDB *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, webhookDB.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// statsHistory is the webhook seedStatsHistory builds: its event
// database, its newest event, and its two active targets.
type statsHistory struct {
webhook *database.Webhook
webhookDB *gorm.DB
newest database.Event
first, second string
}
// seedStatsHistory builds the webhook the statistics test checks: 14
// days of retention, twelve entrypoints (one inactive) and six targets
// (four inactive). Ten events arrive through the receiver, and so each
// has a delivery to the two active targets. The oldest event is past
// retention, the next 30 hours old, the next six hours old, the other
// seven just in. Six deliveries are settled as the delivery engine
// would, two of them inside a recent window though their event arrived
// before it. The newest event's delivery to the second target is
// retrying, the rest are left pending, and a replay adds a pending
// delivery to the oldest event. Once retention has removed the oldest
// event, every figure in the pane differs from every other.
func seedStatsHistory(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
db *database.Database,
dbMgr *database.WebhookDBManager,
) statsHistory {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
for range 10 {
statsEntrypoint(t, db, wh.ID, true)
}
statsEntrypoint(t, db, wh.ID, false)
first := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
for range 4 {
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(inactive).
Update("active", false).Error)
}
router := receiverRouter(h)
for range 10 {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, 10)
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
now := time.Now()
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
statsFinish(t, webhookDB, oldestFailure,
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, middle.ID, second.ID),
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, middle.ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, newest.ID, first.ID),
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
require.NoError(t, webhookDB.Model(&retrying).
Update("status", database.DeliveryStatusRetrying).Error)
require.Equal(t, http.StatusSeeOther,
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
return statsHistory{
webhook: wh, webhookDB: webhookDB, newest: newest,
first: first.ID, second: second.ID,
}
}
// statsPrune runs the real retention reaper until it has removed one
// event from the webhook's database, then stops it.
func statsPrune(
t *testing.T,
db *database.Database,
dbMgr *database.WebhookDBManager,
log *logger.Logger,
webhookDB *gorm.DB,
) {
t.Helper()
lc := fxtest.NewLifecycle(t)
database.NewRetentionReaper(lc, database.RetentionReaperParams{
Config: &config.Config{
RetentionSweepInterval: 10 * time.Millisecond,
},
Database: db,
DBManager: dbMgr,
Logger: log,
})
lc.RequireStart()
require.Eventually(t, func() bool {
var totals database.EventTotals
err := webhookDB.Take(&totals).Error
return err == nil && totals.EventsRemoved == 1
}, 10*time.Second, 10*time.Millisecond)
lc.RequireStop()
}
// statsPane returns the text of the statistics pane in a rendered
// webhook page, everything from its heading to the next heading on the
// page, with the markup taken out and each run of space made one
// space. A table then reads header by header and row by row, each
// row's label followed by its figures in column order.
func statsPane(t *testing.T, page string) string {
t.Helper()
_, pane, found := strings.Cut(page, ">Statistics</h2>")
require.True(t, found, "the page has no statistics pane")
pane, _, _ = strings.Cut(pane, "<h2")
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
return strings.Join(strings.Fields(pane), " ")
}
// assertStatsTargets checks, for the history seedStatsHistory builds,
// each target's totals and its deliveries finished in the last 24
// hours. The first target has ten deliveries and the replay, the
// second ten; the inactive targets have none and so no row.
func assertStatsTargets(t *testing.T, hist statsHistory) {
t.Helper()
first, second := hist.first, hist.second
assert.Equal(t, map[string]database.TargetTotals{
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
second: {TargetID: second, Deliveries: 10, Failed: 2},
}, statsTargetTotals(t, hist.webhookDB))
lastDay, err := handlers.FinishedByTargetForTest(
hist.webhookDB, time.Now().Add(-24*time.Hour),
)
require.NoError(t, err)
assert.ElementsMatch(t, []handlers.TargetFinished{
{TargetID: first, Delivered: 1, Failed: 1},
{TargetID: second, Failed: 2},
}, lastDay)
}
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
// history seedStatsHistory builds, once retention has removed the
// oldest event: each figure after its label, in its column.
func assertStatsPaneAfterPrune(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
hist statsHistory,
) {
t.Helper()
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
"Targets 6 (2 active) "+
"Deliveries in progress 13 "+
"Last event "+lastEvent+" "+
"Retention 14 days")
assert.Contains(t, pane, "Lifetime Within retention "+
"Events 10 9 "+
"Deliveries 21 18 "+
"Failures 5 4")
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
"Events 7 8 "+
"Failures 1 3 "+
"Failure percentage 50.0% 75.0%")
}
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
// the statistics pane shows for the history seedStatsHistory builds,
// and each target's totals and recent figures, before and after the
// real retention reaper removes the oldest event.
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
hist := seedStatsHistory(t, h, sess, db, dbMgr)
first, second := hist.first, hist.second
stats := h.WebhookStatsForTest(hist.webhook.ID)
require.NotNil(t, stats)
assert.Equal(t, 12, stats.Entrypoints)
assert.Equal(t, 11, stats.ActiveEntrypoints)
assert.Equal(t, 6, stats.Targets)
assert.Equal(t, 2, stats.ActiveTargets)
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
stats.Lifetime)
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
assert.Equal(t, int64(15), stats.InProgress)
require.NotNil(t, stats.LastEventAt)
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
assert.Equal(t, handlers.RecentWindow{
Events: 7, Delivered: 1, Failed: 1,
}, stats.Last10Minutes)
assert.Equal(t, handlers.RecentWindow{
Events: 8, Delivered: 1, Failed: 3,
}, stats.Last24Hours)
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
assertStatsTargets(t, hist)
// Retention removes the oldest event with its three deliveries:
// the first target's failed one and the pending replay, and the
// second target's pending one.
statsPrune(t, db, dbMgr, log, hist.webhookDB)
after := h.WebhookStatsForTest(hist.webhook.ID)
require.NotNil(t, after)
assert.Equal(t, stats.Lifetime, after.Lifetime)
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
after.WithinRetention)
assert.Equal(t, int64(13), after.InProgress)
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
DeliveriesRemoved: 2, FailedRemoved: 1,
},
second: {
TargetID: second, Deliveries: 10, Failed: 2,
DeliveriesRemoved: 1,
},
}, statsTargetTotals(t, hist.webhookDB))
assertStatsPaneAfterPrune(t, h, sess, hist)
}
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event, the pane still shows when the last
// one arrived rather than "none".
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
require.Equal(t, http.StatusOK,
postReceiver(t, receiverRouter(h), path))
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, 1)
arrived := events[0].CreatedAt
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
stats := h.WebhookStatsForTest(wh.ID)
require.NotNil(t, stats)
require.NotNil(t, stats.LastEventAt)
assert.True(t, arrived.Equal(*stats.LastEventAt))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane,
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
}
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
// last event arrived in UTC, as the event list does, when the time was
// stored in another zone, as it is on a host whose local time is not
// UTC.
func TestWebhookStats_LastEventInUTC(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
time.FixedZone("EST", -5*60*60))
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{Events: 1, LastEventAt: &arrived}))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
}
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
// pane itself, not only the line at the foot of the page, shows the
// webhook's retention period, for a finite one and for forever.
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
tests := []struct {
retentionDays int
want string
}{
{30, "30 days"},
{database.RetentionForeverDays, "forever"},
}
for _, tt := range tests {
wh := &database.Webhook{
UserID: deleteTestUserID,
Name: "retention",
RetentionDays: tt.retentionDays,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(wh).Error)
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Retention "+tt.want)
}
}
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
// database has never been opened: every count is zero, the
// percentages are a dash, and showing the page does not create the
// database.
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Last event none")
assert.Contains(t, pane, "Failure percentage — —")
assert.False(t, dbMgr.DBExists(wh.ID))
}
// TestRecentWindow_FailurePercent pins the percentage: failed
// deliveries out of all that finished in the window.
func TestRecentWindow_FailurePercent(t *testing.T) {
t.Parallel()
tests := []struct {
window handlers.RecentWindow
want string
}{
{handlers.RecentWindow{}, "—"},
{handlers.RecentWindow{Events: 4}, "—"},
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
{handlers.RecentWindow{Failed: 2}, "100.0%"},
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
}
}
+1 -1
View File
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
t.Parallel()
for _, s := range []string{
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
} {
assert.Equal(t, s, logfield.Truncate(s, budget))
}
+20 -27
View File
@@ -3,18 +3,17 @@
// deliveries are attempted, how they end, how long they take, how
// deep the queues are, and how many circuit breakers are open.
//
// It also builds the registry the authenticated /metrics route
// serves. In production, these collectors, the inbound HTTP metrics
// recorded in internal/middleware, and the Go runtime and process
// collectors all register on that one registry, never on Prometheus's
// global default.
// The inbound HTTP metrics come from the go-http-metrics recorder in
// internal/middleware and land on prometheus.DefaultRegisterer. These
// collectors register there too, so both surfaces are gathered by the
// one promhttp handler mounted on the authenticated /metrics route.
package metrics
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promauto"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -58,31 +57,25 @@ var knownTargetTypes = []database.TargetType{
database.TargetTypeSlack,
}
// NewRegistry returns the registry /metrics serves, carrying the Go
// runtime and process collectors that Prometheus's global default
// registry carries, so the go_* and process_* series stay in the
// scrape.
// defaultSet is the process-wide metric set, registered on the same
// registry the HTTP middleware and the /metrics handler already use.
// It is built on first use rather than in an init so that a test
// binary that never touches metrics never registers them.
//
// A registry of its own, rather than the global default, is what lets
// two dependency graphs in one process — two tests, say — each
// register their collectors without the second registration
// panicking.
func NewRegistry() *prometheus.Registry {
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(
collectors.ProcessCollectorOpts{},
),
)
//nolint:gochecknoglobals // one process-wide registration, by design
var defaultSet = sync.OnceValue(func() *Set {
return New(prometheus.DefaultRegisterer)
})
return reg
// Default returns the process-wide metric set.
func Default() *Set {
return defaultSet()
}
// Set is one registered group of webhooker's delivery collectors.
// Production builds one on the registry /metrics serves; tests build
// one on a registry of their own so they can gather what their own
// deliveries recorded.
// Production uses the single Default set; tests build their own
// against a private registry so assertions are not disturbed by
// deliveries other tests are making concurrently.
type Set struct {
eventsReceived prometheus.Counter
deliveryAttempts *prometheus.CounterVec
@@ -100,7 +93,7 @@ type Set struct {
// New registers a full set of delivery collectors on reg and returns
// it. It panics if reg already holds them, which is the intended
// behaviour for a duplicate registration.
func New(reg *prometheus.Registry) *Set {
func New(reg prometheus.Registerer) *Set {
factory := promauto.With(reg)
s := &Set{
+8 -8
View File
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
)
router.HandleFunc(
"/h/{uuid}",
"/webhook/{uuid}",
func(w http.ResponseWriter, r *http.Request) {
// Stands in for the real handler: an unknown entrypoint
// UUID 404s, a known one succeeds.
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
assertFloodIsBounded(
t,
func(i int) string {
return "/h/" + attackerMarker +
return "/webhook/" + attackerMarker +
strings.Repeat("x", i) + "?q=" + attackerMarker
},
http.StatusNotFound,
"/h/{uuid}",
"/webhook/{uuid}",
)
}
@@ -346,10 +346,10 @@ type sizeCase struct {
func lineSizeCases() map[string]sizeCase {
cases := map[string]sizeCase{
"oversized path segment": {
target: "/h/" + attackerMarker +
target: "/webhook/" + attackerMarker +
strings.Repeat("x", oversizedSegmentBytes),
wantStatus: http.StatusNotFound,
wantURL: "/h/{uuid}",
wantURL: "/webhook/{uuid}",
bound: maxLineBytes,
},
// /.well-known/healthcheck answers 200 to anyone and has no
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
router := accessLogRouter(m)
assert.Equal(
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
)
// The path resolved against a stored entrypoint, so it stays. The
// query never does: see TestAccessLog_UnauthenticatedSuccess...
entries := accessLogEntries(t, buf)
require.Len(t, entries, 1)
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
assert.NotContains(t, buf.String(), "src=ci")
}
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
assert.Equal(
t,
http.StatusNotFound,
get(t, router, "/h/"+attackerMarker),
get(t, router, "/webhook/"+attackerMarker),
)
entries := accessLogEntries(t, buf)
+4 -6
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token are logged and answered by forbidden, which must write the 403.
// token receive a 403 Forbidden response.
//
// The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,14 +36,12 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF(
forbidden http.Handler,
) func(http.Handler) http.Handler {
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an
// unauthenticated client: a POST with no token to
// /hook/<any length of any text>/edit lands here. The
// /source/<any length of any text>/edit lands here. The
// method and path are capped against the same budgets as
// the access log. remote_addr is set by net/http from the
// accepted connection rather than by the client, and
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
"remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r),
)
forbidden.ServeHTTP(w, r)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
})
key := m.session.GetKey()
+9 -15
View File
@@ -18,12 +18,6 @@ import (
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext(
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
+2 -1
View File
@@ -10,7 +10,8 @@ import (
// MetricsMiddlewareForTest builds the metrics recording middleware
// against a caller-supplied recorder, so a test can gather from its
// own Prometheus registry without building a whole Middleware.
// own Prometheus registry rather than the process-wide default one
// that Middleware.Metrics uses.
func MetricsMiddlewareForTest(
rec httpmetrics.Recorder,
) func(http.Handler) http.Handler {
+4 -6
View File
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
) http.Handler {
t.Helper()
return m.CSRF(http.HandlerFunc(forbidden))(
unreachable(t),
)
return m.CSRF()(unreachable(t))
},
send: postNoToken,
wantStatus: http.StatusForbidden,
@@ -385,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
t, newHandler,
)
path := "/hook/" +
path := "/source/" +
oversizedPathSegment(fill) + "/edit"
assert.Equal(
@@ -436,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/hook/"+
"/source/"+
oversizedPathSegment(fill)+"/login",
nil,
)
@@ -501,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
http.StatusRequestEntityTooLarge,
postOversize(
h,
"/hook/"+segment(i)+"/edit",
"/source/"+segment(i)+"/edit",
),
)
}
+4 -4
View File
@@ -108,10 +108,10 @@ type failureWindow struct {
//
// A limiter that spends budget on arrival cannot protect a
// single-admin product: behind the reverse proxy the deployment
// requires, when TRUSTED_PROXIES does not cover it, every client
// keys on the proxy, so a stranger trickling five POSTs a minute
// keeps the one bucket full and the operator's own correct password
// is answered 429 forever. There is no second administrative path.
// requires, with TRUSTED_PROXIES unset, every client keys on the
// proxy, so a stranger trickling five POSTs a minute keeps the one
// bucket full and the operator's own correct password is answered 429
// forever. There is no second administrative path.
//
// So budget is spent only by a FAILED verification. A correct
// password is never throttled, whatever the counters say, which is
+9 -8
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi"
httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
)
@@ -39,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
//
// The pattern is what bounds the label's domain to the routes the
// service registers. The path does not bound it at all — every byte
// after /h/ is client-chosen, so labelling by path lets any
// after /webhook/ is client-chosen, so labelling by path lets any
// unauthenticated client mint permanent series at will, and publishes
// the entrypoint UUID (the receiver's only credential) in the scrape
// while doing it.
@@ -150,17 +151,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
var _ httpmetrics.Recorder = boundedLabelRecorder{}
// Metrics returns middleware that records Prometheus HTTP metrics
// with the Middleware's one recorder, which New builds on the registry
// the /metrics route serves and NewForTest on a registry of its own.
// Every call reuses that recorder, so any number of routers can
// install it.
// Metrics returns middleware that records Prometheus HTTP metrics on
// the default registry, which is the one the /metrics route gathers.
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
return metricsMiddleware(s.metricsRecorder)
return metricsMiddleware(
prommetrics.NewRecorder(prommetrics.Config{}),
)
}
// metricsMiddleware builds the recording middleware against a given
// recorder, so tests can gather from a registry of their own.
// recorder, so tests can gather from a registry of their own instead
// of the process-wide default.
func metricsMiddleware(
rec httpmetrics.Recorder,
) func(http.Handler) http.Handler {
+1 -1
View File
@@ -50,7 +50,7 @@ func realMethods() []string {
// dimension varying, so any series growth a probe produces is the
// method label's and nothing else's.
func methodProbePath() string {
return "/h/" + uuid.NewString()
return "/webhook/" + uuid.NewString()
}
// inventedMethods returns n distinct RFC 9110 method tokens that no
+9 -34
View File
@@ -28,7 +28,7 @@ const (
// receiverRoutePattern is the one handler label every receiver
// request must produce, however the client varies the path.
receiverRoutePattern = "/h/{uuid}"
receiverRoutePattern = "/webhook/{uuid}"
// okRoute is a static route used to pin that the response-writer
// interceptor still reports status and size after the handler id
@@ -57,8 +57,9 @@ const (
// Server.setupWebhookRoutes inside it. That ordering is the whole
// defect, so a test that flattens it would prove nothing.
//
// The recorder writes to a registry of the test's own, so each test
// observes only its own traffic.
// The recorder writes to a registry of the test's own rather than the
// process-wide default one, so each test observes only its own
// traffic.
func metricsTestRouter(
t *testing.T,
receiverLimit int,
@@ -142,13 +143,13 @@ func drivePaths(
return drive(t, h, probes)
}
// receiverPaths returns n distinct /h/ paths, each naming a
// receiverPaths returns n distinct /webhook/ paths, each naming a
// fresh UUID exactly as an unauthenticated flood would.
func receiverPaths(n int) []string {
paths := make([]string, 0, n)
for range n {
paths = append(paths, "/h/"+uuid.NewString())
paths = append(paths, "/webhook/"+uuid.NewString())
}
return paths
@@ -219,7 +220,7 @@ func keys(set map[string]struct{}) []string {
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
// assertion the issue asks for: N requests to N distinct
// /h/<uuid> paths must produce exactly ONE handler label, the
// /webhook/<uuid> paths must produce exactly ONE handler label, the
// route pattern. Before the fix this produced N of them.
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
t.Parallel()
@@ -249,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
// The scrape must not republish the UUIDs it was driven with.
// They are the receiver's only credential.
for _, p := range paths {
id := strings.TrimPrefix(p, "/h/")
id := strings.TrimPrefix(p, "/webhook/")
for label := range labels {
assert.NotContains(
t, label, id,
@@ -353,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
if i%2 == 0 {
paths = append(paths, "/"+id)
} else {
paths = append(paths, "/h/"+id+"/"+id)
paths = append(paths, "/webhook/"+id+"/"+id)
}
}
@@ -454,29 +455,3 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
"the interceptor must still count written bytes",
)
}
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
// by NewForTest has a recorder of its own: its Metrics() serves a
// request instead of panicking, and a second one does not collide
// with the first.
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
t.Parallel()
log := slog.New(slog.DiscardHandler)
cfg := &config.Config{Environment: "prod"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(okBody))
})
for range 2 {
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, okRoute, nil,
)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
}
+4 -44
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"sync"
"time"
@@ -14,9 +13,6 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/prometheus/client_golang/prometheus"
httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals"
@@ -156,7 +152,6 @@ type MiddlewareParams struct {
Globals *globals.Globals
Config *config.Config
Session *session.Session
Registry *prometheus.Registry
}
// Middleware provides HTTP middleware for logging, CORS, auth, and
@@ -166,14 +161,6 @@ type Middleware struct {
params *MiddlewareParams
session *session.Session
// metricsRecorder records the inbound HTTP metrics. New builds
// it on the registry /metrics serves, NewForTest on a registry
// of its own. Either way it is built once per Middleware and
// Metrics reuses it, because building it registers its
// collectors, and a second registration on the same registry
// panics.
metricsRecorder httpmetrics.Recorder
// loginGuard counts failed credential verifications and bounds
// concurrent password hashing. It is built on first use so that
// every construction path gets one; see guard().
@@ -192,9 +179,6 @@ func New(
s.params = &params
s.log = params.Logger.Get()
s.session = params.Session
s.metricsRecorder = prommetrics.NewRecorder(
prommetrics.Config{Registry: params.Registry},
)
return s, nil
}
@@ -273,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
//
// 3xx and 4xx responses get the chi route pattern instead. Those are
// the outcomes an unauthenticated client drives for free: 404 or 429
// on any invented /h/ path, 303 to the login page on any
// on any invented /webhook/ path, 303 to the login page on any
// invented /user/ path. Logging the concrete URL there lets a flood
// write attacker-chosen text, of attacker-chosen length, into the
// operator's log at one line per request. The pattern comes from the
@@ -382,30 +366,6 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
}
}
// NextParam is the query parameter on the login redirect, and the
// login form field, that holds the page to return to after login.
const NextParam = "next"
// MaxNextBytes bounds the NextParam value. The login page writes it
// into its form, and every page is rendered into a buffer first, so
// without a bound a request would choose the size of that buffer.
const MaxNextBytes = 2048
// loginURL is the login page RequireAuth redirects to. A GET carries
// its own path and query in NextParam so that logging in returns to
// it, unless they are longer than MaxNextBytes; loginDestination in
// the handlers package checks whether that value is safe to follow.
// Other methods carry nothing, since a redirect cannot repeat them.
func loginURL(r *http.Request) string {
next := r.URL.RequestURI()
if r.Method != http.MethodGet || len(next) > MaxNextBytes {
return "/pages/login"
}
return "/pages/login?" + url.Values{NextParam: {next}}.Encode()
}
// RequireAuth returns middleware that checks for a valid session.
// Unauthenticated users are redirected to the login page.
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
@@ -421,7 +381,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
"error", err,
)
http.Redirect(
w, r, loginURL(r), http.StatusSeeOther,
w, r, "/pages/login", http.StatusSeeOther,
)
return
@@ -449,7 +409,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
),
)
http.Redirect(
w, r, loginURL(r), http.StatusSeeOther,
w, r, "/pages/login", http.StatusSeeOther,
)
return
@@ -600,7 +560,7 @@ func (s *Middleware) MaxBodySize(
// internal/server/routes.go), so an
// unauthenticated client reaches it with a path
// of its own choosing and its own length —
// POST /hook/<8 KB>/edit with an oversize
// POST /source/<8 KB>/edit with an oversize
// declared Content-Length costs nothing to
// send. At WARN, on by default, that is a
// write into the operator's log sized by the
+3 -77
View File
@@ -338,76 +338,6 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
"unauthenticated request",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
}
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
// redirect carries: a GET's path and query, so logging in can return
// there, and nothing for a POST, which a redirect cannot repeat.
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
get := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/hook/abc/events?page=2", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(
t, "/pages/login?next=%2Fhook%2Fabc%2Fevents%3Fpage%3D2",
w.Header().Get("Location"),
)
post := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/hook/abc/delete", nil,
)
w = httptest.NewRecorder()
handler.ServeHTTP(w, post)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
// TestRequireAuth_LoginRedirectLeavesOutALongURL: a GET whose path
// and query are longer than the login page accepts goes to the plain
// login page, so a long URL does not make the redirect long.
func TestRequireAuth_LoginRedirectLeavesOutALongURL(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
atLimit := "/" + strings.Repeat("a", middleware.MaxNextBytes-1)
get := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, atLimit, nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(
t, "/pages/login?next=%2F"+atLimit[1:],
w.Header().Get("Location"),
)
get = httptest.NewRequestWithContext(
context.Background(), http.MethodGet, atLimit+"a", nil,
)
w = httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
@@ -513,9 +443,7 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
"unauthenticated session",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
// --- RequireAuth Session Expiry Tests ---
@@ -613,9 +541,7 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
"handler should not run for an idle-expired session",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
assert.Empty(
t, sessionCookies(w),
"an expired session must not be refreshed",
@@ -714,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/hooks", nil,
http.MethodGet, "/sources", nil,
)
w := httptest.NewRecorder()
+5 -4
View File
@@ -63,7 +63,7 @@ const (
// receiverAggregateMultiplier scales the configured
// per-entrypoint receiver limit into the aggregate limit one
// client IP may spend across the whole /h/* route. Ten
// client IP may spend across the whole /webhook/* route. Ten
// entrypoints' worth lets a single sender address drive several
// entrypoints at their full rate, while still capping what one
// address costs the unauthenticated receiver.
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
return prefix.String()
}
// isTrustedProxy reports whether addr belongs to a network in
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
// isTrustedProxy reports whether addr belongs to a network the
// operator listed in TRUSTED_PROXIES. The list is empty by default,
// so by default nothing is trusted.
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
for _, prefix := range m.params.Config.TrustedProxies {
if prefix.Contains(addr) {
@@ -389,7 +390,7 @@ func (m *Middleware) postRateLimit(
// It is Config.ReceiverRateLimit requests per minute.
//
// That limit alone bounds nothing in aggregate. The route pattern
// /h/{uuid} matches any single segment, so a client that
// /webhook/{uuid} matches any single segment, so a client that
// invents a fresh path per request mints a fresh bucket per request
// and never refills one — and every such request still reaches the
// handler's entrypoint lookup before it 404s. The outer limit is
+21 -22
View File
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// pass.
for i := range limit {
w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a",
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The next request over the limit is rejected with a 429
// carrying a Retry-After header.
w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a",
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.NotEmpty(
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The same IP is not limited on a different entrypoint.
w = receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-b",
handler, "9.9.9.9:1234", "/webhook/uuid-b",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// A different IP is not limited on the same entrypoint.
w = receiverPost(
handler, "8.8.8.8:1234", "/h/uuid-a",
handler, "8.8.8.8:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
const (
limit = 2
ip = "7.7.7.7:1234"
path = "/h/uuid-c"
path = "/webhook/uuid-c"
)
handler := receiverLimitedHandler(t, limit)
@@ -384,8 +384,8 @@ const (
// trustedProxyCIDR is the proxy network the forwarded-path
// tests configure, and trustedPeer an address inside it. A
// production deployment is required to run behind a reverse
// proxy that TRUSTED_PROXIES covers, either by the default or by
// a set value, so this is the shape the bucketing has to hold in.
// proxy with TRUSTED_PROXIES set, so this is the shape the
// bucketing has to hold in.
trustedProxyCIDR = "10.0.0.0/8"
trustedPeer = "10.0.0.1:44444"
)
@@ -426,8 +426,8 @@ func assertSharedBucket(
}
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
// this gating exists for: from a peer that is not a trusted
// proxy, a client that rotates a forwarded header on every
// this gating exists for: with no trusted proxies configured (the
// default), a client that rotates a forwarded header on every
// request must stay in one bucket. If forwarded headers were
// trusted unconditionally, each spoofed value would mint a fresh
// bucket and the limit would stop no one.
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
// none of them shares a per-entrypoint bucket with another.
for i := range aggregate {
w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", i),
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
}
w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
)
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"a client must not be able to raise its aggregate rate "+
"against /h/* by varying the path",
"against /webhook/* by varying the path",
)
// The aggregate limit is still per client IP: exhausting one
// address must not throttle another.
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
assert.Equal(
t, http.StatusOK, w.Code,
"a different client IP must not be affected",
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
// limit requests are served; the rest are rejected by the
// per-entrypoint limiter but still count against the aggregate.
for i := range aggregate {
w := receiverPost(handler, ip, "/h/exhausted")
w := receiverPost(handler, ip, "/webhook/exhausted")
want := http.StatusTooManyRequests
if i < limit {
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
)
}
w := receiverPost(handler, ip, "/h/never-used")
w := receiverPost(handler, ip, "/webhook/never-used")
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"requests rejected per entrypoint must still count "+
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
const (
limit = 3
peer = "203.0.113.10:44444"
path = "/h/uuid-d"
path = "/webhook/uuid-d"
)
handler := receiverLimitedHandler(t, limit)
@@ -1097,9 +1097,8 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
// that arrives from trustedPeer — a configured trusted proxy — and
// names forwarded as its client in X-Forwarded-For. That is the
// production path: a deployment is required to run behind a reverse
// proxy that TRUSTED_PROXIES covers, either by the default or by a
// set value, so the forwarded address, not the peer, is what the
// limiters bucket on there.
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
// peer, is what the limiters bucket on there.
func forwardedKeyFor(
t *testing.T, m *middleware.Middleware, forwarded string,
) string {
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
//
// Every existing test of this fallback uses an IPv4 proxy, where
// bucketKey is the identity function, so replacing the call with
// peer.String() leaves the whole suite green. Only addresses inside
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
// this pins behaviour rather than fixing a defect.
// peer.String() leaves the whole suite green. Only operator-listed
// addresses reach this line and the fallback is fail-closed, so this
// pins behaviour rather than fixing a defect.
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
t *testing.T,
) {

Some files were not shown because too many files have changed in this diff Show More