Author SHA1 Message Date
clawbot 444ecb401c Name each database target's archive for its webhook and target (closes #376)
check / check (push) Successful in 3m13s
Each database target now has its own archive file,
archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, instead of one
archive-WEBHOOKID.db per webhook. delivery.ArchiveFileName builds the
name: each name is lowercased, keeps ASCII letters and digits, turns
every other run of characters into one dash, and is cut to 40
characters.

A change of webhook or target name renames its archive files under the
archive writer's lock, before the new name is saved, and back again if
the save fails. A rename never replaces a file, and one that fails part
way moves back what it moved. Deleting a target evicts only that
target's writer. Archive files are never deleted, and nothing looks for
files under the old name.

Model: opus-5-5
2026-10-02 08:33:51 +00:00
179 changed files with 1848 additions and 13113 deletions
+4 -4
View File
@@ -28,10 +28,10 @@ jobs:
- name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which
# invalidates the `COPY . .` layer of every check stage: a commit
# that was never linted, format-checked, stylesheet-checked, tested
# and built cannot report success from cache.
# 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
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, make test, make build)
- name: Build Docker image (runs make check)
run: script/cibuild
Binary file not shown.
Binary file not shown.
+2 -40
View File
@@ -25,45 +25,8 @@ COPY . .
# would need a docker daemon inside the build. Keep these steps in step with
# Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check
RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
# tailwindcss, from static/css/input.css and the files its @source lines
# name. `make css` (script/css) writes it out from the css-output stage.
# The css-check stage fails when the committed file differs from what is
# generated; `make check` runs it, and so does the build stage below.
#
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
# TARGETARCH, set by docker, is the architecture being built for.
FROM tailwind-${TARGETARCH} AS css
WORKDIR /src
COPY . .
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
FROM scratch AS css-output
COPY --from=css /out/tailwind.css /
# Both files are split after each "}", one rule per line, so that when they
# differ the diff shows the rules that differ.
FROM css AS css-check
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
exit 1; \
}
RUN --network=none golangci-lint run --config .golangci.yml ./...
# Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
@@ -71,9 +34,8 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Depend on the lint and stylesheet check stages passing
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /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.
-29
View File
@@ -1,29 +0,0 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+1 -7
View File
@@ -31,13 +31,7 @@ FROM deps AS lint
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml
# --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
RUN --network=none golangci-lint run --config .golangci.yml ./...
+2 -8
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target
.DEFAULT_GOAL := check
@@ -33,9 +33,6 @@ assets:
test:
@script/test
test-browser:
@script/test-browser
lint:
@script/lint
@@ -74,7 +71,4 @@ hooks:
@script/install-precommit
css:
@script/css
css-check:
@script/css-check
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
+208 -467
View File
File diff suppressed because it is too large Load Diff
+7 -27
View File
@@ -8,7 +8,6 @@ import (
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
@@ -38,19 +37,17 @@ import (
// hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the HTTP drain plus the Sentry flush that
// follows it in the same hook, and each is clamped to the stop
// close. That hook is the 3s HTTP drain plus the Sentry flush that
// follows it in the same hook, so the flush is clamped to the stop
// context's remaining time less server.TailHookReserve rather than
// running for its own fixed 3s and 2s; the reserve is what the tail
// hooks live on, and they are microsecond-scale in normal operation.
// running for its own fixed 2s; the reserve is what the tail hooks
// live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length and every amount of budget the hooks
// before the server may already have spent.
// across every drain length.
//
// This does not make the database close unconditional: the
// ArchiveSweeper and RetentionReaper hooks run before the server.
// What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget.
// ArchiveSweeper and RetentionReaper hooks run before the server
// and can still consume the whole budget on their own.
const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make
@@ -171,19 +168,6 @@ func run(stderr io.Writer) int {
func newApp() *fx.App {
return fx.New(
fx.StopTimeout(stopTimeout),
// fx's own events go through the service's logger, not fx's
// console logger on standard error. The exception is a failure
// before this logger is built, such as an invalid configuration
// value, which fx's console logger still prints there. fx holds
// its events back until this logger is built and then replays
// them, so it takes the configuration, which sets the level
// DEBUG=true asks for: without it the replay would run at INFO
// and drop every record of how the graph was built.
fx.WithLogger(
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
return logger.NewFxLogger(l.Get())
},
),
fx.Provide(
globals.New,
logger.New,
@@ -212,10 +196,6 @@ func newApp() *fx.App {
// or renaming a webhook or target reaches its archive
// files.
func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.CircuitBreakers so
// the pages can show a target whose deliveries are
// paused.
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
server.New,
),
fx.Invoke(
+9 -129
View File
@@ -2,19 +2,12 @@ package main
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
@@ -37,7 +30,6 @@ const dockerStopGrace = 10 * time.Second
// fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout()
@@ -46,100 +38,6 @@ func TestNewApp_StopTimeout(t *testing.T) {
require.Less(t, got, dockerStopGrace)
}
// freePort returns a loopback TCP port that was free a moment ago, by
// taking one and releasing it.
func freePort(t *testing.T) int {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok, "listener is not TCP")
require.NoError(t, l.Close())
return addr.Port
}
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
// runs, with DEBUG=true, and reads back what reached the service's
// logger. fx's own events must arrive there as structured records:
// the start at INFO, and at DEBUG the records of how the graph was
// built.
//
// fx holds its events back until its logger is built and then replays
// them all at once, so the earliest of them arriving shows the replay
// ran at DEBUG: that globals.New was provided, which fx records before
// anything is built, and the run of logger.New, which happens before
// the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true")
// internal/logger writes to whatever os.Stdout is when it builds
// its handler. A file is not a terminal, so that handler is the
// JSON one the service uses in production.
out, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stdout := os.Stdout
os.Stdout = out
t.Cleanup(func() {
os.Stdout = stdout
_ = out.Close()
})
app := newApp()
require.NoError(t, app.Start(t.Context()))
require.NoError(t, app.Stop(t.Context()))
_, err = out.Seek(0, io.SeekStart)
require.NoError(t, err)
written, err := io.ReadAll(out)
require.NoError(t, err)
type record struct {
Level string `json:"level"`
Msg string `json:"msg"`
Name string `json:"name"`
Constructor string `json:"constructor"`
}
var records []record
for line := range strings.Lines(string(written)) {
var r record
// The first-boot banner is plain text, not a record.
if json.Unmarshal([]byte(line), &r) == nil {
records = append(records, r)
}
}
const pkg = "sneak.berlin/go/webhooker/internal/"
info := slog.LevelInfo.String()
debug := slog.LevelDebug.String()
assert.Contains(t, records, record{Level: info, Msg: "started"})
assert.Contains(t, records, record{
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
})
assert.Contains(t, records, record{
Level: debug, Msg: "run", Name: pkg + "logger.New()",
})
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
assert.Contains(t, records, record{
Level: debug, Msg: "initialized custom fxevent.Logger",
})
}
// TestRunRefusesLockedDataDir pins what an operator's second start
// does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds —
@@ -252,40 +150,22 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme.
//
// Nor does the hook start on a full budget: the ArchiveSweeper and
// RetentionReaper hooks run before it, and whatever they spent is
// gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend
// nothing of what is left. A drain that starts on the full budget
// must still get all of ShutdownTimeout, so a smaller stopTimeout
// cannot silently shorten every drain.
//
// Shrinking either budget, or unbounding the drain or the flush
// again, must fail here rather than silently recreating a hook that
// swallows the whole sequence.
// Shrinking either budget, or unbounding the flush again, must fail
// here rather than silently recreating a hook that swallows the
// whole sequence.
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout)
require.Equal(
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
"a drain that starts on the full stop budget is cut short",
)
const step = 10 * time.Millisecond
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
remaining := stopTimeout - spent
longest := max(server.DrainBudget(remaining), 0)
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
for drain := time.Duration(0); drain <= longest; drain += step {
hook := drain + server.SentryFlushBudget(remaining-drain)
require.GreaterOrEqual(
t, remaining-hook, min(remaining, tailHeadroom),
"a %s drain after %s of earlier hooks leaves "+
"the tail hooks short", drain, spent,
)
}
require.LessOrEqual(
t, hook+tailHeadroom, stopTimeout,
"a %s drain leaves the tail hooks short", drain,
)
}
}
+7 -12
View File
@@ -4,8 +4,6 @@ go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
@@ -20,7 +18,7 @@ require (
github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4
@@ -31,17 +29,13 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // 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
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
@@ -50,12 +44,13 @@ require (
github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect
+22 -30
View File
@@ -1,15 +1,14 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -24,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -64,20 +55,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
@@ -94,24 +82,28 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
@@ -119,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+24 -26
View File
@@ -80,7 +80,8 @@ const (
// 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.
// maxPort is the highest valid TCP port number. The lower
// bound (at least 1) is enforced by envPositiveInt.
maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped
@@ -104,7 +105,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
var ErrNonPositiveValue = errors.New("value must be positive")
// ErrInvalidPort is returned when an environment variable holding a
// TCP port number is set to a number outside 1 to 65535.
// TCP port number is set above the valid port range.
var ErrInvalidPort = errors.New("invalid port")
// ErrInvalidCIDR is returned when an environment variable holding a
@@ -148,6 +149,7 @@ type ConfigParams struct {
type Config struct {
DataDir string
Debug bool
MaintenanceMode bool
Environment string
MetricsPassword string
MetricsUsername string
@@ -194,13 +196,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
@@ -362,27 +363,17 @@ func envPositiveInt(
// envPort returns the value of the named environment variable parsed
// as a TCP port number. Returns defaultValue if not set. A set value
// that is unparseable, below 1, or above maxPort is a hard error
// naming the key and the bad value; every out-of-range value wraps
// ErrInvalidPort, including one too large or too small for an int.
// naming the key and the bad value.
func envPort(key string, defaultValue int) (int, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
port, err := envPositiveInt(key, defaultValue)
if err != nil {
return 0, err
}
// strconv.ErrRange means a number too large or too small for an
// int, which is outside the port range as well.
port, err := strconv.Atoi(v)
if err != nil && !errors.Is(err, strconv.ErrRange) {
if port > maxPort {
return 0, fmt.Errorf(
"invalid integer for %s: %q: %w", key, v, err,
)
}
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf(
"%w: %s must be from 1 to %d, got %q",
ErrInvalidPort, key, maxPort, v,
"%w: %s must be at most %d, got %d",
ErrInvalidPort, key, maxPort, port,
)
}
@@ -666,6 +657,11 @@ func loadFromEnv() (*Config, error) {
return nil, err
}
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval,
@@ -715,6 +711,7 @@ func loadFromEnv() (*Config, error) {
return &Config{
DataDir: DataDir(),
Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment,
MetricsUsername: metricsUsername,
MetricsPassword: metricsPassword,
@@ -801,6 +798,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// host can reach the admin UI.
"bindAddress", s.BindAddress,
"debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here
+45 -28
View File
@@ -3,6 +3,7 @@ package config_test
import (
"bytes"
"log/slog"
"os"
"testing"
"time"
@@ -70,12 +71,14 @@ func TestEnvironmentConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.envValue != "" {
t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue,
)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
}
for k, v := range tt.envVars {
@@ -121,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,
@@ -196,11 +194,14 @@ func TestRetentionSweepInterval(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RETENTION_SWEEP_INTERVAL",
))
}
if tt.expectError {
@@ -271,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -335,11 +335,14 @@ func TestSessionIdleTimeout(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"SESSION_IDLE_TIMEOUT",
))
}
if tt.expectError {
@@ -361,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -388,17 +390,20 @@ func TestDefaultDataDir(t *testing.T) {
t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
}
require.NoError(t, os.Unsetenv("DATA_DIR"))
var cfg *config.Config
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -433,9 +438,9 @@ func TestDataDirHelper(t *testing.T) {
t.Run(name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if set != "" {
if set == "" {
require.NoError(t, os.Unsetenv("DATA_DIR"))
} else {
t.Setenv("DATA_DIR", set)
}
@@ -498,11 +503,14 @@ func TestReceiverRateLimit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
}
if tt.expectError {
@@ -526,7 +534,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -614,11 +621,12 @@ func TestTrustedProxies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("TRUSTED_PROXIES", tt.value)
} else {
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
}
if tt.expectError {
@@ -642,7 +650,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -725,11 +732,14 @@ func TestAllowedEgressCIDRs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
} else {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
}
if tt.expectError {
@@ -753,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -797,10 +806,13 @@ func TestEgressAllowlistWarning(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed != "" {
if tt.allowed == "" {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} else {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
}
@@ -933,14 +945,20 @@ func TestMetricsAuthConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.username.set {
t.Setenv("METRICS_USERNAME", tt.username.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_USERNAME"),
)
}
if tt.password.set {
t.Setenv("METRICS_PASSWORD", tt.password.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_PASSWORD"),
)
}
if tt.expectError {
@@ -988,7 +1006,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),
)
+18 -7
View File
@@ -22,6 +22,17 @@ const malformedDotEnv = "PORT 19615\n" +
"this is not = valid ! syntax\n" +
"\"unclosed\n"
// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of
// the test and restores it afterwards. t.Setenv registers the restore;
// the Unsetenv that follows is what the test actually needs, because a
// variable set to the empty string is still present in os.Environ and
// godotenv would refuse to overwrite it.
func unsetDotEnvKey(t *testing.T) {
t.Helper()
t.Setenv(dotEnvKey, "placeholder")
require.NoError(t, os.Unsetenv(dotEnvKey))
}
// writeDotEnv writes contents to a .env file in a fresh temporary
// directory and returns its path.
func writeDotEnv(t *testing.T, contents string) string {
@@ -39,9 +50,9 @@ func writeDotEnv(t *testing.T, contents string) string {
// normally rather than be refused for a file it was never meant to
// have.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
config.ClearEnvForTest(t)
unsetDotEnvKey(t)
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, config.LoadDotEnvFileForTest(absent))
@@ -54,9 +65,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
// reaches the environment, which is the whole reason the file is read
// at all.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) {
config.ClearEnvForTest(t)
unsetDotEnvKey(t)
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
@@ -82,9 +93,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
// reverts to its default; the process used to start that way with no
// log line naming the file at all.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
config.ClearEnvForTest(t)
unsetDotEnvKey(t)
path := writeDotEnv(
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
@@ -132,7 +143,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
config.ClearEnvForTest(t)
unsetDotEnvKey(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(
+103 -81
View File
@@ -1,6 +1,7 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,9 +18,10 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
// Real configuration variables exercised by the config.New tests.
const (
envKeyPort = "PORT"
envKeyDebug = "DEBUG"
envKeyBindAddress = "BIND_ADDRESS"
envKeyPort = "PORT"
envKeyDebug = "DEBUG"
envKeyMaintenanceMode = "MAINTENANCE_MODE"
envKeyBindAddress = "BIND_ADDRESS"
)
// Sample BIND_ADDRESS values used by the tables below.
@@ -120,10 +122,10 @@ func TestEnvBool(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvBoolForTest(
@@ -144,62 +146,17 @@ func TestEnvBool(t *testing.T) {
}
}
// envIntCase is one row of the envPositiveInt and envPort tables.
type envIntCase struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}
// runEnvIntCases runs each row through parse, which is
// envPositiveInt or envPort, with testEnvKey set to the row's value
// or left unset.
func runEnvIntCases(
t *testing.T,
parse func(key string, defaultValue int) (int, error),
defaultValue int,
tests []envIntCase,
) {
t.Helper()
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.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := parse(testEnvKey, defaultValue)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPositiveInt(t *testing.T) {
const defaultValue = 7
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{
name: "unset returns the default integer",
expected: defaultValue,
@@ -236,14 +193,51 @@ func TestEnvPositiveInt(t *testing.T) {
expectError: true,
errIs: config.ErrNonPositiveValue,
},
})
}
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.
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPositiveIntForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPort(t *testing.T) {
const defaultValue = 8080
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{
name: "unset returns the default port",
expected: defaultValue,
@@ -271,14 +265,7 @@ func TestEnvPort(t *testing.T) {
set: true,
value: "0",
expectError: true,
errIs: config.ErrInvalidPort,
},
{
name: "negative is rejected",
set: true,
value: "-1",
expectError: true,
errIs: config.ErrInvalidPort,
errIs: config.ErrNonPositiveValue,
},
{
name: "above the port range is rejected",
@@ -287,14 +274,37 @@ func TestEnvPort(t *testing.T) {
expectError: true,
errIs: config.ErrInvalidPort,
},
{
name: "too large for an int is rejected",
set: true,
value: "99999999999999999999",
expectError: true,
errIs: config.ErrInvalidPort,
},
})
}
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.
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPortForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
// TestEnvBindAddress covers BIND_ADDRESS parsing.
@@ -310,10 +320,10 @@ func TestEnvBindAddress(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvBindAddressForTest(
@@ -476,7 +486,6 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
t.Setenv(tt.key, tt.value)
@@ -595,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
value: "ture",
expectError: true,
},
{
name: "unparseable MAINTENANCE_MODE aborts startup",
key: envKeyMaintenanceMode,
value: "sometimes",
expectError: true,
},
}
}
@@ -638,15 +653,22 @@ func sentryEnvValueCases() []badEnvValueCase {
// break the legitimate unset case: absent variables still get their
// documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
envKeyBindAddress, envKeySentryDSN,
} {
require.NoError(t, os.Unsetenv(key))
}
cfg, err := buildConfig(t)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
// Loopback, not the wildcard: the default must not publish the
// cleartext admin UI and the unauthenticated receiver on every
+3 -2
View File
@@ -1,6 +1,7 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -100,10 +101,10 @@ func TestEnvSentryDSN(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(envKeySentryDSN, tt.value)
} else {
require.NoError(t, os.Unsetenv(envKeySentryDSN))
}
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
-50
View File
@@ -1,50 +0,0 @@
package config
import (
"os"
"strings"
"testing"
)
// ClearEnvForTest unsets every variable in the process environment
// for the rest of the test, so a test sees only the variables it sets
// itself, not whatever the developer's shell exports. When the test
// ends it leaves the environment exactly as it found it: each variable
// it unset is put back, and any variable added since is removed.
func ClearEnvForTest(t *testing.T) {
t.Helper()
present := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
present[key] = true
// t.Setenv registers the restore; the Unsetenv after it is
// what makes the key absent, since a key set to the empty
// string is still present, and godotenv will not overwrite a
// present key.
t.Setenv(key, "")
err := os.Unsetenv(key)
if err != nil {
t.Fatalf("unsetting %s: %v", key, err)
}
}
// A variable the test adds other than through t.Setenv, as loading
// a .env file does, has no restore of its own.
t.Cleanup(func() {
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if present[key] {
continue
}
err := os.Unsetenv(key)
if err != nil {
t.Errorf("unsetting %s: %v", key, err)
}
}
})
}
-36
View File
@@ -1,36 +0,0 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
// after the clear other than through t.Setenv, as a test's .env file
// sets one, is gone once the test ends, so it cannot reach the tests
// that run after it.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
// The outer clear keeps a value of the key exported in the shell
// from making it a variable the inner clear has to put back.
config.ClearEnvForTest(t)
t.Run("loads a .env file after the clear", func(t *testing.T) {
config.ClearEnvForTest(t)
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
})
_, present := os.LookupEnv(dotEnvKey)
assert.False(
t, present,
"a variable set after the clear must not outlive the test",
)
}
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
@@ -120,26 +119,3 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t, second, created, "an existing database is not new",
)
}
// TestZeroLengthDatabase_IsLoggedAsNew covers what
// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a
// zero-length file as an empty database, so a start on one is a first
// start, and it must say so exactly as a start with no file does.
func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm))
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
assert.Contains(
t, out.String(),
`level=WARN msg="created a new, empty database" path=`+path,
)
}
+7 -12
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -202,7 +203,8 @@ func (d *Database) connectTo(dataDir string) error {
// Checked before opening, which creates the file. A DATA_DIR that
// is unexpectedly empty -- its volume not mounted, say -- looks
// exactly like a first start, so a new database is a warning.
created := missingOrEmpty(dbPath)
_, statErr := os.Stat(dbPath)
created := errors.Is(statErr, fs.ErrNotExist)
// Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool
@@ -211,15 +213,13 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil {
d.log.Error(
"failed to open database",
"path", dbPath,
"error", err,
)
return err
}
// Then use it with GORM. Its errors are SQLite's alone and name no
// file, so the path is added to them here.
// Then use it with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{
@@ -229,11 +229,10 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil {
d.log.Error(
"failed to connect to database",
"path", dbPath,
"error", err,
)
return fmt.Errorf("connecting to %s: %w", dbPath, err)
return err
}
d.db = db
@@ -244,12 +243,8 @@ func (d *Database) connectTo(dataDir string) error {
d.log.Info("connected to database", "path", dbPath)
}
err = d.migrate()
if err != nil {
return fmt.Errorf("migrating %s: %w", dbPath, err)
}
return nil
// Run migrations
return d.migrate()
}
func (d *Database) migrate() error {
-25
View File
@@ -1,15 +1,9 @@
package database_test
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
@@ -106,22 +100,3 @@ func TestDatabaseConnection(t *testing.T) {
)
}
}
// TestOpen_UnreadableDatabaseIsNamed pins
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
// read webhooker.db, the error that stops the server and `webhooker
// resetpw` names the file, not only SQLite's own message.
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
))
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.Contains(t, err.Error(), path)
assert.Contains(t, err.Error(), "file is not a database")
}
@@ -199,77 +199,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
"(deleted_at=? AND created_at>?)")
}
// TestResubmitCountUsesItsIndex does the same for the event log's count
// of the events resubmitted from each of a page's events (resubmitCounts
// in the handlers). It passes a full page of 25 ids: with an index on
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
// the deleted_at index from five.
func TestResubmitCountUsesItsIndex(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})
page := make([]string, 25)
for i := range page {
page[i] = uuid.New().String()
}
var counts []struct{ Total int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", page).
Group("resubmitted_from_id").Find(&counts),
"idx_events_resubmitted_from_id "+
"(resubmitted_from_id=? AND deleted_at=?)")
}
// TestEntrypointEventsUseTheirIndex does the same for the webhook
// page's count, for each entrypoint, of the events that arrived on its
// URL since the retention cutoff (addEntrypointEvents in the
// handlers), which must come from the index alone. It passes 25
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
func TestEntrypointEventsUseTheirIndex(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})
entrypoints := make([]string, 25)
for i := range entrypoints {
entrypoints[i] = uuid.New().String()
}
var rows []struct{ Events int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
entrypoints).
Where("created_at >= ?", time.Now()).
Group("entrypoint_id").Find(&rows),
"COVERING INDEX idx_events_entrypoint_id "+
"(entrypoint_id=? AND deleted_at=? AND "+
"resubmitted_from_id=? AND created_at>?)")
}
// assertPlanUses asserts that SQLite's plan for a statement GORM built
// in a dry run, run with the same SQL and arguments GORM would send,
// names each of the given indexes.
+2 -3
View File
@@ -15,7 +15,6 @@ type APIKey struct {
Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations. No model marshals the record it belongs to:
// User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
// Relations
User User `json:"user,omitzero"`
}
+3 -5
View File
@@ -56,10 +56,8 @@ type Delivery struct {
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations. No model marshals the record it belongs to:
// Event.Deliveries and Target.Deliveries lead back here, and the
// JSON could loop.
Event Event `json:"-"`
Target Target `json:"-"`
// Relations
Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
}
+2 -3
View File
@@ -23,7 +23,6 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations. No model marshals the record it belongs to:
// Delivery.DeliveryResults leads back here, and the JSON could loop.
Delivery Delivery `json:"-"`
// Relations
Delivery Delivery `json:"delivery,omitzero"`
}
+2 -3
View File
@@ -15,7 +15,6 @@ type Entrypoint struct {
Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"`
// Relations. No model marshals the record it belongs to:
// Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
}
+10 -16
View File
@@ -19,16 +19,11 @@ type Event struct {
// narrows by a < only on the last column it uses. Its final delete
// has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at.
// DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives. The entrypoint_id
// index, for the webhook page's entrypoint list, has it second too,
// resubmitted_from_id third, and created_at last, which the list
// compares with a range.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
// Request data
Method string `gorm:"not null" json:"method"`
@@ -37,8 +32,8 @@ type Event struct {
ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored, so that the recent events list, which reads only the
// start of each body, knows the whole body's size.
// 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
@@ -47,11 +42,10 @@ type Event struct {
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON.
Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}
-126
View File
@@ -1,126 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
// Each child holds the parent's id as its webhookId, so the parent
// is looked for by its own id field.
parentIDField := `"id":"` + stored.ID + `"`
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, parentIDField)
}
// TestModelsMarshalWithoutTheirParent covers the other references to a
// parent: each model is built with its parent set, and the parent's id
// must not appear in the JSON.
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
parent := database.BaseModel{ID: uuid.New().String()}
cases := []struct {
name string
model any
}{
{
name: "Webhook.User",
model: database.Webhook{User: database.User{BaseModel: parent}},
},
{
name: "APIKey.User",
model: database.APIKey{User: database.User{BaseModel: parent}},
},
{
name: "Delivery.Event",
model: database.Delivery{Event: database.Event{BaseModel: parent}},
},
{
name: "Delivery.Target",
model: database.Delivery{Target: database.Target{BaseModel: parent}},
},
{
name: "DeliveryResult.Delivery",
model: database.DeliveryResult{
Delivery: database.Delivery{BaseModel: parent},
},
},
{
name: "Event.Webhook",
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
},
{
name: "Event.Entrypoint",
model: database.Event{
Entrypoint: database.Entrypoint{BaseModel: parent},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
})
}
}
+4 -4
View File
@@ -31,10 +31,10 @@ type Target struct {
// For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"`
MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}
-37
View File
@@ -52,21 +52,6 @@ func (TargetTotals) TableName() string {
return "target_totals"
}
// EntrypointTotals is one row per entrypoint, created by the first
// event that arrives on its URL: when the newest such event arrived,
// which retention leaves as it is. A resubmitted copy did not arrive
// on the URL and does not change it.
type EntrypointTotals struct {
EntrypointID string `gorm:"type:uuid;primaryKey"`
LastEventAt time.Time `gorm:"not null"`
}
// TableName names the table AddEntrypointTotals updates.
func (EntrypointTotals) TableName() string {
return "entrypoint_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
@@ -112,25 +97,3 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
return nil
}
// AddEntrypointTotals records add.LastEventAt as when the newest event
// arrived on the URL of the entrypoint add.EntrypointID names, creating
// its row the first time. Call it on the transaction that stores the
// event.
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
err := tx.Exec(
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
VALUES (?, ?)
ON CONFLICT (entrypoint_id) DO UPDATE SET
last_event_at = excluded.last_event_at`,
add.EntrypointID, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of entrypoint %s: %w",
add.EntrypointID, err,
)
}
return nil
}
+2 -10
View File
@@ -66,9 +66,8 @@ type Webhook struct {
// must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations. No model marshals the record it belongs to:
// User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
// Relations
User User `json:"user,omitzero"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"`
}
@@ -111,13 +110,6 @@ func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays)
}
// RetentionCutoff returns the time before which this webhook's events
// have expired, as the reaper computes it, and false when the webhook
// retains them forever.
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
return retentionCutoff(now, w.RetentionDays)
}
// RetentionLabel returns the webhook's retention policy as display
// text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string {
+1 -1
View File
@@ -3,7 +3,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, EntrypointTotals) live in
// TargetTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
+17 -9
View File
@@ -184,8 +184,18 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i]
// A missing database has nothing to reap. Restart recovery
// reports a lost one (see WebhookDBManager.GetDB).
// Skip retain-forever webhooks before building any query.
// RetainsForever covers both the RetentionForeverDays
// sentinel and the non-positive values that predate it: the
// sentinel is a positive number, so without this the reaper
// would compute a cutoff a thousand years in the past and
// issue a DELETE matching nothing on every single sweep.
if wh.RetainsForever() {
continue
}
// Nothing to reap if the per-webhook database has never
// been created.
if !r.dbManager.DBExists(wh.ID) {
continue
}
@@ -202,13 +212,6 @@ func (r *RetentionReaper) reapWebhook(
webhookID string,
retentionDays int,
) {
// A retain-forever webhook has no cutoff, so its database is not
// even opened.
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
db, err := r.dbManager.GetDB(webhookID)
if err != nil {
r.log.Error(
@@ -220,6 +223,11 @@ func (r *RetentionReaper) reapWebhook(
return
}
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
deleted, err := reapExpired(ctx, db, cutoff)
if err != nil {
r.log.Error(
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t,
overflowingRetentionDays,
database.RetentionForeverDays,
"the test value must not be treated as retain-forever",
"the test value must not be rescued by the forever skip",
)
webhookID := createWebhook(
-23
View File
@@ -182,29 +182,6 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
requireDatabaseSetOwnerOnly(t, path)
}
// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place
// of -wal or -shm. Beside a -shm directory SQLite opens the database
// read-only without a word, and every write then fails naming no file,
// so the open must stop instead, naming the directory.
func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) {
t.Parallel()
for _, suffix := range []string{"-wal", "-shm"} {
t.Run(suffix, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), database.MainDBFileName)
require.NoError(t, os.Mkdir(path+suffix, 0o700))
_, err := database.OpenSQLite(
path, database.SQLiteModeCreate,
)
require.Error(t, err)
assert.Contains(t, err.Error(), path+suffix)
})
}
}
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
// the fix uses: OpenSQLite now creates the database file itself, and
// must not do so for a caller that asked for an existing database. An
+2 -26
View File
@@ -7,7 +7,6 @@ import (
"io/fs"
"net/url"
"os"
"syscall"
"time"
_ "modernc.org/sqlite" // Pure Go SQLite driver
@@ -94,8 +93,7 @@ const (
const SQLiteFilePerm fs.FileMode = 0o600
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
// touches it, and tightens any sidecar already on disk. A directory in
// place of any of them is an error naming it.
// touches it, and tightens any sidecar already on disk.
//
// The mode has to be settled here rather than by a chmod after opening,
// because SQLite picks it: robust_open substitutes
@@ -145,15 +143,7 @@ func reserveSQLiteFile(path string, create bool) error {
for _, p := range append(
[]string{path}, sqliteSidecarPaths(path)...,
) {
// Chmod accepts a directory, and SQLite opens a database whose
// -shm is one read-only, without a word: every write then
// fails naming no file.
info, err := os.Stat(p)
if err == nil && info.IsDir() {
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
}
err = os.Chmod(p, SQLiteFilePerm)
err := os.Chmod(p, SQLiteFilePerm)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("securing %s: %w", p, err)
}
@@ -162,20 +152,6 @@ func reserveSQLiteFile(path string, create bool) error {
return nil
}
// missingOrEmpty reports whether opening path in SQLiteModeCreate
// would start a new, empty database: the file is not there, or it is
// zero-length, which SQLite opens as an empty database. A file left at
// zero length by an interrupted first start or a truncated copy holds
// as little as a missing one, and must be reported the same way.
func missingOrEmpty(path string) bool {
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return true
}
return err == nil && info.Size() == 0
}
// sqliteSidecarPaths returns the files SQLite maintains beside a
// database under WAL. They carry the same rows as the database itself,
// so a fix that tightens only the main file has fixed nothing.
+4 -11
View File
@@ -102,13 +102,6 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
// 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.
//
// It and seedBareEvents insert 50 rows per statement, not more. The
// SQLite driver looks up each parameter's value by scanning the
// statement's arguments from the first until it reaches that
// parameter's, so the time to bind a statement grows with the square of
// its parameter count: at 500 rows, several thousand parameters, the
// seeding took most of these tests' time under -race.
func seedExpiredEvents(
t *testing.T,
db *gorm.DB,
@@ -145,8 +138,8 @@ func seedExpiredEvents(
)
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
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 {
@@ -155,7 +148,7 @@ func seedExpiredEvents(
}
}
require.NoError(t, db.CreateInBatches(results, 50).Error)
require.NoError(t, db.CreateInBatches(results, 500).Error)
}
// seedBareEvents stores count events created at the given time, with
@@ -179,7 +172,7 @@ func seedBareEvents(
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
require.NoError(t, db.CreateInBatches(events, 500).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
+33 -118
View File
@@ -33,23 +33,10 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type",
)
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// 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, EntrypointTotals).
// running totals of them (EventTotals, TargetTotals).
// Database connections are opened lazily and cached.
type WebhookDBManager struct {
dataDir string
@@ -98,37 +85,34 @@ func NewWebhookDBManager(
return m, nil
}
// GetDB returns the database connection for a webhook, opening it on
// first use.
//
// The file is made by CreateDB when the webhook is created. One that is
// missing or zero-length here means the webhook's events and pending
// deliveries are gone: an empty database is created in its place so
// the webhook keeps receiving, and that is logged as a warning naming
// the file, as a new main database is.
// GetDB returns the database connection for a webhook,
// creating the database file lazily if it doesn't exist.
func (m *WebhookDBManager) GetDB(
webhookID string,
) (*gorm.DB, error) {
return m.getDB(webhookID, false)
}
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// GetDBIf is GetDB, done only when check reports true. check runs under
// the lock DeleteDB holds while it removes the files, so a caller can
// confirm the webhook still exists and open its database with no delete
// in between. The handle is nil when check reports false. check must
// not call the manager.
func (m *WebhookDBManager) GetDBIf(
webhookID string, check func() (bool, error),
) (*gorm.DB, error) {
// Slow path: open the database under the lock, looking in the
// cache again first. A caller that raced another one here then
// waits for its handle instead of opening a second one.
m.mu.Lock()
defer m.mu.Unlock()
ok, err := check()
if err != nil || !ok {
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
return m.getDBLocked(webhookID, false)
m.dbs.Store(webhookID, db)
return db, nil
}
// asGormDB returns a value read from the cache as the database
@@ -146,12 +130,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
return db, nil
}
// CreateDB creates a new webhook's database file and runs
// migrations.
// CreateDB explicitly creates a new per-webhook database file
// and runs migrations.
func (m *WebhookDBManager) CreateDB(
webhookID string,
) error {
_, err := m.getDB(webhookID, true)
_, err := m.GetDB(webhookID)
return err
}
@@ -167,10 +151,7 @@ func (m *WebhookDBManager) DBExists(
}
// DeleteDB closes the connection and deletes the database file
// for a webhook, with its -wal and -shm sidecars. The files are
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
// for a webhook. The file is permanently removed.
func (m *WebhookDBManager) DeleteDB(
webhookID string,
) error {
@@ -189,23 +170,16 @@ func (m *WebhookDBManager) DeleteDB(
}
}
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID)
dbErr := removeFile(path)
sidecarErr := errors.Join(
removeFile(path+"-wal"),
removeFile(path+"-shm"),
)
if dbErr != nil {
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
for _, suffix := range []string{"", "-wal", "-shm"} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"deleting webhook database file %s%s: %w",
path, suffix, err,
)
}
}
m.log.Info(
@@ -216,17 +190,6 @@ func (m *WebhookDBManager) DeleteDB(
return nil
}
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections.
// Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error {
@@ -269,54 +232,6 @@ func (m *WebhookDBManager) DBPath(
return m.dbPath(webhookID)
}
// getDB is GetDB, and CreateDB when isNew is true: the webhook has just
// been created, so a missing file is expected rather than lost.
func (m *WebhookDBManager) getDB(
webhookID string, isNew bool,
) (*gorm.DB, error) {
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
m.mu.Lock()
defer m.mu.Unlock()
return m.getDBLocked(webhookID, isNew)
}
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
// cache again first: a caller that raced another one to the lock then
// gets its handle instead of opening a second one.
func (m *WebhookDBManager) getDBLocked(
webhookID string, isNew bool,
) (*gorm.DB, error) {
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// Checked before opening, which creates the file. See GetDB.
path := m.dbPath(webhookID)
replaced := !isNew && missingOrEmpty(path)
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
if replaced {
m.log.Warn(
"created a new, empty database",
"webhook_id", webhookID,
"path", path,
)
}
m.dbs.Store(webhookID, db)
return db, nil
}
func (m *WebhookDBManager) dbPath(
webhookID string,
) string {
@@ -381,7 +296,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
&EventTotals{}, &TargetTotals{},
)
if err != nil {
_ = sqlDB.Close()
+3 -146
View File
@@ -182,91 +182,17 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
}
require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist
assert.False(t, mgr.DBExists(webhookID))
// Verify the files are actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(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()
// Verify the file is actually gone from disk
dbPath := mgr.DBPath(webhookID)
blockRemoval(t, dbPath)
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(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()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
_, err = os.Stat(dbPath)
assert.True(t, os.IsNotExist(err))
}
func TestWebhookDBManager_LazyCreation(t *testing.T) {
@@ -289,75 +215,6 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
assert.True(t, mgr.DBExists(webhookID))
}
// A webhook's database is made by CreateDB along with the webhook. One
// that GetDB finds missing or zero-length has lost the webhook's events
// and pending deliveries, so the empty database made in its place is
// logged as a warning naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
// reopening a database that is there, log no such warning.
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
t.Parallel()
const created = `level=WARN msg="created a new, empty database"`
open := func(
t *testing.T, prepare func(*database.WebhookDBManager, string),
) (string, string) {
t.Helper()
var logs bytes.Buffer
mgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)),
)
webhookID := uuid.New().String()
prepare(mgr, webhookID)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
return logs.String(),
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
}
t.Run("missing", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(*database.WebhookDBManager, string) {},
)
assert.Contains(t, logs, created+fields)
})
t.Run("zero-length", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, os.WriteFile(
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
))
},
)
assert.Contains(t, logs, created+fields)
})
t.Run("created with the webhook, then reopened", func(t *testing.T) {
t.Parallel()
logs, _ := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, mgr.CreateDB(webhookID))
require.NoError(t, mgr.CloseAll())
},
)
assert.NotContains(t, logs, created)
})
}
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
t.Parallel()
+2 -15
View File
@@ -45,13 +45,8 @@ type ArchiveSweeper struct {
eng *Engine
log *slog.Logger
interval time.Duration
// cancel needs no lock: fx calls the stop hook only after the
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc
wg sync.WaitGroup
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewArchiveSweeper creates the archive sweeper and registers
@@ -168,18 +163,10 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target
err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error
if err != nil {
// The app stopping as a sweep starts cancels the listing.
// Stopping is not a failure, so it must not produce an
// error line.
if ctx.Err() != nil {
return
}
s.log.Error(
"archive sweep: failed to list database targets",
"error", err,
+3 -67
View File
@@ -1,11 +1,9 @@
package delivery_test
import (
"bytes"
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
@@ -22,7 +20,6 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
const (
@@ -71,8 +68,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
@@ -170,8 +166,7 @@ func (env *archiveEnv) seedArchiveRows(
require.NoError(t, err)
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
@@ -230,8 +225,7 @@ func countArchivedRows(path string) (int64, error) {
defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
if err != nil {
return 0, err
@@ -687,64 +681,6 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
)
}
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
// closes the handle it finds open before it reopens the file.
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
// close, the reopen replaces the handle without closing it, the
// sweep then closes only the new one, and one connection leaks per
// archive per sweep.
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(time.Hour))
before, err := w.DB().DB()
require.NoError(t, err)
require.NoError(t, w.SweepExpired(time.Hour))
assert.Error(
t, before.PingContext(t.Context()),
"the handle open before the sweep must be closed by it",
)
}
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
// context is already cancelled, as when the app stops just as a
// sweep starts, returns without an error line: stopping is not a
// failure.
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
var errorLines bytes.Buffer
sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.eng,
slog.New(slog.NewTextHandler(
&errorLines,
&slog.HandlerOptions{Level: slog.LevelError},
)),
)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sweeper.ExportSweep(ctx)
assert.Empty(
t, errorLines.String(),
"a cancelled sweep must not log at error level",
)
}
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so archives with no
// expiry (or the literal "never") behave exactly as before.
-14
View File
@@ -102,20 +102,6 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
return remaining
}
// StateAndCooldown returns the circuit state and, while the circuit is
// open, what is left of the cooldown, or zero once that has passed.
// Both are read under one lock, so they always agree.
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state != CircuitOpen {
return cb.state, 0
}
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
}
// RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() {
+8 -61
View File
@@ -143,15 +143,6 @@ type Archives interface {
Rename(targetID, webhookName, targetName string) error
}
// CircuitBreakers is how the handlers read a target's circuit
// breaker, so the webhook page and the event log can say that
// deliveries to the target are paused and until when. Like Archives,
// it keeps the handlers free of the engine's internals and is
// trivially faked in tests.
type CircuitBreakers interface {
StateAndCooldown(targetID string) (CircuitState, time.Duration)
}
// EngineParams are the fx dependencies for the delivery
// engine.
type EngineParams struct {
@@ -195,11 +186,9 @@ type Engine struct {
// targets maps each target type to its implementation.
targets map[database.TargetType]Target
// httpTarget and slackTarget are retained so StateAndCooldown
// can read their circuit breakers, and so tests can reach the
// HTTP target's shared client.
httpTarget *httpTarget
slackTarget *slackTarget
// httpTarget is retained so tests can reach the HTTP
// target's shared client and circuit breakers.
httpTarget *httpTarget
// dbTarget is retained so the engine can reach the archive
// writer registry for eviction, renames and the idle sweep.
@@ -311,28 +300,6 @@ func (e *Engine) Rename(
return e.dbTarget.rename(targetID, webhookName, targetName)
}
// StateAndCooldown implements CircuitBreakers. It returns the state of
// the target's circuit breaker and, while the breaker is open, what is
// left of its cooldown; the cooldown is zero once that has passed and
// in any other state. A target with no breaker reads as closed with no
// cooldown, and reading never creates one.
func (e *Engine) StateAndCooldown(
targetID string,
) (CircuitState, time.Duration) {
for _, core := range []*httpCore{
e.httpTarget.httpCore, e.slackTarget.httpCore,
} {
val, ok := core.circuitBreakers.Load(targetID)
if ok {
cb, _ := val.(*CircuitBreaker)
return cb.StateAndCooldown()
}
}
return CircuitClosed, 0
}
// ScheduleRetry schedules a task to be re-enqueued onto the
// retry channel after delay. It implements the Scheduler
// interface the targets use to own their durable retries.
@@ -732,9 +699,10 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
default:
}
// Opened even when its file is missing, so that a lost
// database is reported at start, not when the webhook next
// receives an event, which for a quiet webhook may be never.
if !e.dbManager.DBExists(webhookID) {
continue
}
e.recoverWebhookDeliveries(ctx, webhookID)
}
}
@@ -742,24 +710,7 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
func (e *Engine) recoverWebhookDeliveries(
ctx context.Context, webhookID string,
) {
// The web interface is already serving, so the webhook may have
// been deleted since the list was read. Opening its database then
// would create the file again after the delete removed it.
stillExists := func() (bool, error) {
var count int64
err := e.database.DB().
Model(&database.Webhook{}).
Where("id = ?", webhookID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("confirming webhook exists: %w", err)
}
return count > 0, nil
}
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
webhookDB, err := e.dbManager.GetDB(webhookID)
if err != nil {
e.log.Error(
"failed to get webhook database for recovery",
@@ -770,10 +721,6 @@ func (e *Engine) recoverWebhookDeliveries(
return
}
if webhookDB == nil {
return
}
e.recoverPendingDeliveries(
ctx, webhookDB, webhookID,
)
+3 -125
View File
@@ -1,7 +1,6 @@
package delivery_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -24,7 +23,6 @@ import (
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// iSetup holds common integration test dependencies.
@@ -82,8 +80,7 @@ func iMainDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
@@ -358,14 +355,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
),
@@ -405,8 +397,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task,
)
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -453,14 +443,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
),
@@ -497,8 +482,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task,
)
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -1137,85 +1120,6 @@ func TestRecoverInFlight_WithPendingDeliveries(
}
}
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Restart
// recovery opens every webhook's database, so the empty one made in its
// place is reported at start, naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290).
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "lost-database")
var logs bytes.Buffer
dbMgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
assert.Contains(
t, logs.String(),
`level=WARN msg="created a new, empty database" webhook_id=`+
webhookID+" path="+dbMgr.DBPath(webhookID),
)
}
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while restart recovery runs.
// Its database file is gone, and recovery must not create it again.
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T,
) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
// The first query to return is recovery's read of the list of
// webhooks. Deleting the webhook right after it puts the delete
// between that read and the opening of the webhook's database.
deleted := false
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) {
if deleted {
return
}
deleted = true
require.NoError(t, mainDB.Delete(
&database.Webhook{}, "id = ?", webhookID,
).Error)
}))
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID))
}
// --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
@@ -1507,32 +1411,6 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
)
}
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) {
+1 -130
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -26,7 +25,6 @@ import (
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics"
)
@@ -51,8 +49,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
@@ -1018,62 +1015,6 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
)
}
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
// reads the state of an http or a slack target's circuit breaker, with
// what is left of its cooldown while it is open, and no cooldown while
// it is half-open, once it closes, or for a target with no breaker.
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
httpID := uuid.New().String()
slackID := uuid.New().String()
state, cooldown := e.StateAndCooldown(httpID)
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
assert.Zero(t, cooldown, "no breaker")
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetCircuitBreaker(httpID, httpCB)
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
httpCB.RecordFailure()
slackCB.RecordFailure()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitOpen, state)
assert.Greater(t, cooldown, 59*time.Minute)
assert.LessOrEqual(t, cooldown, time.Hour)
}
httpCB.RecordSuccess()
slackCB.RecordSuccess()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitClosed, state, "closed")
assert.Zero(t, cooldown, "closed")
}
// A breaker with no cooldown goes half-open on the first Allow
// after it trips, letting that one delivery through to test the
// target.
halfOpenID := uuid.New().String()
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
halfOpenCB.RecordFailure()
require.True(t, halfOpenCB.Allow())
state, cooldown = e.StateAndCooldown(halfOpenID)
assert.Equal(t, delivery.CircuitHalfOpen, state)
assert.Zero(t, cooldown, "half-open")
}
func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel()
@@ -1115,21 +1056,6 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
)
}
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel(
t *testing.T,
) {
@@ -1315,33 +1241,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
)
}
// A response that ends before the length it announced is an error, not
// a short body.
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
)
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
}
// The event's stored inbound headers carry the same Content-Type the
// receiver saved as the event's ContentType, so a delivery could send
// it twice. It must go out exactly once, with a Content-Type configured
@@ -1418,34 +1317,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
}
}
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
}
func TestProcessDelivery_RoutesToCorrectHandler(
t *testing.T,
) {
-14
View File
@@ -212,14 +212,6 @@ func (e *Engine) ExportSetCircuitBreaker(
e.httpTarget.circuitBreakers.Store(targetID, cb)
}
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
// slack target.
func (e *Engine) ExportSetSlackCircuitBreaker(
targetID string, cb *CircuitBreaker,
) {
e.slackTarget.circuitBreakers.Store(targetID, cb)
}
// ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig(
configJSON string,
@@ -520,12 +512,6 @@ func (e *ExportArchiveWriter) Reopen(
return e.w.reopen(expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
@@ -376,97 +376,3 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
database.DeliveryStatusPending,
)
}
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
+13 -57
View File
@@ -37,8 +37,8 @@ var (
"blocked 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",
@@ -72,17 +72,14 @@ var blockedNetworks []*net.IPNet
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 +90,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 —
@@ -115,15 +112,6 @@ var blockedPublicNetworks []*net.IPNet
// 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,46 +131,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{
@@ -242,14 +207,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.
@@ -386,9 +343,8 @@ 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.
+11 -39
View File
@@ -168,13 +168,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 +219,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
@@ -370,23 +367,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.
@@ -544,10 +524,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.
@@ -580,8 +556,7 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{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: "0.0.0.0/8", reopenable: true},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
@@ -591,11 +566,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{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},
}
-36
View File
@@ -101,42 +101,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()
-1
View File
@@ -105,7 +105,6 @@ func (e *Engine) initTargets(client *http.Client) {
dbT := &databaseTarget{eng: e}
e.httpTarget = httpT
e.slackTarget = slackT
e.dbTarget = dbT
e.targets = map[database.TargetType]Target{
+3 -3
View File
@@ -86,9 +86,9 @@ func NewTargetConfigForm(
}
// databaseConfigForm parses an archive target's optional expiry.
// An absent, empty or never expiry yields an empty expiry, on which
// the edit form starts at never; saving it unchanged stores never,
// which means the same as an empty expiry. An expiry that is set
// An absent or empty configuration is the keep-forever default and
// yields an empty field, so re-saving the form unchanged stores the
// same empty configuration it started with. An expiry that is set
// but not a valid duration is an error, not a blank field.
func databaseConfigForm(
configJSON string,
+42 -60
View File
@@ -1,9 +1,9 @@
package delivery
import (
"encoding/json"
"fmt"
"strconv"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -97,7 +97,7 @@ func targetConfigFields(
) []ConfigField {
switch t.Type {
case database.TargetTypeSlack:
return slackConfigFields(t)
return slackConfigFields(t.Config)
case database.TargetTypeHTTP:
return httpConfigFields(t)
case database.TargetTypeDatabase:
@@ -119,11 +119,10 @@ func unavailableConfigFields() []ConfigField {
}}
}
// slackConfigFields describes a Slack target: its masked
// webhook URL and its retry count. Only the masked URL is
// shown; the full URL is the credential.
func slackConfigFields(t *database.Target) []ConfigField {
cfg, err := parseSlackConfig(t.Config)
// slackConfigFields describes a Slack target. Only the masked
// webhook URL is shown; the full URL is the credential.
func slackConfigFields(configJSON string) []ConfigField {
cfg, err := parseSlackConfig(configJSON)
if err != nil {
return unavailableConfigFields()
}
@@ -131,7 +130,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
return []ConfigField{{
Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(),
}, maxRetriesField(t)}
}}
}
// httpConfigFields describes an HTTP target: its destination
@@ -171,80 +170,63 @@ func httpConfigFields(t *database.Target) []ConfigField {
})
}
fields = append(fields, maxRetriesField(t))
return fields
return append(fields, retryFields(t)...)
}
// maxRetriesField describes a target's retry count, which lives
// retryFields describes a target's retry settings, which live
// on the target row rather than in its configuration blob.
func maxRetriesField(t *database.Target) ConfigField {
func retryFields(t *database.Target) []ConfigField {
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
}
return ConfigField{
fields := []ConfigField{{
Label: "Max Retries",
Value: retries,
}}
if t.MaxQueueSize > 0 {
fields = append(fields, ConfigField{
Label: "Max Queue Size",
Value: strconv.Itoa(t.MaxQueueSize),
})
}
return fields
}
// databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever. An expiry that is set but not
// a valid duration is reported as unavailable rather than
// echoed back.
// databaseConfigFields describes an archive target. Its
// configuration is optional, and an absent or empty expiry
// means the archive is kept forever. An expiry that is set
// but not a valid duration is reported as unavailable rather
// than echoed back.
func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON)
if err != nil {
return unavailableConfigFields()
}
expiry := archiveExpiryNever
value := archiveExpiryNever
if expiry > 0 {
value = plainDuration(expiry)
if configJSON != "" {
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return unavailableConfigFields()
}
if cfg.Expiry != "" {
if ValidateArchiveExpiry(cfg.Expiry) != nil {
return unavailableConfigFields()
}
expiry = cfg.Expiry
}
}
return []ConfigField{{
Label: "Archive Expiry",
Value: value,
Value: expiry,
}}
}
// plainDuration writes a positive duration as a count of the
// largest whole unit it divides into: "30 days", "12 hours",
// "1 minute". A duration with a fraction of a second is
// written as Go writes it.
func plainDuration(d time.Duration) string {
const day = 24 * time.Hour
units := []struct {
size time.Duration
name string
}{
{day, "day"},
{time.Hour, "hour"},
{time.Minute, "minute"},
{time.Second, "second"},
}
for _, unit := range units {
if d%unit.size != 0 {
continue
}
count := int64(d / unit.size)
if count == 1 {
return "1 " + unit.name
}
return fmt.Sprintf("%d %ss", count, unit.name)
}
return d.String()
}
// MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of
+13 -39
View File
@@ -32,7 +32,6 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)"
viewExpiryNever = "never"
viewMaxRetries = "Max Retries"
)
func TestMaskedWebhookURL(t *testing.T) {
@@ -158,7 +157,9 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
t, slackTargetName+" (deleted)", view.DisplayName(),
)
assert.Equal(
t, viewFor(t, slackTarget()).Config, view.Config,
t,
map[string]string{"Webhook URL": slackMaskedURL},
fieldMap(view.Config),
)
}
@@ -188,30 +189,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "0 (fire-and-forget)",
},
fieldMap(view.Config),
)
}
// TestNewTargetViews_SlackRetries proves a Slack target shows
// its retry count the same way an HTTP target does.
func TestNewTargetViews_SlackRetries(t *testing.T) {
t.Parallel()
target := slackTarget()
target.MaxRetries = 2
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "2",
},
map[string]string{"Webhook URL": slackMaskedURL},
fieldMap(view.Config),
)
}
@@ -224,7 +202,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5,
MaxRetries: 5,
MaxQueueSize: 100,
})
fields := fieldMap(view.Config)
@@ -235,7 +214,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin,
"Timeout": "30s",
"Headers": "1 configured",
viewMaxRetries: "5",
"Max Retries": "5",
"Max Queue Size": "100",
},
fields,
)
@@ -258,7 +238,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t,
map[string]string{
"Destination URL": viewMaskedOrigin,
viewMaxRetries: "0 (fire-and-forget)",
"Max Retries": "0 (fire-and-forget)",
},
fieldMap(view.Config),
)
@@ -301,20 +281,14 @@ func TestNewTargetViews_Database(t *testing.T) {
}{
"empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`,
want: viewExpiryNever,
},
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
}
for name, tc := range tests {
+10 -6
View File
@@ -3,6 +3,7 @@ package delivery
import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
@@ -276,9 +277,10 @@ func (t *databaseTarget) releaseSweepWriter(
}
// newWriter builds the writer for a database target's archive. The
// file is the one ArchivePath gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive file.
// 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) {
@@ -297,10 +299,12 @@ func (t *databaseTarget) newWriter(
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
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
+2 -48
View File
@@ -194,10 +194,6 @@ type archiveWriter struct {
lastReopen time.Time
reopens int
// now is the clock the reopen debounce is measured on. It is
// time.Now outside tests.
now func() time.Time
// 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
@@ -232,7 +228,6 @@ func newArchiveWriter(
path: path,
log: log,
debounce: archiveReopenDebounce,
now: time.Now,
}
}
@@ -268,7 +263,7 @@ func (w *archiveWriter) write(
)
}
if w.now().Sub(w.lastReopen) >= w.debounce {
if time.Since(w.lastReopen) >= w.debounce {
return w.reopen(expiry)
}
@@ -328,7 +323,7 @@ func (w *archiveWriter) openMode(
}
w.db = gdb
w.lastReopen = w.now()
w.lastReopen = time.Now()
w.reopens++
if expiry > 0 {
@@ -515,47 +510,6 @@ func (w *archiveWriter) prune(expiry time.Duration) {
}
}
// ArchiveFileInfo is what the metadata of a database target's archive
// file says about it.
type ArchiveFileInfo struct {
// Size is the bytes on disk of the file and its -wal together.
Size int64
// Written is when the file or its -wal was last modified, whichever
// is later: a write lands in the -wal first.
Written time.Time
}
// StatArchive reads the metadata of the archive file at path and of
// its -wal, without opening the archive. With no file at path, which is
// so before the first write and after the operator moved it away, the
// error wraps fs.ErrNotExist.
func StatArchive(path string) (ArchiveFileInfo, error) {
file, err := os.Stat(path)
if err != nil {
return ArchiveFileInfo{}, err
}
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
wal, err := os.Stat(path + "-wal")
if errors.Is(err, fs.ErrNotExist) {
return info, nil
}
if err != nil {
return ArchiveFileInfo{}, err
}
info.Size += wal.Size()
if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime()
}
return info, nil
}
// fileExists reports whether a path currently exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
-275
View File
@@ -1,275 +0,0 @@
package delivery
import (
"compress/gzip"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"time"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// archiveTableQuery counts the archive's table: 0 when the archive
// writer has created the file but not yet the table in it.
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'"
// ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the
// name ArchiveFileName gives it.
func ArchivePath(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
) string {
return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)),
ArchiveFileName(webhook.Name, target.Name, target.ID),
)
}
// ArchiveExportFileName returns the name a database target's archive
// downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in ArchiveFileName and the time in UTC.
func ArchiveExportFileName(
webhookName, targetName string, at time.Time,
) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" +
at.UTC().Format("20060102T150405Z") + ".json.gz"
}
// ArchiveExport is a database target's archive opened for download.
// It reads the file on its own connection, inside one read-only
// transaction, so it writes out the archive as it stood when
// OpenArchiveExport returned.
//
// Archives are in WAL mode, where a reader works from a snapshot and
// never blocks a writer: archive writes go on while an export is open,
// and the export does not see them. SQLite cannot checkpoint the -wal
// past an open snapshot, so the -wal grows until the export is closed.
type ArchiveExport struct {
db *sql.DB
tx *gorm.DB
// empty is true when there is nothing to read: no file, or a file
// without the archive's table yet.
empty bool
}
// exportedName is how an export names its webhook and its target.
type exportedName struct {
ID string `json:"id"`
Name string `json:"name"`
}
// OpenArchiveExport opens the archive file at path for export and
// takes the snapshot the export reads. It never creates the file: with
// no file at path, the export has no rows.
//
// Once it has returned, the file is open, so a rename or a move of it
// does not affect the export, which reads the same file under its new
// name.
//
// The transaction lasts as long as ctx does, so ctx must last for the
// whole export.
func OpenArchiveExport(
ctx context.Context, path string, log *slog.Logger,
) (*ArchiveExport, error) {
if !fileExists(path) {
return &ArchiveExport{empty: true}, nil
}
db, err := database.OpenSQLite(path, archiveModeExisting)
if err != nil {
return nil, fmt.Errorf("opening archive %s: %w", path, err)
}
gdb, err := gorm.Open(
sqlite.Dialector{Conn: db}, &gorm.Config{
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(log),
},
)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("opening archive %s: %w", path, err)
}
// ReadOnly makes the driver begin a deferred transaction in place
// of the BEGIN IMMEDIATE the connection string asks for, so the
// export never takes the archive's write lock.
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
if tx.Error != nil {
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
}
// The transaction's first read is what takes the snapshot.
var tables int
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
if err != nil {
_ = tx.Rollback()
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", path, err)
}
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
}
// WriteGzipJSON writes the export to w as one gzipped JSON object:
// webhook and target, each an id and a name; exported_at; and
// archived_events, one object per archived row, keyed by column name.
// A body that is not valid UTF-8 cannot be a JSON string, so it is
// written in base64, with "body_encoding": "base64" beside it.
//
// Each row is written out before the next is read, so neither the
// archive nor its JSON is ever held in memory whole. After an error
// the gzip stream is left unfinished, so what was written does not
// decompress as a whole file.
func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context,
w io.Writer,
webhook *database.Webhook,
target *database.Target,
exportedAt time.Time,
) error {
head, err := json.Marshal(map[string]any{
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
"target": exportedName{ID: target.ID, Name: target.Name},
"exported_at": exportedAt.UTC(),
})
if err != nil {
return fmt.Errorf("encoding archive export: %w", err)
}
zw := gzip.NewWriter(w)
err = x.writeJSON(ctx, zw, head)
if err != nil {
return fmt.Errorf("writing archive export: %w", err)
}
return zw.Close()
}
// Close ends the export's transaction and closes its connection.
func (x *ArchiveExport) Close() error {
if x.db == nil {
return nil
}
_ = x.tx.Rollback()
return x.db.Close()
}
// writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time.
func (x *ArchiveExport) writeJSON(
ctx context.Context, w io.Writer, head []byte,
) error {
// head goes out without its closing brace, so that
// archived_events can follow it.
_, err := w.Write(head[:len(head)-1])
if err != nil {
return err
}
_, err = io.WriteString(w, `,"archived_events":[`)
if err != nil {
return err
}
err = x.writeRows(ctx, w)
if err != nil {
return err
}
_, err = io.WriteString(w, "\n]}\n")
return err
}
// writeRows writes each archived row to w, oldest first, one per line,
// separated by commas.
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
if x.empty {
return nil
}
rows, err := x.tx.WithContext(ctx).
Model(&archivedEvent{}).Order("id").Rows()
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for sep := "\n"; rows.Next(); sep = ",\n" {
var ev archivedEvent
err = x.tx.ScanRows(rows, &ev)
if err != nil {
return err
}
_, err = io.WriteString(w, sep)
if err != nil {
return err
}
err = writeRow(w, &ev)
if err != nil {
return err
}
}
return rows.Err()
}
// writeRow writes an archived row to w as a JSON object keyed by
// column name, its body in base64 when it is not valid UTF-8.
func writeRow(w io.Writer, ev *archivedEvent) error {
row := map[string]any{
"id": ev.ID,
"event_id": ev.EventID,
"webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID,
"method": ev.Method,
"headers": ev.Headers,
"body": ev.Body,
"content_type": ev.ContentType,
"archived_at": ev.ArchivedAt.UTC(),
}
if !utf8.ValidString(ev.Body) {
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
row["body_encoding"] = "base64"
}
line, err := json.Marshal(row)
if err != nil {
return err
}
_, err = w.Write(line)
return err
}
@@ -1,412 +0,0 @@
package delivery_test
import (
"bufio"
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The webhook and the target the export tests' archives belong to.
const (
exportWebhookID = "wh-export"
exportWebhookName = "Orders (EU)"
exportTargetID = "tgt-export"
exportTargetName = "Long-term archive"
)
const (
// binaryBody is a body that is not valid UTF-8.
binaryBody = "\xff\xfe\x00\x01binary\x80"
// openedEventID is the event the snapshot tests archive before
// they open the export.
openedEventID = "opened"
)
// writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z.
func writeExportTo(
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
) error {
t.Helper()
return export.WriteGzipJSON(
t.Context(), w,
&database.Webhook{
BaseModel: database.BaseModel{ID: exportWebhookID},
Name: exportWebhookName,
},
&database.Target{
BaseModel: database.BaseModel{ID: exportTargetID},
Name: exportTargetName,
},
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
)
}
// exportArchive runs a whole export of the archive at path and returns
// its JSON, decompressed and parsed.
func exportArchive(t *testing.T, path string) map[string]any {
t.Helper()
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
return writeExport(t, export)
}
// writeExport writes an opened export and returns its JSON,
// decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished.
func writeExport(
t *testing.T, export *delivery.ArchiveExport,
) map[string]any {
t.Helper()
var buf bytes.Buffer
require.NoError(t, writeExportTo(t, export, &buf))
zr, err := gzip.NewReader(&buf)
require.NoError(t, err)
raw, err := io.ReadAll(zr)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(raw, &got))
return got
}
// exportedEvents returns an export's archived_events.
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
t.Helper()
list, ok := got["archived_events"].([]any)
require.True(t, ok, "archived_events must be an array: %v", got)
events := make([]map[string]any, len(list))
for i, v := range list {
events[i], ok = v.(map[string]any)
require.True(t, ok, "an archived event must be an object: %v", v)
}
return events
}
// exportedEventIDs returns the event_id of each of an export's
// archived_events.
func exportedEventIDs(t *testing.T, got map[string]any) []string {
t.Helper()
events := exportedEvents(t, got)
ids := make([]string, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
}
return ids
}
// TestArchiveExport_MatchesStoredRows proves an export holds the
// webhook, the target, the time, and every column of every stored
// row: a body that is valid UTF-8 as a string, and one that is not in
// base64, marked as such.
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
}, 0))
}
var stored []delivery.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error)
got := exportArchive(t, path)
assert.Equal(t,
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
got["webhook"],
)
assert.Equal(t,
map[string]any{"id": exportTargetID, "name": exportTargetName},
got["target"],
)
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
events := exportedEvents(t, got)
require.Len(t, events, len(bodies))
for i, row := range stored {
assertExportedRow(t, row, events[i])
}
}
// assertExportedRow checks that ev, from an export, holds every column
// of the stored row.
func assertExportedRow(
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
) {
t.Helper()
archivedAt, err := time.Parse(
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
)
require.NoError(t, err)
assert.True(t, archivedAt.Equal(row.ArchivedAt))
assert.EqualValues(t, row.ID, ev["id"])
assert.Equal(t, row.EventID, ev["event_id"])
assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return
}
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
require.NoError(t, err)
assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
}
// TestArchiveExport_Empty proves an archive with nothing in it exports
// as an empty archived_events: no file, which the export must not
// create; a file the archive writer has not yet put its table in; and
// a table with no rows.
func TestArchiveExport_Empty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missing := filepath.Join(dir, "missing.db")
noTable := filepath.Join(dir, "no-table.db")
noRows := filepath.Join(dir, "no-rows.db")
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t,
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0),
)
for _, path := range []string{missing, noTable, noRows} {
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
}
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, missing+suffix)
}
}
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
// archive as it was when it was opened, and holds up no archive
// write: a row written while the export is open is stored, and is not
// in the export. A write held up for the whole busy timeout would
// fail.
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
)
var stored int64
require.NoError(t, openArchiveDBForRead(t, path).
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(2), stored)
}
// TestArchiveExport_SurvivesRename proves that renaming the archive
// while an export of it is open, as renaming its webhook or target
// does, leaves the export reading the same file.
func TestArchiveExport_SurvivesRename(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-old.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
require.NoError(t, w.Rename("archive-new.db"))
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
require.NoFileExists(t, path)
assert.Equal(t,
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
)
}
// heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage before each
// reading, so the heap it reads is what is still held.
type heapPeak struct {
max uint64
}
func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
p.max = max(p.max, m.HeapAlloc)
return len(b), nil
}
// exportHeapGrowth exports an archive of rows random bodies, each
// bodySize bytes of base64, and returns how far the heap rose above
// where it stood when the export began, at its highest.
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3)
for range rows {
_, _ = rand.Read(random)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0))
}
export, err := delivery.OpenArchiveExport(
t.Context(), path, archiveTestLogger(),
)
require.NoError(t, err)
defer func() { require.NoError(t, export.Close()) }()
runtime.GC()
var start runtime.MemStats
runtime.ReadMemStats(&start)
// Through a buffer, the heap is read once per 8 KiB of output
// rather than at each of gzip's small writes, which takes far
// longer.
peak := &heapPeak{max: start.HeapAlloc}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, export, buffered))
require.NoError(t, buffered.Flush())
return peak.max - start.HeapAlloc
}
// TestArchiveExport_Streams proves an export holds neither the archive
// nor its output in memory whole: exporting 384 KiB more of archive
// raises the heap's peak by less than half of that. The export's own
// memory, mostly gzip's compressor, is the same for both archives, so
// it cancels out. The bodies are random bytes in base64, which gzip
// shrinks by only a quarter, so an export that read every row before
// writing, or built the JSON or the gzipped file before writing it,
// would raise the peak by at least three quarters of the difference.
//
// The smaller archive has two rows so that its export, too, writes
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
// the heap must be read while the export's own memory is held.
//
//nolint:paralleltest // It measures the heap, which tests share.
func TestArchiveExport_Streams(t *testing.T) {
const (
bodySize = 16 << 10
smallRows = 2
largeRows = smallRows + 24
limit = (largeRows - smallRows) * bodySize / 2
)
small := exportHeapGrowth(t, smallRows, bodySize)
large := exportHeapGrowth(t, largeRows, bodySize)
assert.Less(t, large, small+limit,
"the heap rose by %d for %d rows and by %d for %d rows",
small, smallRows, large, largeRows,
)
}
// TestArchiveExportFileName proves the download is named for the
// webhook and the target, with the names made safe as for the archive
// file, and the export time in UTC.
func TestArchiveExportFileName(t *testing.T) {
t.Parallel()
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
),
)
}
+5 -58
View File
@@ -3,7 +3,6 @@ package delivery_test
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -18,7 +17,6 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
func archiveTestLogger() *slog.Logger {
@@ -44,8 +42,7 @@ func openArchiveDBForRead(
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
@@ -184,66 +181,16 @@ func TestArchiveWriter_RecreatesAfterRemoval(
assert.Equal(t, "b", got[0].EventID)
}
// TestStatArchive proves StatArchive finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
const debounce = 2 * time.Second
// A generous debounce keeps the two rapid writes inside
// the window even on a heavily loaded test machine.
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), debounce,
path, archiveTestLogger(), 2*time.Second,
)
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0,
))
@@ -255,7 +202,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
// initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce)
time.Sleep(2100 * time.Millisecond)
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "c"}, 0,
+2 -3
View File
@@ -216,9 +216,8 @@ func TestNewTargetConfigForm(t *testing.T) {
assert.Empty(t, form.URL)
}
// A keep-forever archive target yields an empty expiry, so the edit
// form starts on never; saving it unchanged stores never, which means
// the same as an empty expiry.
// A keep-forever archive target must pre-fill as an empty field, so
// saving the form back unchanged stores the same empty config.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel()
+4 -6
View File
@@ -234,7 +234,7 @@ func (c *httpCore) handleRetry(
database.DeliveryStatusRetrying,
)
backoff := Backoff(attemptNum)
backoff := calcBackoff(attemptNum)
retryTask := *task
retryTask.AttemptNum = attemptNum + 1
@@ -301,7 +301,7 @@ func (c *httpCore) remainingBackoff(
return 0
}
backoff := Backoff(attemptNum)
backoff := calcBackoff(attemptNum)
elapsed := time.Since(lastResult.CreatedAt)
remaining := backoff - elapsed
@@ -326,14 +326,12 @@ func (c *httpCore) backoffElapsed(
return true
}
backoff := Backoff(attemptNum)
backoff := calcBackoff(attemptNum)
return time.Since(lastResult.CreatedAt) >= backoff
}
// Backoff is how long an http or slack target with retries waits after
// a delivery's failed attempt attemptNum before trying it again.
func Backoff(attemptNum int) time.Duration {
func calcBackoff(attemptNum int) time.Duration {
shift := max(attemptNum-1, 0)
shift = min(shift, maxBackoffShift)
-4
View File
@@ -573,8 +573,6 @@ func TestRecoverPending_TargetDeleted(t *testing.T) {
s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery")
deliveryID := tSeedDeletedTarget(
t, s, "gone-while-pending", "http://example.com/hook",
database.DeliveryStatusPending,
@@ -614,8 +612,6 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery")
deliveryID := tSeedDeletedTarget(
t, s, "gone-but-owned", "http://example.com/hook",
database.DeliveryStatusPending,
-21
View File
@@ -179,27 +179,6 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
)
}
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
// target URL that no request can be built from.
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n",
},
&database.Event{},
)
require.Error(t, reqErr)
assert.Zero(t, statusCode)
assertNoCredential(t, reqErr.Error())
assert.Contains(t, reqErr.Error(), "invalid control character")
}
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
// validator's error does not carry the submitted URL, which
// the handler both logs and shows.
+2 -7
View File
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
}
// bootAtDebug starts and stops the real application graph against
// dataDir with DEBUG=true and nothing else set, and returns everything
// it wrote to standard output.
// dataDir with DEBUG=true, and returns everything it wrote to standard
// output.
//
// config.New reads DEBUG from the environment exactly as the binary
// does, internal/logger builds the handler it builds in production,
@@ -128,7 +128,6 @@ func readFirstBootSecrets(
func bootAtDebug(t *testing.T, dataDir string) string {
t.Helper()
config.ClearEnvForTest(t)
t.Setenv("DEBUG", "true")
t.Setenv("DATA_DIR", dataDir)
@@ -138,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,
+3 -3
View File
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
//
// One GORM path does not consult this: (*gorm.DB).Scan records the
// statement through gorm's own traceRecorder, which does not implement
// this interface. No production code path calls it; only tests do, and
// what a test binds is fixture data. scan_guard_test.go fails if a
// non-test file calls it.
// this interface. No production code path calls it; its one caller is
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
// scan_guard_test.go fails if a non-test file calls it.
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
// processor and are filtered.
func (l *Logger) ParamsFilter(
+40 -99
View File
@@ -14,16 +14,18 @@ import (
"github.com/stretchr/testify/require"
)
// isRowProducer reports whether name is GORM's Row or database/sql's
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
// it also returns an error, so Scan is never called on its result
// directly. It matches the method name only and resolves no types, so
// a repo-local method with one of these names that returns *gorm.DB
// gets past it: Scan on that method's result is not reported.
// minNonTestFiles guards the walk below against passing because it
// found nothing to look at. The tree held 60 non-test .go files when
// this was written.
const minNonTestFiles = 40
// isRowProducer reports whether name is a method that returns a
// database/sql row handle. GORM's Row and Rows return *sql.Row and
// *sql.Rows, so Scan on the result of one of them is database/sql's
// Scan and never (*gorm.DB).Scan.
func isRowProducer(name string) bool {
switch name {
case "Row", "QueryRow", "QueryRowContext":
case "Row", "Rows", "QueryRow", "QueryRowContext":
return true
default:
return false
@@ -48,14 +50,9 @@ func receiverIsRowHandle(x ast.Expr) bool {
}
// unguardedScans returns the position of every Scan call in file whose
// receiver is not a call to a row producer. It fails closed: any other
// receiver — a local variable, a struct field, a call to any other
// method — is reported rather than assumed safe.
//
// It sees only calls written x.Scan(...). A method value, f := db.Scan
// followed by f(&v), is out of scope: Scan is never the called
// expression there, and nobody writes a query that way by accident,
// which is the mistake this check exists to catch.
// receiver is not a row handle. It fails closed: a receiver it cannot
// resolve syntactically — a local variable, a struct field — is
// reported rather than assumed safe.
func unguardedScans(
fset *token.FileSet, file *ast.File,
) []token.Position {
@@ -114,15 +111,15 @@ func skipDir(name string) bool {
}
}
// walkNonTestGo parses every non-test .go file under root. It returns
// the directories, relative to root, it parsed a file in, along with
// every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
// walkNonTestGo parses every non-test .go file under root and returns
// how many it parsed along with every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (int, []string) {
t.Helper()
walked := map[string]bool{}
var hits []string
var (
parsed int
hits []string
)
fset := token.NewFileSet()
@@ -150,12 +147,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
return err
}
dir, err := filepath.Rel(root, filepath.Dir(path))
if err != nil {
return err
}
walked[dir] = true
parsed++
for _, pos := range unguardedScans(fset, file) {
hits = append(hits, relPosition(root, pos))
@@ -165,7 +157,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
},
))
return walked, hits
return parsed, hits
}
// isNonTestGo reports whether a file name is Go source this check
@@ -197,39 +189,19 @@ func relPosition(root string, pos token.Position) string {
// logged with its values interpolated. The package comment states the
// limit; this fails when someone adds a call site anyway.
//
// Test files are not governed: what a test binds is fixture data.
// The current tree has one caller, internal/database/database_test.go,
// which this check does not govern: it is test-only and its SELECT 1
// binds nothing.
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
t.Parallel()
root := moduleRoot(t)
walked, offenders := walkNonTestGo(t, root)
// The module's packages are static, templates, and every directory
// directly under cmd and internal. Each holds non-test code, so one
// the walk parsed nothing in was skipped, and a Scan there would
// pass unseen.
packages := []string{"static", "templates"}
for _, parent := range []string{"cmd", "internal"} {
entries, err := os.ReadDir(filepath.Join(root, parent))
require.NoError(t, err)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packages = append(packages, filepath.Join(parent, entry.Name()))
}
}
for _, dir := range packages {
require.True(
t, walked[dir],
"the walk parsed no non-test .go file in %s", dir,
)
}
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
require.GreaterOrEqual(
t, parsed, minNonTestFiles,
"parsed %d non-test .go files, so this check found "+
"nothing to look at", parsed,
)
require.Empty(
t, offenders,
"Scan called on a receiver this check cannot show is a "+
@@ -250,51 +222,18 @@ type scanGuardCase struct {
want int
}
// scanGuardCases covers each receiver form unguardedScans names, plus
// each row producer isRowProducer lets through. Each body is valid Go
// inside plantedFile.
func scanGuardCases() []scanGuardCase {
return []scanGuardCase{
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
{"struct field", `s.db.Scan(&v)`, 1},
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
{
"sql rows in a variable",
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
1,
},
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
{
"sql QueryRowContext",
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
0,
},
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
{"gorm receiver", `gdb.Scan(&v)`, 1},
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
{"unrelated call", `gdb.Find(&v)`, 0},
}
}
// plantedFile wraps one case body in a function that declares every
// name the bodies use, so each body is the Go it stands for. The result
// is parsed, never compiled.
const plantedFile = `package p
import (
"context"
"database/sql"
"gorm.io/gorm"
)
type store struct{ db *gorm.DB }
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
var v int
%s
}
`
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
// a detector that matched nothing would satisfy the walk above no
// matter what the tree contained.
@@ -306,7 +245,9 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
t.Parallel()
fset := token.NewFileSet()
src := fmt.Sprintf(plantedFile, tc.body)
src := fmt.Sprintf(
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
)
file, err := parser.ParseFile(
fset, tc.name+".go", src, 0,
-58
View File
@@ -1,58 +0,0 @@
package handlers
const (
// archiveExpiryNever is the archive expiry that keeps archived
// events forever. A stored empty expiry means the same.
archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the
// entries of a page's archive expiry select.
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
)
// archiveExpiryChoice is one entry of a database target's archive
// expiry select: the expiry stored, the label shown, and whether the
// select starts on it.
type archiveExpiryChoice struct {
Value string
Label string
Selected bool
}
// archiveExpiryChoices lists the archive expiries offered by the new
// webhook page, the add target form and the target edit form.
func archiveExpiryChoices() []archiveExpiryChoice {
return []archiveExpiryChoice{
{Value: archiveExpiryNever, Label: archiveExpiryNever},
{Value: "1h", Label: "1h"},
{Value: "12h", Label: "12h"},
{Value: "24h", Label: "24h"},
{Value: "720h", Label: "30d"},
{Value: "2160h", Label: "90d"},
{Value: "8760h", Label: "365d"},
}
}
// archiveExpiryOptions returns the choices with expiry selected; an
// empty expiry selects never. An expiry that is not one of the
// choices comes first as its own selected entry, so saving the form
// unchanged keeps it.
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
if expiry == "" {
expiry = archiveExpiryNever
}
options := archiveExpiryChoices()
for i := range options {
if options[i].Value == expiry {
options[i].Selected = true
return options
}
}
own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true}
return append([]archiveExpiryChoice{own}, options...)
}
-166
View File
@@ -1,166 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// expiryNever is the archive expiry that keeps archived events
// forever.
const expiryNever = "never"
// matched returns what the one group of pattern matched in page, at
// each match.
func matched(pattern, page string) []string {
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
groups := make([]string, 0, len(matches))
for _, m := range matches {
groups = append(groups, m[1])
}
return groups
}
// expiryShown returns the archive expiries the webhook page's target
// list shows.
func expiryShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return matched(
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
)
}
// expirySelected returns the target edit page and the expiries its
// select starts on.
func expirySelected(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) (string, []string) {
t.Helper()
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
return page, matched(`<option value="([^"]*)" selected>`, page)
}
// TestArchiveExpiryChoices adds a database target with each archive
// expiry the forms offer, and checks that it is stored as chosen,
// shown in plain units in the target list, and that the target edit
// form starts on it.
func TestArchiveExpiryChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
}
for _, choice := range choices {
t.Run(choice.value, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", choice.value)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(
t, []string{choice.shown},
expiryShown(t, env, webhook.ID),
)
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{choice.value}, selected)
})
}
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
assert.Equal(t, []string{expiryNever}, selected)
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
assert.Equal(t, []string{"36h"}, selected)
assert.Regexp(
t,
`<select id="expiry" name="expiry" class="input">\s*`+
`<option value="36h" selected>36h</option>\s*`+
`<option value="never">never</option>`,
page,
)
assert.Contains(t, page, `<option value="8760h">365d</option>`)
form := url.Values{}
form.Set("name", unlisted.Name)
form.Set("expiry", "36h")
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
)
}
+2 -1
View File
@@ -139,7 +139,8 @@ func (h *Handlers) renderLoginError(
),
}
h.renderTemplateStatus(w, r, "login.html", data, status)
w.WriteHeader(status)
h.renderTemplate(w, r, "login.html", data)
}
// authenticateUser looks up and verifies a user's credentials.
-55
View File
@@ -3,7 +3,6 @@ package handlers_test
import (
"context"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
@@ -405,60 +404,6 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
)
}
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
// form shown again with an error still answers 400 with the whole page.
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(
t, w.Body.String(), "Username and password are required",
)
assert.Contains(
t, w.Body.String(), "</html>",
"the page must render to completion",
)
}
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
// error page whose template fails answers 500 with the error page and
// none of the form page, rather than the 400 it meant to send.
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// The page prints its error message and then fails.
h.AddTemplateForTest("login.html", template.Must(
template.New("login").Funcs(template.FuncMap{
"fail": func() (string, error) { return "", errMidRender },
}).Parse(`{{.Error}}{{fail}}`),
))
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(
t, w.Body.String(), "Username and password are required",
"the response must carry no part of the aborted page",
)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
}
// TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) {
@@ -1,89 +0,0 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
// event's page and in the event log, that an http or slack attempt
// shows its status as before, while a database or log attempt, which
// sends no HTTP request, says what it did and shows no status.
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
t.Parallel()
cases := []struct {
targetType database.TargetType
success bool
statusCode int
errText string
outcome string
status string // "" when the attempt must show no status
}{
{
database.TargetTypeHTTP, false, 0, "",
"failure", "Status: &mdash; (no response)",
},
{
database.TargetTypeSlack, true, http.StatusOK, "",
"success", "Status: 200",
},
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
{
database.TargetTypeDatabase, false, 0,
"opening archive database: disk full", "failure", "",
},
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
event := f.event(t, contentTypeJSON, "{}", time.Now())
dlv := f.delivery(
t, event, target.ID, database.DeliveryStatusDelivered,
)
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
Success: tc.success,
StatusCode: tc.statusCode,
Error: tc.errText,
},
).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
pages := []string{
w.Body.String(),
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
}
for _, page := range pages {
assert.Contains(t, page, ">"+tc.outcome+"</span>")
if tc.errText != "" {
assert.Contains(t, page, "Error: "+tc.errText)
}
if tc.status == "" {
assert.NotContains(t, page, "Status:")
} else {
assert.Contains(t, page, tc.status)
}
}
})
}
}
+1 -3
View File
@@ -21,9 +21,7 @@ const (
// 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. The
// event log shows no Replay button for such a delivery, so only
// a page loaded before the delete reaches this.
// target and this is the ordinary case for an old event.
replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at
+1 -5
View File
@@ -513,11 +513,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
)
assert.Contains(t, refused, "alert-error")
assert.Contains(
t, refused,
"has been deleted. Use Resubmit to send the event "+
"to the webhook",
)
assert.Contains(t, refused, "has been deleted")
// An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery(
+2 -5
View File
@@ -1,8 +1,6 @@
package handlers
import (
"time"
"sneak.berlin/go/webhooker/internal/delivery"
)
@@ -26,8 +24,8 @@ const maxRenderedResponseBytes = 4096
// bytes rather than characters, and they make SQLite do the
// cut, so an oversized stored response never becomes a Go
// string at all.
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
"success, status_code, error, duration, " +
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
"status_code, error, duration, " +
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
"length(cast(response_body as blob)) AS response_bytes"
@@ -102,7 +100,6 @@ func (v DeliveryResultView) HasStatusCode() bool {
type deliveryResultRow struct {
DeliveryID string
AttemptNum int
CreatedAt time.Time
Success bool
StatusCode int
Error string
@@ -1,95 +0,0 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle
// which loaded the entrypoint before an edit of its description was
// saved does not write the old description back over the edit. The
// edit is submitted from a callback on the toggle's own read of the
// entrypoint, so it is saved after that read and before the toggle
// writes.
func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 30)
ep := seedEntrypoint(t, env.db, wh.ID)
require.True(t, ep.Active)
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/entrypoints/{entrypointID}/edit",
env.handlers.HandleEntrypointEdit(),
)
router.Post(
"/hook/{sourceID}/entrypoints/{entrypointID}/toggle",
env.handlers.HandleEntrypointToggle(),
)
// post submits one of the entrypoint's forms as the test user and
// returns the response's status code.
post := func(action string, form url.Values) int {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action,
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range env.cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code
}
var (
edited bool
editCode int
)
require.NoError(t, env.db.DB().Callback().Query().
After("gorm:query").
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
// Only the first read of an entrypoint, the toggle's,
// submits the edit.
if tx.Statement.Table != "entrypoints" || edited {
return
}
edited = true
editCode = post(
"edit", url.Values{"description": {"Billing sender"}},
)
}),
)
require.Equal(t, http.StatusSeeOther, post("toggle", nil))
require.Equal(t, http.StatusSeeOther, editCode)
var stored database.Entrypoint
require.NoError(
t, env.db.DB().First(&stored, "id = ?", ep.ID).Error,
)
assert.False(t, stored.Active)
assert.Equal(t, "Billing sender", stored.Description)
}
-77
View File
@@ -1,11 +1,6 @@
package handlers
import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -16,21 +11,6 @@ type EntrypointView struct {
Path string
Description string
Active bool
// Events is how many events arrived on the entrypoint's URL within
// the webhook's retention period. LastEvent is when the newest
// event ever to arrive on it did, relative, and LastEventUTC the
// full time; both are empty when none ever did.
Events int64
LastEvent string
LastEventUTC string
}
// entrypointEvents is one entrypoint's count read by
// addEntrypointEvents.
type entrypointEvents struct {
EntrypointID string
Events int64
}
// NewEntrypointViews projects entrypoints for rendering.
@@ -52,60 +32,3 @@ func NewEntrypointViews(
return views
}
// addEntrypointEvents fills in each view's event figures from the
// webhook's event database: when the last event arrived on its URL,
// from its EntrypointTotals row, and how many events arrived on it
// since the webhook's retention cutoff, counted in one query over the
// events' entrypoint_id index. Resubmitted copies did not arrive on
// the URL and are left out of both.
func addEntrypointEvents(
webhookDB *gorm.DB,
webhook *database.Webhook,
views []EntrypointView,
now time.Time,
) error {
ids := make([]string, len(views))
byID := make(map[string]*EntrypointView, len(views))
for i := range views {
ids[i] = views[i].ID
byID[views[i].ID] = &views[i]
}
var totals []database.EntrypointTotals
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
if err != nil {
return fmt.Errorf("reading entrypoint totals: %w", err)
}
query := webhookDB.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
cutoff, finite := webhook.RetentionCutoff(now)
if finite {
query = query.Where("created_at >= ?", cutoff)
}
var counts []entrypointEvents
err = query.Group("entrypoint_id").Find(&counts).Error
if err != nil {
return fmt.Errorf("counting events by entrypoint: %w", err)
}
for _, row := range totals {
view := byID[row.EntrypointID]
view.LastEvent = humanize.Time(row.LastEventAt)
view.LastEventUTC =
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
for _, row := range counts {
byID[row.EntrypointID].Events = row.Events
}
return nil
}
-197
View File
@@ -1,197 +0,0 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// entrypointRow returns the part of a rendered webhook page from an
// entrypoint's URL to the next entrypoint's, which holds its figures.
func entrypointRow(t *testing.T, page, entrypointID string) string {
t.Helper()
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
require.True(t, found)
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
return row
}
// lastEventShown matches an entrypoint row's last event arriving at at.
func lastEventShown(at time.Time) string {
return `Last Event:</span>\s*<span title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
}
// eventsShown matches an entrypoint row's count of n events.
func eventsShown(n int) string {
return `Events Within Retention:</span>\s*<span>` +
strconv.Itoa(n) + `</span>`
}
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
// on the webhook page shows its own figures: how many events arrived
// through it within the webhook's retention period, leaving out one
// older than that, and when the newest arrived, or "never" for an
// entrypoint with none.
func TestHandleSourceDetail_ShowsEntrypointEvents(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 := &database.Webhook{
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
entrypoint := func() *database.Entrypoint {
ep := &database.Entrypoint{
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// event stores an event that arrived on ep's URL age ago and
// records it as ep's last event, as the receiver does.
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
e := &database.Event{
WebhookID: wh.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
}
e.CreatedAt = time.Now().Add(-age)
require.NoError(t,
webhookDB.Omit(clause.Associations).Create(e).Error)
require.NoError(t, database.AddEntrypointTotals(webhookDB,
database.EntrypointTotals{
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
}))
return e.CreatedAt
}
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
event(busy, 8*24*time.Hour) // older than the 7 days kept
event(busy, 3*time.Hour)
busyLast := event(busy, time.Hour)
quietLast := event(quiet, 2*24*time.Hour)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
entrypointRow(t, body, unused.ID))
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
}
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
// that once retention has removed every event that arrived on an
// entrypoint's URL, the entrypoint still shows when the last one
// arrived rather than "never".
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
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: "swept", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 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))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(0), row)
}
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
// resubmitted copy, which did not arrive on the entrypoint's URL,
// changes neither the entrypoint's last event nor its count.
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
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)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
require.Equal(t, http.StatusSeeOther,
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
require.Len(t, listEvents(t, webhookDB), 2)
var totals database.EntrypointTotals
require.NoError(t, webhookDB.Take(&totals).Error)
assert.True(t, arrived.Equal(totals.LastEventAt))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(1), row)
}
+7 -10
View File
@@ -15,19 +15,16 @@ import (
// eventBodyQuery reads one event's stored body as bytes. The cast
// to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, so Content-Length taken from
// the result matches what goes on the wire. The retention reaper
// deletes event rows outright, so a reaped event is simply gone
// and the query finds no row. The deleted_at predicate repeats
// the soft-delete scope GORM adds to its own queries, which Raw
// bypasses; nothing soft-deletes an event, so today it excludes
// nothing.
// the result matches what goes on the wire. The soft-delete
// predicate is spelled out because Raw bypasses GORM's default
// scope, and it is what stops a reaped event still being
// downloadable.
const eventBodyQuery = "SELECT cast(body as blob) " +
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
// HandleEventBodyDownload serves one event's stored body byte
// for byte, which the pages do not: they show it as escaped
// text, cut at maxRenderedBodyBytes in the lists of events, and
// leave a binary one out.
// HandleEventBodyDownload serves one event's stored body in
// full, which the event log page cannot: it caps each rendered
// body at maxRenderedBodyBytes.
//
// The bytes are attacker-supplied — anyone who can reach the
// public receiver chooses them — and this route hands them back
+4 -5
View File
@@ -405,11 +405,10 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
// route. The body is read in one query before any header is
// written, so a reaped event cannot produce a partial download:
// it is a clean 404 with no Content-Length and no
// Content-Disposition. The reaper deletes event rows outright,
// which is the "hard deleted" case. The "soft deleted" case
// covers a row no code produces today: it only pins the query's
// own deleted_at predicate, the soft-delete condition Raw would
// otherwise skip.
// Content-Disposition. Both removals the codebase performs are
// covered — the reaper hard-deletes, and a soft-deleted row is
// excluded by the query's own deleted_at predicate rather than
// by GORM's default scope, which Raw bypasses.
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
t.Parallel()
-168
View File
@@ -1,168 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"io"
"unicode"
"unicode/utf8"
)
// maxRenderedBodyBytes is the most of one event's body that the
// recent events on a webhook's page and the event log show; a larger
// body is cut there and shown whole only on the event's own page.
// Bodies come from the unauthenticated receiver under its 1 MB cap,
// and renderTemplate buffers a whole page before writing it, so a list
// of events cannot show every body whole.
const maxRenderedBodyBytes = 32 << 10
// maxInlineBodyLines is the most lines a body is shown at its full
// height with. A body with more lines, or larger than
// maxRenderedBodyBytes, is shown in a box of fixed height that
// scrolls, so that it does not make the page huge.
const maxInlineBodyLines = 200
// maxIndentDepth is how deeply a JSON body's objects and arrays may
// nest for it to be indented at all; a deeper one is shown as received.
// Each level indents every line inside it two more spaces, so 10 KB of
// nested brackets would indent to some 50 MB; within this depth a body
// grows at most 35 times.
const maxIndentDepth = 16
// A JSON body is shown pretty-printed only when that makes it at most
// maxIndentGrowth times its size plus indentAllowance bytes, and
// otherwise as received, so that indenting does not undo
// maxRenderedBodyBytes. The allowance keeps a small nested body
// pretty-printed.
const (
maxIndentGrowth = 4
indentAllowance = 1 << 10
)
// jsonIndent is the indent of a pretty-printed JSON body.
const jsonIndent = " "
// BodyView is an event's body as the pages show it. newBodyView
// decides it and templates/event_body.html shows it, the same way in
// the recent events on a webhook's page, in the event log and on the
// event's own page.
type BodyView struct {
// EventURL is the event's own page. The stored body downloads
// from EventURL/body.
EventURL string
// Text is the body as shown, pretty-printed when it is JSON.
Text string
// Size is the stored body's size in bytes, and ShownBytes how
// many of them Text holds when Cut.
Size int64
ShownBytes int
// Cut reports that Text is only the start of the body.
Cut bool
// Binary reports a body that is not text. It is not shown.
Binary bool
// Scroll reports a body to show in a box that scrolls.
Scroll bool
}
// newBodyView decides how to show an event's body. body is the
// stored body, or its first maxRenderedBodyBytes when only those were
// read, and size is the stored body's size.
func newBodyView(eventURL string, body []byte, size int64) BodyView {
v := BodyView{EventURL: eventURL, Size: size}
if size > int64(len(body)) {
v.Cut = true
body = trimPartialRune(body)
v.ShownBytes = len(body)
}
// html/template shows invalid UTF-8 as replacement characters,
// and a browser shows a control character other than tab, line
// feed and carriage return as a box or not at all, so a body
// holding either is not text.
isControl := func(r rune) bool {
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
}
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
v.Binary = true
return v
}
// A cut JSON document is no longer valid JSON.
if !v.Cut {
body = indentJSON(body)
}
// The page shows a carriage return, a line feed, or the two
// together as one line break. A final one ends the last line
// rather than starting another.
text := bytes.TrimSuffix(body, []byte("\n"))
text = bytes.TrimSuffix(text, []byte("\r"))
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
bytes.Count(text, []byte("\r\n"))
lines := breaks + 1
v.Text = string(body)
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
return v
}
// indentJSON returns body pretty-printed when it is a JSON document,
// and unchanged when it is not, nests deeper than maxIndentDepth, or
// would grow past maxIndentGrowth times its size plus indentAllowance
// bytes.
func indentJSON(body []byte) []byte {
if !json.Valid(body) || !indentFits(body) {
return body
}
var out bytes.Buffer
err := json.Indent(&out, body, "", jsonIndent)
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
return body
}
return out.Bytes()
}
// indentFits reports whether the objects and arrays of the JSON
// document body nest at most maxIndentDepth deep.
func indentFits(body []byte) bool {
depth := 0
dec := json.NewDecoder(bytes.NewReader(body))
// A number too large for a float64 is still valid JSON.
dec.UseNumber()
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return true
}
if err != nil {
return false
}
switch tok {
case json.Delim('{'), json.Delim('['):
depth++
if depth > maxIndentDepth {
return false
}
case json.Delim('}'), json.Delim(']'):
depth--
}
}
}
-176
View File
@@ -1,176 +0,0 @@
package handlers_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// bodyView is how the pages would show body, stored whole.
func bodyView(body string) handlers.BodyView {
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
}
// lines is n lines of text, without a newline after the last.
func lines(n int) string {
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
}
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
// pretty-printed, whatever its content type, with its keys in
// the order they arrived.
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
t.Parallel()
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
assert.Equal(t, []string{
`{`,
` "b": 1,`,
` "a": [`,
` true,`,
` null,`,
` "x"`,
` ],`,
` "c": {}`,
`}`,
}, strings.Split(v.Text, "\n"))
assert.False(t, v.Scroll)
}
// TestNewBodyView_FormatsNestedJSON proves a small document with a
// few levels of nesting is pretty-printed.
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
t.Parallel()
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
assert.Equal(t, []string{
`{`,
` "data": [`,
` [`,
` 1,`,
` 2,`,
` 3`,
` ],`,
` [`,
` 4,`,
` 5,`,
` 6`,
` ]`,
` ]`,
`}`,
}, strings.Split(v.Text, "\n"))
}
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
// a JSON document is shown exactly as it arrived.
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
t.Parallel()
for _, body := range []string{
`{"a":1,`,
`{"a":1} {"b":2}`,
"plain text\n indented",
} {
assert.Equal(t, body, bodyView(body).Text)
}
}
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
// more than 16 levels deep is shown as it arrived. 10 KB of nested
// arrays would indent to some 50 MB.
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
t.Parallel()
nested := func(depth int) string {
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
}
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
assert.Equal(t, body, bodyView(body).Text)
}
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
// pretty-printing would make more than four times its size plus 1 KiB
// is shown as it arrived, however shallow: each short element eight
// levels deep gets a line indented sixteen spaces.
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
t.Parallel()
numbers := func(n int) string {
return strings.Repeat("[", 8) +
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
strings.Repeat("]", 8)
}
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
}
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
// its full height up to 200 lines and in the scrolling box past
// them, counting the lines after formatting.
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(lines(200)).Scroll)
assert.True(t, bodyView(lines(201)).Scroll)
// A final newline ends the last line rather than starting another.
assert.False(t, bodyView(lines(200)+"\n").Scroll)
assert.True(t, bodyView(lines(201)+"\n").Scroll)
// The page shows a carriage return, a line feed, or the two
// together as one line break.
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
// One line as received, 201 once formatted: the brackets and
// 199 elements.
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
assert.NotContains(t, numbers, "\n")
assert.True(t, bodyView(numbers).Scroll)
}
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
// cap of the lists of events is shown in the scrolling box
// however few lines it has, on the event's own page as in the
// lists.
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
}
// TestNewBodyView_BinaryNotShown proves a body that is not text
// is never shown: one that is not valid UTF-8, or that holds a
// control character other than tab, line feed and carriage return.
func TestNewBodyView_BinaryNotShown(t *testing.T) {
t.Parallel()
for _, body := range []string{
"\xff\xfe\xfd",
"a\x00b",
// A small protobuf message: valid UTF-8, but control bytes.
"\x08\x01\x12\x03abc",
"\x1b[31mred\x1b[0m",
"a\x7fb",
} {
v := bodyView(body)
assert.True(t, v.Binary, "%q", body)
assert.Empty(t, v.Text)
}
assert.False(t, bodyView("snow "+snowman).Binary)
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
}
-74
View File
@@ -1,74 +0,0 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEventDetail shows one event on its own page: its details,
// its whole body and every delivery of it. The page reads the
// event's body whole, which the receiver caps at 1 MB.
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
var rows []eventLogRow
err = webhookDB.Model(&database.Event{}).
Select(eventColumns).
Where(
"id = ? AND webhook_id = ?",
chi.URLParam(r, "eventID"), webhook.ID,
).
Limit(1).
Find(&rows).Error
if err != nil {
h.serverError(w, r, "failed to load event", err)
return
}
if len(rows) == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
targets, err := h.loadTargetMap(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to load targets", err)
return
}
views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets,
)
if !ok {
return
}
h.renderTemplate(w, r, "event_detail.html", map[string]any{
tmplKeyWebhook: &webhook,
"Event": views[0],
})
}
}
-152
View File
@@ -1,152 +0,0 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// serveEventPage runs the real event page handler as the test user
// for the given webhook and event ids.
func serveEventPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID, eventID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID+"/events/"+eventID,
nil,
)
for _, c := range authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
) {
req.AddCookie(c)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add(paramSourceID, webhookID)
rctx.URLParams.Add(paramEventID, eventID)
req = req.WithContext(
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
)
w := httptest.NewRecorder()
h.HandleEventDetail().ServeHTTP(w, req)
return w
}
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
// event's page shows its details, its whole body even past the cap
// of the lists of events, pretty-printed and in the scrolling box,
// and each delivery with its status and attempts.
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
const sentinel = "TAIL-SENTINEL-5b2e"
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
`","tail":"` + sentinel + `"}`
event := f.event(t, contentTypeJSON, body, time.Now())
f.attempt(t, f.delivery(
t, event, target.ID, database.DeliveryStatusFailed,
), http.StatusBadGateway, time.Second)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, event.ID)
assert.Contains(t, page, contentTypeJSON)
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>")
assert.Contains(t, page, "Status: 502")
}
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
// page links to its original's page, and the original's page says
// it was resubmitted.
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
original := f.event(t, contentTypeJSON, "{}", time.Now())
cp := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: "{}",
ContentType: contentTypeJSON,
ResubmittedFromID: &original.ID,
}
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(
t, w.Body.String(),
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
)
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "as 1 new event<")
}
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
// 404 for an event that does not exist and for one that belongs to
// another webhook.
func TestHandleEventDetail_UnknownEventNotFound(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)
mine := seedWebhook(t, db)
theirs := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, mine.ID, "{}")
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
for _, id := range []string{"no-such-event", elsewhere.ID} {
w := serveEventPage(t, h, sess, mine.ID, id)
assert.Equal(t, http.StatusNotFound, w.Code, id)
}
}
+50 -98
View File
@@ -1,15 +1,18 @@
package handlers
import (
"encoding/json"
"net/http"
"slices"
"time"
"unicode/utf8"
"sneak.berlin/go/webhooker/internal/database"
)
// maxRenderedBodyBytes caps how many bytes of a stored event
// body reach the event log page. Bodies come from the
// unauthenticated receiver under the 1 MB ingest cap and
// renderTemplate buffers a whole page before writing it, so
// an uncapped page of paginationPerPage events is tens of
// megabytes of resident memory per concurrent viewer.
const maxRenderedBodyBytes = 8192
// eventLogColumns is the event log's projection. The casts to
// blob are load-bearing: they make substr and length count
// bytes rather than characters, so the cap bounds the page in
@@ -17,36 +20,31 @@ import (
// rather than in Go is the point of the projection — an
// oversized body never becomes a Go string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"resubmitted_from_id, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which
// shows the whole body.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// EventLogView is the display-safe projection of an event for
// the event log page and the event's own page, alongside
// DeliveryView and TargetView.
// the event log page, alongside DeliveryView and TargetView.
// It carries a capped body plus the true stored size, so the
// page can mark a body as truncated without ever holding the
// whole thing.
type EventLogView struct {
ID string
CreatedAt time.Time
Method string
ContentType string
Body BodyView
// Body holds at most maxRenderedBodyBytes bytes of the
// stored body.
Body string
// Entrypoint names the entrypoint the event arrived at: its
// description, "Entrypoint" when it has none, or "deleted
// entrypoint". Never its URL, which is the entrypoint's secret.
Entrypoint string
// BodyBytes is the true size of the stored body.
BodyBytes int64
// Headers is the event's request headers, one "Name: value"
// per value, sorted by name.
Headers []string
// BodyTruncated reports that the stored body was larger
// than the cap, so the page owes the reader a marker.
BodyTruncated bool
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
@@ -67,101 +65,55 @@ func (v EventLogView) ResubmittedFrom() bool {
return v.ResubmittedFromID != ""
}
// eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its body column arrives
// already cut to the cap by SQLite, with the true size beside
// it.
// BodyShownBytes is how many body bytes the page is actually
// rendering, which the truncation marker reports beside the
// true size.
func (v EventLogView) BodyShownBytes() int {
return len(v.Body)
}
// eventLogRow is one row of the event log projection. Its
// body column arrives already cut to the cap by SQLite, with
// the true size beside it.
type eventLogRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
EntrypointID string
Headers string
Body []byte
BodyBytes int64
}
// view projects a loaded row of the webhook's events for
// rendering.
func (r *eventLogRow) view(webhookID string) EventLogView {
// view projects a loaded row for rendering.
func (r *eventLogRow) view() EventLogView {
body := r.Body
truncated := r.BodyBytes > int64(len(body))
// Only a cut body can have been left mid-sequence by
// this query. A whole body is passed through exactly as
// stored, however malformed.
if truncated {
body = trimPartialRune(body)
}
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
return EventLogView{
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
Headers: requestHeaderLines(r.Headers),
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: string(body),
BodyBytes: r.BodyBytes,
BodyTruncated: truncated,
ResubmittedFromID: from,
}
}
// requestHeaderLines turns an event's stored request headers, the
// JSON the receiver writes, into one "Name: value" line per value,
// sorted by name. Headers that do not parse show as none.
func requestHeaderLines(headersJSON string) []string {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
for _, name := range names {
for _, value := range headers[name] {
lines = append(lines, name+": "+value)
}
}
return lines
}
// entrypointNames maps each of the webhook's entrypoints to the name
// an event that arrived at it shows: its description, or "Entrypoint"
// when it has none, as the webhook page names it. A deleted
// entrypoint is left out.
func (h *Handlers) entrypointNames(
webhookID string,
) (map[string]string, error) {
var entrypoints []database.Entrypoint
err := h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&entrypoints).Error
if err != nil {
return nil, err
}
names := make(map[string]string, len(entrypoints))
for i := range entrypoints {
name := entrypoints[i].Description
if name == "" {
name = "Entrypoint"
}
names[entrypoints[i].ID] = name
}
return names, nil
}
// trimPartialRune drops a trailing UTF-8 sequence that the
// byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail.
+21 -31
View File
@@ -16,7 +16,7 @@ import (
"sneak.berlin/go/webhooker/internal/session"
)
// bodyCap is the number of body bytes the lists of events are
// bodyCap is the number of body bytes the event log page is
// allowed to render for one event.
const bodyCap = handlers.MaxRenderedBodyBytesForTest
@@ -85,7 +85,7 @@ func seedAndProject(
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
// page is bounded by the cap rather than by the stored payload:
// the body here is 16 times the cap, and the ingest path would
// the body here is 64 times the cap, and the ingest path would
// accept twice as much again.
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
t.Parallel()
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
// The marker states the true stored size, not the cut one.
assert.Contains(
t, page,
"Showing the first "+strconv.Itoa(bodyCap)+
"showing "+strconv.Itoa(bodyCap)+
" of "+strconv.Itoa(storedBytes)+" bytes",
)
}
@@ -152,35 +152,34 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
page := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, page, "&#34;kept&#34;")
assert.NotContains(t, page, "Showing the first")
assert.NotContains(t, page, "Body truncated for display")
}
// TestEventLogView_CutMidRune proves a multi-byte rune severed
// by the byte-wise cut is dropped rather than surfaced as a
// mojibake tail, which would also make the text look binary.
// mojibake tail.
func TestEventLogView_CutMidRune(t *testing.T) {
t.Parallel()
body := strings.Repeat(snowman, bodyCap)
body := strings.Repeat(snowman, 4096)
view := seedAndProject(t, body)
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
// of the next one; those two are dropped.
whole := bodyCap / len(snowman)
assert.True(t, view.Body.Cut)
assert.False(t, view.Body.Binary)
assert.Equal(t, int64(len(body)), view.Body.Size)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
assert.True(t, utf8.ValidString(view.Body.Text))
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(body)), view.BodyBytes)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
assert.True(t, utf8.ValidString(view.Body))
assert.LessOrEqual(t, len(view.Body), bodyCap)
}
// TestEventLogView_BinaryBodyNotShown proves a body that is not
// text is left out rather than shown as replacement characters,
// whether it is cut or not.
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
// payload is passed through byte for byte. Its tail is invalid
// UTF-8 however the cut falls, so repairing it would misreport
// what the sender delivered.
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
t.Parallel()
raw := make([]byte, bodyCap+808)
@@ -189,21 +188,12 @@ func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
raw[i] = 0x80 | byte(i%0x40)
}
for name, body := range map[string][]byte{
"cut": raw,
"whole": raw[:2048],
"NUL": []byte("text\x00text"),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
view := seedAndProject(t, string(raw))
view := seedAndProject(t, string(body))
assert.True(t, view.Body.Binary)
assert.Empty(t, view.Body.Text)
assert.Equal(t, int64(len(body)), view.Body.Size)
})
}
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(raw)), view.BodyBytes)
assert.Equal(t, string(raw[:bodyCap]), view.Body)
assert.False(t, utf8.ValidString(view.Body))
}
// TestTrimPartialRune covers the distinction the cut repair
-161
View File
@@ -1,161 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900">` + name + `</span>`
}
// entrypoint records one of the fixture webhook's entrypoints.
func (f *recentEventsFixture) entrypoint(
t *testing.T, description string,
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
WebhookID: f.webhook.ID,
Path: uuid.NewString(),
Description: description,
Active: true,
}
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// eventAt records an event that arrived at the entrypoint with the
// given request headers, stored as the receiver stores them.
func (f *recentEventsFixture) eventAt(
t *testing.T,
ep *database.Entrypoint,
headers http.Header,
receivedAt time.Time,
) *database.Event {
t.Helper()
headersJSON, err := json.Marshal(headers)
require.NoError(t, err)
event := &database.Event{
WebhookID: f.webhook.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
Headers: string(headersJSON),
Body: "{}",
BodyBytes: 2,
ContentType: contentTypeJSON,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
// events that arrived at two entrypoints each show their own
// entrypoint and request headers, in the event log and on their own
// pages, with the headers sorted by name and escaped, and never the
// entrypoint's URL.
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t *testing.T,
) {
t.Parallel()
f := newRecentEventsFixture(t)
billing := f.entrypoint(t, "Billing sender")
unnamed := f.entrypoint(t, "")
older := f.eventAt(t, billing, http.Header{
"X-Shop-Event": {"order.created"},
"User-Agent": {"shop/1"},
}, time.Now().Add(-time.Minute))
newer := f.eventAt(t, unnamed, http.Header{
"X-Shop-Event": {"order.paid"},
"X-Note": {"<b>hi</b>"},
}, time.Now())
olderShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, "<div>User-Agent: shop/1</div>")
assert.Contains(t, page, "<div>X-Shop-Event: order.created</div>")
assert.Less(t,
strings.Index(page, "User-Agent: shop/1"),
strings.Index(page, "X-Shop-Event: order.created"),
"headers are sorted by name",
)
assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path)
}
newerShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, "<div>X-Shop-Event: order.paid</div>")
assert.Contains(t, page, "<div>X-Note: &lt;b&gt;hi&lt;/b&gt;</div>")
assert.NotContains(t, page, "<b>hi</b>")
assert.NotContains(t, page, "order.created")
assert.NotContains(t, page, unnamed.Path)
}
// The log lists the newer event first, so everything between
// the two events' first mentions belongs to the newer one.
_, rest, found := strings.Cut(renderSourceLogsPage(
t, f.h, f.sess, f.webhook.ID,
), newer.ID)
require.True(t, found)
newerPart, olderPart, found := strings.Cut(rest, older.ID)
require.True(t, found)
newerShows(t, newerPart)
olderShows(t, olderPart)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
require.Equal(t, http.StatusOK, w.Code)
newerShows(t, w.Body.String())
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
require.Equal(t, http.StatusOK, w.Code)
olderShows(t, w.Body.String())
}
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
// has since been deleted says so in the event log and on its own page.
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Retired sender")
event := f.eventAt(t, ep, nil, time.Now())
require.NoError(t, f.db.DB().Delete(ep).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
assert.NotContains(t, page, "Retired sender")
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
assert.NotContains(t, w.Body.String(), "Retired sender")
}
+2 -3
View File
@@ -145,9 +145,8 @@ func (h *Handlers) resubmitEvent(
// per-webhook database files — a sibling webhook's event is not in the
// database being queried at all — and is there so the scoping survives
// any future change that puts more than one webhook's events in one
// file. A reaped event is not found because the retention reaper
// deletes its row outright rather than marking it deleted; see
// deleteEvents in internal/database/retention.go.
// file. Going through Model applies GORM's soft-delete scope, which is
// what stops a reaped event being resubmitted.
func loadResubmitSource(
webhookDB *gorm.DB,
webhookID, eventID string,
+14 -26
View File
@@ -19,16 +19,10 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
s.log = log
}
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
// of events to the handlers_test package.
// MaxRenderedBodyBytesForTest exposes the event log's body cap
// to the handlers_test package.
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
// NewBodyViewForTest exposes newBodyView for use in the
// handlers_test package.
func NewBodyViewForTest(body []byte, size int64) BodyView {
return newBodyView("/hook/w/events/e", body, size)
}
// MaxRenderedResponseBytesForTest exposes the event log's
// delivery response cap to the handlers_test package.
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
@@ -42,15 +36,6 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
// the handlers enforce rather than a number copied beside it.
const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event
// database is left on disk, so a test checking that one is absent
// checks for the handler's own wording.
const (
EventDBLeftMsgForTest = eventDBLeftMsg
SidecarLeftMsgForTest = sidecarLeftMsg
)
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package.
func PageOrFirstForTest(s string) int {
@@ -143,20 +128,22 @@ func (s *Handlers) RenderTemplateForTest(
// BuildSlackTargetConfigForTest exposes
// buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, string, error) {
return s.buildSlackTargetConfig(ctx, targetURL)
) (string, error) {
return s.buildSlackTargetConfig(w, r, targetURL)
}
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package, taking the form fields
// an HTTP target's configuration is built from.
func (s *Handlers) BuildHTTPTargetConfigForTest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL, headers, timeout string,
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
) (string, error) {
return s.buildHTTPTargetConfig(w, r, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
@@ -166,8 +153,9 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, string, error) {
return buildDatabaseTargetConfig(expiry)
) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
}
+36 -82
View File
@@ -10,12 +10,10 @@ import (
"html/template"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
@@ -57,20 +55,18 @@ var errVerificationBusy = errors.New(
type HandlersParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Database *database.Database
WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck
Session *session.Session
Middleware *middleware.Middleware
Notifier delivery.Notifier
Archives delivery.Archives
CircuitBreakers delivery.CircuitBreakers
SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
Logger *logger.Logger
Globals *globals.Globals
Database *database.Database
WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck
Session *session.Session
Middleware *middleware.Middleware
Notifier delivery.Notifier
Archives delivery.Archives
SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
}
// Handlers provides HTTP handler methods for all application
@@ -85,7 +81,6 @@ type Handlers struct {
mw *middleware.Middleware
notifier delivery.Notifier
archives delivery.Archives
breakers delivery.CircuitBreakers
mtr *metrics.Set
templates map[string]*template.Template
@@ -94,15 +89,6 @@ type Handlers struct {
// is one delivery will actually attempt.
ssrf *delivery.Guard
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// it reads the stored names and opens the file they give.
renameMu sync.Mutex
// dummyVerifications counts the equivalent-cost verifications
// charged for usernames that do not exist. It exists so a test
// can prove that path runs without measuring wall-clock time.
@@ -112,25 +98,22 @@ 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 set is named after the page file, so
// the page's root action ({{template "base" .}}) is its entry point.
//
// The page file is parsed last because a later definition of a name
// replaces an earlier one: the page's {{define "title"}} must replace
// the {{block "title"}} fallback in htmlheader.html.
// 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{
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
"notice.html",
}, included...)
files = append(files, pageFile)
return template.Must(
template.New(pageFile).ParseFS(templates.Templates, files...),
template.ParseFS(templates.Templates, files...),
)
}
@@ -150,31 +133,20 @@ func New(
s.mw = params.Middleware
s.notifier = params.Notifier
s.archives = params.Archives
s.breakers = params.CircuitBreakers
s.mtr = params.Metrics
s.ssrf = params.SSRFGuard
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.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", "event_body.html",
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
"source_logs.html", "event_request.html", "event_body.html",
"delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_request.html", "event_body.html",
"delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
"login.html": parsePageTemplate("login.html"),
"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_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{
@@ -325,26 +297,12 @@ func (s *Handlers) getUserInfo(
}
// renderTemplate renders a pre-parsed template with common
// data and answers 200.
// data
func (s *Handlers) renderTemplate(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
) {
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
}
// renderTemplateStatus is renderTemplate answering with status, for a
// form shown again with an error. Call it instead of WriteHeader
// followed by renderTemplate: the status is written only once the page
// has rendered, so a failed render can still answer 500.
func (s *Handlers) renderTemplateStatus(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
status int,
) {
tmpl, ok := s.templates[pageTemplate]
if !ok {
@@ -357,9 +315,7 @@ func (s *Handlers) renderTemplateStatus(
return
}
s.executeTemplate(
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
)
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
}
// pageData adds the fields the shared layout renders to a page's own
@@ -395,20 +351,19 @@ func (s *Handlers) pageData(
}
}
// executeTemplate renders the template into a buffer and writes status
// and the page to the response only once rendering has fully
// succeeded. Executing straight into the ResponseWriter commits a
// partial body and the status before a mid-render error can be
// reported, leaving no way to serve a 500. Buffering makes a page's
// rendered size resident memory per concurrent viewer, so every page
// owes it a bound: the lists of events cap each stored body at
// maxRenderedBodyBytes for exactly this reason.
// executeTemplate renders the template into a buffer and writes to
// the response only once rendering has fully succeeded. Executing
// straight into the ResponseWriter commits a partial body and a 200
// status before a mid-render error can be reported, leaving no way
// to serve a 500. Buffering makes a page's rendered size resident
// memory per concurrent viewer, so every page owes it a bound: the
// event log caps each stored body at maxRenderedBodyBytes for exactly
// this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
status int,
) {
var buf bytes.Buffer
@@ -423,7 +378,6 @@ func (s *Handlers) executeTemplate(
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
+51 -113
View File
@@ -9,7 +9,6 @@ import (
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -57,16 +56,13 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
// 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. After
// BlockNextRename, the next rename is recorded and then waits.
// rename of that target fails with the given error.
type recordingArchives struct {
mu sync.Mutex
evicted []string
evictedTargets []string
renames []archiveRename
renameErrs map[string]error
entered chan struct{}
release chan struct{}
}
// errInjectedRename is the failure a test hands FailRenames.
@@ -103,38 +99,15 @@ 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,
})
err := r.renameErrs[targetID]
entered, release := r.entered, r.release
r.entered, r.release = nil, nil
r.mu.Unlock()
if entered != nil {
close(entered)
<-release
}
return err
}
// BlockNextRename makes the next rename, once recorded, wait until
// the returned release is called. The returned channel is closed
// when that rename starts waiting.
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
entered := make(chan struct{})
release := make(chan struct{})
r.mu.Lock()
r.entered, r.release = entered, release
r.mu.Unlock()
return entered, func() { close(release) }
return r.renameErrs[targetID]
}
// FailRenames makes every later rename of targetID fail with err.
@@ -182,74 +155,22 @@ func (r *recordingArchives) Renames() []archiveRename {
return out
}
// testCircuitBreakers is a delivery.CircuitBreakers that reports, for
// each target, the circuit state and cooldown a test gave it with Set,
// and a closed breaker for any other target.
type testCircuitBreakers struct {
mu sync.Mutex
states map[string]delivery.CircuitState
cooldowns map[string]time.Duration
}
// Set makes the target's breaker read as state, with cooldown left.
func (b *testCircuitBreakers) Set(
targetID string, state delivery.CircuitState, cooldown time.Duration,
) {
b.mu.Lock()
defer b.mu.Unlock()
if b.states == nil {
b.states = map[string]delivery.CircuitState{}
b.cooldowns = map[string]time.Duration{}
}
b.states[targetID] = state
b.cooldowns[targetID] = cooldown
}
func (b *testCircuitBreakers) StateAndCooldown(
targetID string,
) (delivery.CircuitState, time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
return b.states[targetID], b.cooldowns[targetID]
}
// newTestApp returns an app whose RequireStart fails the test when
// starting takes longer than fx's default start timeout of 15s. That
// limit catches a start that hangs, not a busy host: measured with make
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
// package's starts took 0.49s.
func newTestApp(
t *testing.T,
targets ...any,
) *fxtest.App {
t.Helper()
return newTestAppWithConfig(
t, &config.Config{DataDir: t.TempDir()}, targets...,
)
}
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
func newTestAppWithConfig(
t *testing.T,
cfg *config.Config,
targets ...any,
) *fxtest.App {
t.Helper()
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,
func() *config.Config { return cfg },
func() *config.Config {
return &config.Config{
DataDir: t.TempDir(),
}
},
database.New,
database.NewWebhookDBManager,
healthcheck.New,
@@ -266,12 +187,6 @@ func newTestAppWithConfig(
func(r *recordingArchives) delivery.Archives {
return r
},
func() *testCircuitBreakers {
return &testCircuitBreakers{}
},
func(b *testCircuitBreakers) delivery.CircuitBreakers {
return b
},
metrics.NewRegistry,
metrics.New,
middleware.New,
@@ -355,12 +270,16 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://93.184.216.34/services/T00/B00/xxx",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, cfg, "webhookUrl")
}
@@ -374,13 +293,17 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://169.254.169.254/latest/meta-data/",
)
require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL")
require.Error(t, err)
assert.Empty(t, cfg)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRenderTemplate(t *testing.T) {
@@ -477,22 +400,29 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// Empty expiry: the keep-forever default, empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
// Explicit never is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
}
@@ -501,14 +431,22 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
) {
t.Parallel()
for _, bad := range []string{"nonsense", "7d", "-5h"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
var h *handlers.Handlers
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive expiry",
"expiry %q should be refused", bad,
)
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
for _, bad := range []string{"nonsense", "7d", "-5h"} {
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
require.Error(t, err, "expiry %q", bad)
assert.Empty(t, cfg)
assert.Equal(
t, http.StatusBadRequest, w.Code,
"expiry %q should be rejected with 400", bad,
)
}
}
+1 -4
View File
@@ -20,7 +20,6 @@ const (
webhookSaved noticeCode = "webhook-saved"
webhookDeleted noticeCode = "webhook-deleted"
entrypointAdded noticeCode = "entrypoint-added"
entrypointSaved noticeCode = "entrypoint-saved"
entrypointDeleted noticeCode = "entrypoint-deleted"
entrypointActivated noticeCode = "entrypoint-activated"
entrypointDeactivated noticeCode = "entrypoint-deactivated"
@@ -49,7 +48,6 @@ func noticeFor(r *http.Request) *notice {
webhookSaved: {Text: "Webhook saved."},
webhookDeleted: {Text: "Webhook deleted."},
entrypointAdded: {Text: "Entrypoint added."},
entrypointSaved: {Text: "Entrypoint description saved."},
entrypointDeleted: {Text: "Entrypoint deleted."},
entrypointActivated: {Text: "Entrypoint activated."},
entrypointDeactivated: {Text: "Entrypoint deactivated."},
@@ -66,8 +64,7 @@ func noticeFor(r *http.Request) *notice {
},
replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " +
"has been deleted. Use Resubmit to send the event " +
"to the webhook's currently active targets.",
"has been deleted. Recreate the target, then replay.",
Failed: true,
},
replayTargetMissing: {
-107
View File
@@ -1,107 +0,0 @@
package handlers_test
import (
"html/template"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
)
// TestEveryPageRendersItsOwnTitle renders each page template and checks
// the browser tab title is the one the page declares, not the
// "Webhooker" fallback in htmlheader.html. A page that fails to render
// shows the error page's title instead, and fails here too.
func TestEveryPageRendersItsOwnTitle(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: some pages call
// Webhook.RetentionLabel, a pointer method.
webhook := &database.Webhook{Name: "orders", RetentionDays: 14}
webhook.ID = testWebhookID
pages := []struct {
page string
data map[string]any
title string
}{
{"login.html", map[string]any{}, "Login - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
{"sources_new.html", map[string]any{}, "New Webhook - Webhooker"},
{
"source_detail.html",
map[string]any{dataKeyWebhook: webhook},
"orders - Webhooker",
},
{
"source_edit.html",
map[string]any{dataKeyWebhook: webhook},
"Edit orders - Webhooker",
},
{
"source_logs.html",
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
"Full Event Log - orders - Webhooker",
},
{
"event_detail.html",
map[string]any{dataKeyWebhook: webhook},
"Event - orders - Webhooker",
},
{
"target_edit.html",
map[string]any{
dataKeyWebhook: webhook,
"Target": map[string]any{"Name": "alerts", "Type": "slack"},
},
"Edit alerts - Webhooker",
},
{
"error.html",
map[string]any{"StatusText": http.StatusText(http.StatusNotFound)},
"Not Found - Webhooker",
},
}
for _, p := range pages {
body := renderPage(t, h, sess, p.page, p.data)
_, afterOpen, _ := strings.Cut(body, "<title>")
title, _, _ := strings.Cut(afterOpen, "</title>")
assert.Equal(t, p.title, title, p.page)
}
}
// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a
// page that declares none. Every page declares one, so it is checked on
// htmlheader.html alone.
func TestTitleFallbackIsWebhooker(t *testing.T) {
t.Parallel()
header := template.Must(
template.ParseFS(templates.Templates, "htmlheader.html"),
)
var buf strings.Builder
require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil))
assert.Contains(t, buf.String(), "<title>Webhooker</title>")
}
+8 -20
View File
@@ -12,12 +12,11 @@ import (
)
// recentEventColumns is the recent events list's projection. It
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
// does, and its size from body_bytes, recorded when the event was
// 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, " +
"substr(cast(body as blob), 1, ?) AS body"
"resubmitted_from_id, body_bytes"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
@@ -51,9 +50,6 @@ type RecentEventView struct {
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
// Body is what the row shows when it is expanded.
Body BodyView
}
// recentEventRow is one row of recentEventColumns.
@@ -64,7 +60,6 @@ type recentEventRow struct {
ContentType string
ResubmittedFromID *string
BodyBytes uint64
Body []byte
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
@@ -105,7 +100,7 @@ func loadRecentEvents(
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns, maxRenderedBodyBytes).
Select(recentEventColumns).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
@@ -150,7 +145,7 @@ func loadRecentEvents(
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
byEvent[rows[i].ID], attempts, statusTargetID,
)
}
@@ -187,20 +182,14 @@ func loadRecentAttempts(
return byDelivery, nil
}
// view projects a loaded row of the webhook's events for
// rendering. deliveries is the event's deliveries, oldest first,
// and attempts their recorded attempts keyed by delivery ID.
// 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(
webhookID string,
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
body := newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
)
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
@@ -208,7 +197,6 @@ func (r *recentEventRow) view(
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
Body: body,
}
if r.ResubmittedFromID != nil {
-68
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -302,73 +301,6 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
}
}
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
// links to its event's own page and expands to show its body, and
// that only the newest row starts expanded.
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
older := f.event(
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
)
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
body := f.render(t)
for _, e := range []*database.Event{older, newer} {
assert.Contains(
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
)
}
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
assert.Equal(t, 1, strings.Count(body, " data-open>"))
open := strings.Index(body, " data-open>")
newerBody := strings.Index(body, "&#34;which&#34;: &#34;newer&#34;")
olderBody := strings.Index(body, "&#34;which&#34;: &#34;older&#34;")
assert.Less(t, open, newerBody, "the newest row is not the open one")
assert.Less(t, newerBody, olderBody)
}
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
// the cap is shown whole and pretty-printed, and a larger one only
// its first bodyCap bytes, as received, with links to the whole
// body on the event's page and to the download.
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
// A JSON document of n bytes.
document := func(n int) string {
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
}
whole := f.event(
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
)
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
body := f.render(t)
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
assert.Equal(t, 1, strings.Count(body, "{\n &#34;pad&#34;: "))
assert.Contains(t, body, "{&#34;pad&#34;:&#34;xxx")
assert.Contains(
t, body,
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
)
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
}
// 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) {
-129
View File
@@ -1,129 +0,0 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, with the description the README's configuration
// table gives it (less its pointers to other README sections), in the
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
// their values never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds. Loopback by default, " +
"so the cleartext listener is not published on every " +
"interface. The Docker image ships 0.0.0.0 instead",
cfg.BindAddress,
},
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
{
"METRICS_USERNAME",
"Basic auth username for /metrics. Must be set together " +
"with METRICS_PASSWORD; one without the other fails " +
"startup",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics. Must be set together " +
"with METRICS_USERNAME; one without the other fails " +
"startup",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Sentry error reporting DSN. Unset leaves error reporting " +
"off; a value the Sentry SDK cannot parse fails startup " +
"rather than serving with reporting silently off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive). A value that does " +
"not parse, or is zero or negative, fails startup",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted. A set value " +
"replaces the default. If any client can reach webhooker, " +
"or the proxy in front of it, from an RFC 1918 source " +
"address, set it to the proxy's address alone",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
-148
View File
@@ -1,148 +0,0 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
// only one of the three set in one of the content tests, so each
// row is checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: "",
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "not set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
MetricsPassword: metricsPassword,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
assert.Equal(t, "not set", shown["SENTRY_DSN"])
assert.NotContains(t, body, metricsPassword)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
}
@@ -1,207 +0,0 @@
package handlers_test
import (
"errors"
"html"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// submitCreateForm posts the new webhook form and returns the
// recorder.
func submitCreateForm(
env *sourceTestEnv, form url.Values,
) *httptest.ResponseRecorder {
req := formRequest("/hooks/new", env.cookies, form, nil)
w := httptest.NewRecorder()
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
return w
}
// assertNothingCreated checks that the main database holds no webhook,
// entrypoint or target.
func assertNothingCreated(t *testing.T, db *database.Database) {
t.Helper()
for _, model := range []any{
&database.Webhook{}, &database.Entrypoint{}, &database.Target{},
} {
var count int64
require.NoError(t, db.DB().Model(model).Count(&count).Error)
assert.Zerof(t, count, "%T rows were created", model)
}
}
// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new
// webhook form with the HTTP target URL filled in or empty, and with
// the archive checkbox off or on with each pruning choice. The webhook
// gets an HTTP target only for a URL and a database target only for a
// checked archive. The pruning choice is always submitted, as the
// browser submits it while it is hidden, and is ignored when archive
// is off.
func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// Each value the archive pruning choice submits, after an empty
// one that stands for the archive checkbox left off.
expiries := []string{
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
}
for _, httpURL := range []string{"", editOriginalURL} {
for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry
t.Run(name, func(t *testing.T) {
t.Parallel()
form := url.Values{}
form.Set("name", name)
form.Set("http_url", httpURL)
form.Set("archive_expiry", "720h")
if expiry != "" {
form.Set("archive", "on")
form.Set("archive_expiry", expiry)
}
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", name).First(&webhook).Error)
byType := map[database.TargetType]database.Target{}
for _, target := range targetsForWebhook(t, env.db, webhook.ID) {
byType[target.Type] = target
}
wantCount := 0
if httpURL != "" {
wantCount++
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
byType[database.TargetTypeHTTP].Config)
}
if expiry != "" {
wantCount++
assert.Equal(t, "Archive",
byType[database.TargetTypeDatabase].Name)
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
byType[database.TargetTypeDatabase].Config)
}
assert.Len(t, byType, wantCount)
})
}
}
}
// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the
// new webhook form for an invalid HTTP target URL and for an invalid
// retention, each with archive on. Nothing is created, and the form
// comes back with the reason and every value entered: name,
// description, retention, URL, the checked archive box and the pruning
// choice.
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
t *testing.T,
) {
t.Parallel()
const badURL = "Invalid target URL"
cases := []struct {
name string
retention string
httpURL string
reason string
}{
{"blocked url", "7", editBlockedURL, badURL},
{"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL},
{"bad retention", "-5", editOriginalURL, "Retention must be"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "kept name")
form.Set("description", "kept description")
form.Set("retention_days", tc.retention)
form.Set("http_url", tc.httpURL)
form.Set("archive", "on")
form.Set("archive_expiry", "2160h")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, tc.reason)
assert.Contains(t, page, `value="kept name"`)
assert.Contains(t, page, `>kept description</textarea>`)
assert.Contains(t, page, `value="`+tc.retention+`"`)
assert.Contains(t, page,
`value="`+html.EscapeString(tc.httpURL)+`"`)
assert.Contains(t, page, `name="archive" value="on" checked`)
assert.Contains(t, page, `x-data="collapsible" data-open`)
assert.Contains(t, page, `<option value="2160h" selected>`)
assertNothingCreated(t, env.db)
})
}
}
// errInjectedTargetCreate is the failure a test makes the insert of a
// target report.
var errInjectedTargetCreate = errors.New("injected target create failure")
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
// inserting a target fail after the webhook and its entrypoint were
// inserted, and checks that neither is left behind.
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
require.NoError(t, env.db.DB().Callback().Create().
Before("gorm:create").
Register("test:fail_target_create", func(tx *gorm.DB) {
if tx.Statement.Table == "targets" {
_ = tx.AddError(errInjectedTargetCreate)
}
}),
)
form := url.Values{}
form.Set("name", "rolled back")
form.Set("archive", "on")
form.Set("archive_expiry", "never")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assertNothingCreated(t, env.db)
}
+2 -122
View File
@@ -1,10 +1,8 @@
package handlers_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -86,13 +84,12 @@ var errInjectedDelete = errors.New("injected delete failure")
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an active entrypoint for a webhook and
// returns it.
// seedEntrypoint inserts an entrypoint for a webhook.
func seedEntrypoint(
t *testing.T,
db *database.Database,
webhookID string,
) *database.Entrypoint {
) {
t.Helper()
ep := &database.Entrypoint{
@@ -105,8 +102,6 @@ func seedEntrypoint(
t,
db.DB().Omit(clause.Associations).Create(ep).Error,
)
return ep
}
// countRows counts the live (not soft-deleted) rows of a model
@@ -471,121 +466,6 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
}
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
// database file is removed but a sidecar beside it is not, the
// operator is told the events are gone, never that the event
// database file is still there.
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
require.NoError(t, mgr.CreateDB(wh.ID))
// Closing removes the sidecars, so the -wal below is the only
// one there.
require.NoError(t, mgr.CloseAll())
// A non-empty directory in the -wal file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NoFileExists(t, eventDBPath)
assert.Contains(t, logs.String(), "its events are gone")
assert.Contains(t, logs.String(), eventDBPath+"-wal")
assert.NotContains(
t, logs.String(), handlers.EventDBLeftMsgForTest,
"the events are gone, so the operator must not be told "+
"the event database file survived",
)
}
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
// event database file itself cannot be removed, the operator is told
// it is still on disk, never that its events are gone.
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
// A non-empty directory in the database file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath, "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(
t, logs.String(), "event database file is still on disk",
)
assert.Contains(t, logs.String(), eventDBPath)
assert.NotContains(
t, logs.String(), handlers.SidecarLeftMsgForTest,
"the database file is still on disk, so the operator must "+
"not be told its events are gone",
)
}
// 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.
+1 -1
View File
@@ -234,7 +234,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry")
assert.Contains(t, body, "30 days")
assert.Contains(t, body, "720h")
// An unknown type gets the neutral placeholder, never the
// stored blob.

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