Compare commits
1
Commits
next
..
3ddc1a3a02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddc1a3a02 |
+18
-14
@@ -76,6 +76,8 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
|||||||
# depends on it.
|
# depends on it.
|
||||||
#
|
#
|
||||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||||
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -165,7 +167,9 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
|
|||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
# The image ships git and make, which the version step below uses.
|
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||||
|
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image
|
||||||
|
# ships git and make, which the version step below uses.
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
# Nothing is wanted from the lint and test phases or from the stylesheet and
|
# Nothing is wanted from the lint and test phases or from the stylesheet and
|
||||||
@@ -207,11 +211,20 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Builds through the Makefile's build target, which runs script/assets
|
# Builds through the Makefile's build target, which runs script/assets
|
||||||
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
|
# (Alpine.js, extracted from its tarball in 3p/) first.
|
||||||
# with cgo off, so the binary is statically linked and runs on the Alpine
|
|
||||||
# runtime image below as it is.
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
# Rebuild with static linking for Alpine runtime.
|
||||||
|
# make build already verified compilation.
|
||||||
|
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||||
|
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||||
|
# the binary runs on Alpine without glibc.
|
||||||
|
#
|
||||||
|
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
||||||
|
# their own: the build target composes them with the -X that stamps the
|
||||||
|
# version, so this relink cannot silently drop the stamp.
|
||||||
|
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
@@ -255,17 +268,8 @@ EXPOSE 8080
|
|||||||
# port.
|
# port.
|
||||||
ENV BIND_ADDRESS=0.0.0.0
|
ENV BIND_ADDRESS=0.0.0.0
|
||||||
|
|
||||||
# The probe requests the container's own address, not loopback, so a
|
|
||||||
# listener that answers only on loopback (an empty or loopback
|
|
||||||
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
|
|
||||||
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
|
|
||||||
# IPv6-only network it prints none, and the probe requests the IPv6
|
|
||||||
# address /etc/hosts lists under the container's hostname instead, which
|
|
||||||
# only :: answers.
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
||||||
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
|
|
||||||
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["/app/webhooker"]
|
CMD ["/app/webhooker"]
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ VERSION ?= $(shell script/version)
|
|||||||
# exists to correct.
|
# exists to correct.
|
||||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||||
|
|
||||||
|
# Extra linker flags for the build target. The static relink in the
|
||||||
|
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||||
|
# so composing flags cannot drop the version stamp.
|
||||||
|
#
|
||||||
|
# The build target itself always passes -trimpath and -s -w, as the Go
|
||||||
|
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
|
||||||
|
# debug information in the binary.
|
||||||
|
GO_LDFLAGS ?=
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
|
|
||||||
@@ -43,12 +52,8 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
|
|
||||||
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
|
|
||||||
# debug information, and on Linux, the image's included, it is statically
|
|
||||||
# linked.
|
|
||||||
build: assets
|
build: assets
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
|
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
|
|||||||
@@ -310,17 +310,16 @@ the namespace, whatever the process bound. Exposure is decided at the publish
|
|||||||
flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is
|
flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is
|
||||||
the operator's to choose and is what [Running with Docker](#running-with-docker)
|
the operator's to choose and is what [Running with Docker](#running-with-docker)
|
||||||
shows. A loopback bind inside a container buys nothing and makes the process
|
shows. A loopback bind inside a container buys nothing and makes the process
|
||||||
unreachable through its own published port. A container on an IPv6-only Docker
|
unreachable through its own published port.
|
||||||
network has no IPv4 address for `0.0.0.0` to answer on, so there it needs
|
|
||||||
`BIND_ADDRESS=::`.
|
|
||||||
|
|
||||||
The value must be an IP address literal:
|
The value must be an IP address literal:
|
||||||
|
|
||||||
- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse
|
- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse
|
||||||
proxy on the same host.
|
proxy on the same host.
|
||||||
- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a
|
- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only
|
||||||
bare host, only behind a firewall on the port.
|
behind a firewall on the port.
|
||||||
- `::` — every IPv6 address and every IPv4 address.
|
- `::` — every address, IPv6 and (on Linux, with the default
|
||||||
|
`net.ipv6.bindv6only=0`) IPv4 as well.
|
||||||
- A specific address such as `10.0.0.5` — that interface only.
|
- A specific address such as `10.0.0.5` — that interface only.
|
||||||
|
|
||||||
An **empty** value is treated as unset, as everywhere else here, and takes the
|
An **empty** value is treated as unset, as everywhere else here, and takes the
|
||||||
@@ -341,8 +340,7 @@ machine that has no such interface — is a valid literal, so it reaches the
|
|||||||
listener and fails there. The process logs the bind error and exits non-zero
|
listener and fails there. The process logs the bind error and exits non-zero
|
||||||
rather than staying up with nothing listening. The effective value is in the
|
rather than staying up with nothing listening. The effective value is in the
|
||||||
`bindAddress` field of the startup log line, which is the way to check what a
|
`bindAddress` field of the startup log line, which is the way to check what a
|
||||||
running deployment actually bound. On a bare host, `ss -ltn` lists the socket
|
running deployment actually bound.
|
||||||
itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`.
|
|
||||||
|
|
||||||
#### Metrics credentials
|
#### Metrics credentials
|
||||||
|
|
||||||
@@ -467,7 +465,10 @@ idle timestamp is rewritten at most once per tenth of the idle window rather
|
|||||||
than on every request, which means a session may expire up to 10% early relative
|
than on every request, which means a session may expire up to 10% early relative
|
||||||
to the user's true last request, but never late.
|
to the user's true last request, but never late.
|
||||||
|
|
||||||
Both clocks are anchored by timestamps stored in the session cookie.
|
Both clocks are anchored by timestamps stored in the session cookie. Sessions
|
||||||
|
issued before this feature existed carry neither, so they are treated as
|
||||||
|
expired: upgrading to a build that has it logs every existing session out once,
|
||||||
|
and those users sign in again.
|
||||||
|
|
||||||
#### Invalid values abort startup
|
#### Invalid values abort startup
|
||||||
|
|
||||||
@@ -670,9 +671,9 @@ Either set a literal or leave the variable out entirely.
|
|||||||
Overriding `BIND_ADDRESS` to a loopback address in a container — by that route
|
Overriding `BIND_ADDRESS` to a loopback address in a container — by that route
|
||||||
or deliberately — makes the container unreachable from outside its namespace
|
or deliberately — makes the container unreachable from outside its namespace
|
||||||
even with `-p`. The published port answers nothing, and the health check fails
|
even with `-p`. The published port answers nothing, and the health check fails
|
||||||
too: it requests the container's own address (its IPv4 address, or its IPv6
|
too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first,
|
||||||
address on an IPv6-only network), where a loopback bind is not listening. The
|
and a `127.0.0.1` bind is not listening there. The container then goes
|
||||||
container then goes `unhealthy` about **65 seconds** after start — from
|
`unhealthy` about **65 seconds** after start — from
|
||||||
`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at
|
`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at
|
||||||
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during
|
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during
|
||||||
the start period has changed between versions; re-derive from those three values
|
the start period has changed between versions; re-derive from those three values
|
||||||
@@ -701,11 +702,11 @@ app as that user.
|
|||||||
`webhooker.db` holds target configuration in plaintext — bearer tokens, API
|
`webhooker.db` holds target configuration in plaintext — bearer tokens, API
|
||||||
keys, Slack webhook URLs — along with the session encryption key, so webhooker
|
keys, Slack webhook URLs — along with the session encryption key, so webhooker
|
||||||
creates every SQLite file it owns `0600`: each database and both of its `-wal`
|
creates every SQLite file it owns `0600`: each database and both of its `-wal`
|
||||||
and `-shm` sidecars, across all three tiers. Any of them found at another mode
|
and `-shm` sidecars, across all three tiers. Files an earlier build left `0644`
|
||||||
is set to `0600` when the database is opened. The directory's `0750` is defence
|
are tightened when they are opened. The directory's `0750` is defence in depth —
|
||||||
in depth — it stops other local users listing the directory and learning your
|
it stops other local users listing the directory and learning your webhook UUIDs
|
||||||
webhook UUIDs from the `events-{uuid}.db` filenames — not the barrier protecting
|
from the `events-{uuid}.db` filenames — not the barrier protecting the
|
||||||
the credentials.
|
credentials.
|
||||||
|
|
||||||
### Running under upaas
|
### Running under upaas
|
||||||
|
|
||||||
@@ -728,8 +729,8 @@ the credentials.
|
|||||||
address (directly, or through anything that can rewrite source addresses,
|
address (directly, or through anything that can rewrite source addresses,
|
||||||
such as NAT or a published container port). The `remoteIP` field of the
|
such as NAT or a published container port). The `remoteIP` field of the
|
||||||
`http request` log line for a request that came through the proxy shows
|
`http request` log line for a request that came through the proxy shows
|
||||||
that address; the health check's own lines show the container's own
|
that address; the health check's own lines show `::1`. See
|
||||||
address. See [Trusted proxies](#trusted-proxies).
|
[Trusted proxies](#trusted-proxies).
|
||||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS`
|
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS`
|
||||||
to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`.
|
to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`.
|
||||||
- Everything else is optional; see [Configuration](#configuration).
|
- Everything else is optional; see [Configuration](#configuration).
|
||||||
@@ -804,12 +805,7 @@ comes up, serves pages, and is broken in a way nothing reports.
|
|||||||
proxy's log names it regardless. nginx's default `combined` format already
|
proxy's log names it regardless. nginx's default `combined` format already
|
||||||
logs `$remote_addr`; do not replace it with one that drops the client
|
logs `$remote_addr`; do not replace it with one that drops the client
|
||||||
address, and retain those logs as long as you would want to answer a question
|
address, and retain those logs as long as you would want to answer a question
|
||||||
about traffic. That log also records the full request path, so it holds the
|
about traffic.
|
||||||
entrypoint UUID of every event a sender posts. webhooker's own log does not,
|
|
||||||
apart from the line a `log` target writes. Protect the proxy's log as you
|
|
||||||
would the entrypoint URLs themselves: anyone who can read it can post events
|
|
||||||
to every entrypoint in it (see
|
|
||||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
|
|
||||||
|
|
||||||
### nginx
|
### nginx
|
||||||
|
|
||||||
@@ -835,9 +831,7 @@ server {
|
|||||||
client_max_body_size 1m;
|
client_max_body_size 1m;
|
||||||
|
|
||||||
# $remote_addr is the client. webhooker's own log names it, as
|
# $remote_addr is the client. webhooker's own log names it, as
|
||||||
# clientIP, only while TRUSTED_PROXIES covers this proxy. The
|
# clientIP, only while TRUSTED_PROXIES covers this proxy.
|
||||||
# request path in this log holds the entrypoint UUID, so protect
|
|
||||||
# the file like the entrypoint URL itself.
|
|
||||||
access_log /var/log/nginx/webhooker.access.log combined;
|
access_log /var/log/nginx/webhooker.access.log combined;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -924,7 +918,7 @@ replaces the `max-age` it stored, so to end the policy sooner, set a short
|
|||||||
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
|
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
|
||||||
up, or have a reverse proxy send `max-age=0`.
|
up, or have a reverse proxy send `max-age=0`.
|
||||||
|
|
||||||
## Backup and Restore
|
## Backup, Restore, and Upgrades
|
||||||
|
|
||||||
### What to back up
|
### What to back up
|
||||||
|
|
||||||
@@ -1077,14 +1071,59 @@ beside it, or wait until there are none.
|
|||||||
|
|
||||||
4. Start the service. The container gives the directory and the restored files
|
4. Start the service. The container gives the directory and the restored files
|
||||||
to the `webhooker` user before the app starts, whoever restored them (see
|
to the `webhooker` user before the app starts, whoever restored them (see
|
||||||
[Running with Docker](#running-with-docker)).
|
[Running with Docker](#running-with-docker)). `AutoMigrate` runs against each
|
||||||
|
restored database as it is opened.
|
||||||
|
|
||||||
|
### Upgrades
|
||||||
|
|
||||||
|
**Back up before every upgrade.** Every start runs GORM `AutoMigrate`
|
||||||
|
unconditionally against whatever files it finds:
|
||||||
|
|
||||||
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
|
`Entrypoint`, `Target`
|
||||||
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
|
`DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
|
||||||
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
|
There is no schema version table, no migration ledger, and no down migrations.
|
||||||
|
Nothing in the files records which version wrote them, and no code path undoes a
|
||||||
|
migration.
|
||||||
|
|
||||||
|
Upgrade procedure:
|
||||||
|
|
||||||
|
1. Stop the service.
|
||||||
|
2. Back up `DATA_DIR` using one of the procedures above.
|
||||||
|
3. Pull the new image and start it.
|
||||||
|
4. Confirm `database migrations completed` in the logs before putting traffic
|
||||||
|
back on it.
|
||||||
|
5. Confirm the new build is the one running:
|
||||||
|
`curl -s http://host:8080/.well-known/healthcheck` reports the version it was
|
||||||
|
stamped with (see [Version stamping](#version-stamping)).
|
||||||
|
|
||||||
|
**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions always
|
||||||
|
bound every interface. **Container deployments are unaffected** — the image
|
||||||
|
ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or Compose service that
|
||||||
|
worked before still works with nothing changed.
|
||||||
|
|
||||||
|
A **bare binary** is the case that changes: the listener now binds `127.0.0.1`
|
||||||
|
unless `BIND_ADDRESS` says otherwise, so a deployment that relied on reaching it
|
||||||
|
from another host becomes unreachable until it sets the address the proxy
|
||||||
|
connects to. Check the `bindAddress` field of the startup log to see what a
|
||||||
|
running process bound. See [Bind address](#bind-address).
|
||||||
|
|
||||||
|
**Downgrade is unsupported.** Once a newer binary has migrated the files there
|
||||||
|
is no way to move them back. `AutoMigrate` is additive — it adds tables, columns
|
||||||
|
and indexes and never drops or rewrites them — so an older binary will generally
|
||||||
|
open migrated files and appear to work while writing against a schema it does
|
||||||
|
not know about. The failure mode is silent divergence, not a startup error. The
|
||||||
|
only supported way back to an older version is restoring the pre-upgrade backup,
|
||||||
|
which discards everything received since that backup was taken.
|
||||||
|
|
||||||
### Version stamping
|
### Version stamping
|
||||||
|
|
||||||
The binary reports its version at `/.well-known/healthcheck` (the `version`
|
The binary reports its version at `/.well-known/healthcheck` (the `version`
|
||||||
field), in the UI footer, and in the startup log line (`msg=starting`,
|
field), in the UI footer, and in the startup log line (`msg=starting`,
|
||||||
`version=...`). It is also the Sentry release name, as `webhooker-{version}`.
|
`version=...`). It is also the Sentry release name, as `webhooker-{version}`.
|
||||||
`curl -s http://host:8080/.well-known/healthcheck` shows which build is running.
|
|
||||||
The value is stamped in at build time by the linker; it is not read from a file
|
The value is stamped in at build time by the linker; it is not read from a file
|
||||||
at runtime, so it identifies the build itself.
|
at runtime, so it identifies the build itself.
|
||||||
|
|
||||||
@@ -1143,6 +1182,26 @@ at rest and restrict who can read them.
|
|||||||
- `events-{uuid}.db` and `archive-….db` hold the **full payload body and
|
- `events-{uuid}.db` and `archive-….db` hold the **full payload body and
|
||||||
headers** of every event as received, including whatever the sending service
|
headers** of every event as received, including whatever the sending service
|
||||||
put in them — tokens, signatures, personal data.
|
put in them — tokens, signatures, personal data.
|
||||||
|
- Event databases written before
|
||||||
|
[issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed **also
|
||||||
|
contain target credentials**: a GORM association upsert on the delivery and
|
||||||
|
retry write path copied `targets` rows, `config` included, into the
|
||||||
|
per-webhook database. For a Slack target the `webhookUrl` _is_ the bearer
|
||||||
|
credential, and an `http` target's URL can embed userinfo. This version never
|
||||||
|
writes those rows; the first time it opens such a file it deletes them and
|
||||||
|
vacuums the file, which removes the credential bytes rather than only
|
||||||
|
unlinking the rows. Deleting alone would not: the bytes stay readable in the
|
||||||
|
file's free pages until it is rewritten. The sweep is recorded in the file's
|
||||||
|
`user_version` only once the vacuum returns, so a sweep that fails or is
|
||||||
|
interrupted fails the open and is retried on the next one, and a file this
|
||||||
|
version has opened without error holds no leaked rows and no recoverable bytes
|
||||||
|
from them. On upgrade this rewrites each existing `events-{uuid}.db` once, on
|
||||||
|
its first open. Two cases still hand over live delivery destinations: a backup
|
||||||
|
taken from an older build, and a backup of a file this version has not yet
|
||||||
|
opened successfully. Copies already made stay affected — the sweep only
|
||||||
|
rewrites the file it opens, and freed blocks may persist in filesystem
|
||||||
|
snapshots and on the underlying storage. Rotate any target credential that was
|
||||||
|
in a backup you cannot account for.
|
||||||
- `webhooker.db` stores target config **unencrypted**, tracked at
|
- `webhooker.db` stores target config **unencrypted**, tracked at
|
||||||
[issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to the
|
[issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to the
|
||||||
session encryption key and the Argon2id password hashes.
|
session encryption key and the Argon2id password hashes.
|
||||||
@@ -1169,16 +1228,7 @@ What that means for an operator:
|
|||||||
|
|
||||||
- **The URL is a capability, so treat it as a secret.** Keep it out of logs,
|
- **The URL is a capability, so treat it as a secret.** Keep it out of logs,
|
||||||
ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can
|
ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can
|
||||||
post events as that sender. Apart from a `log` target's line, webhooker's own
|
post events as that sender.
|
||||||
log lines never carry the UUID: they name a request to the receiver by the
|
|
||||||
route pattern `/h/{uuid}`, the `webhook request received` line names the
|
|
||||||
entrypoint by its `entrypoint_id`, and the access log keeps only the scheme
|
|
||||||
and host of a `Referer`. A `log` target writes the request's headers as
|
|
||||||
received, so a `Referer` naming the entrypoint URL appears in its line, as it
|
|
||||||
does when a sender has followed a redirect from `http://` to `https://`. The
|
|
||||||
reverse proxy's access log does carry the UUID, in the request path, so it
|
|
||||||
must be protected like the URL itself (see
|
|
||||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
|
||||||
- **Rotating means minting a new entrypoint, not changing a key.** There is no
|
- **Rotating means minting a new entrypoint, not changing a key.** There is no
|
||||||
way to rotate the UUID in place. To retire one, delete the entrypoint (or
|
way to rotate the UUID in place. To retire one, delete the entrypoint (or
|
||||||
deactivate it, which answers `410`) and create a new one, then point the
|
deactivate it, which answers `410`) and create a new one, then point the
|
||||||
@@ -1253,37 +1303,34 @@ markup. The CSP build runs no expressions, so every Alpine directive in
|
|||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
A browser test in `internal/server` loads the sign-in page, the webhook page,
|
A browser test in `internal/server` loads the webhook page, its edit pages and
|
||||||
its edit pages, the event log, the webhook list, the settings page, the profile
|
the event log under the real policy and checks that: the add entrypoint form
|
||||||
page and the `404` error page shown for a path that does not exist, under the
|
stays hidden until Add is clicked; for every target type, the targets section's
|
||||||
real policy and checks that: the add entrypoint form stays hidden until Add is
|
Add shows only a choice of type with Next and Cancel, Next shows only that
|
||||||
clicked; for every target type, the targets section's Add shows only a choice of
|
type's fields (no url field for `database` or `log`), Cancel at either step
|
||||||
type with Next and Cancel, Next shows only that type's fields (no url field for
|
closes the form, and saving adds the target; a refused target comes back with
|
||||||
`database` or `log`), Cancel at either step closes the form, and saving adds the
|
its form open, the values entered and the reason, and after Cancel the next Add
|
||||||
target; a refused target comes back with its form open, the values entered and
|
starts with an empty form and no reason; a refused save on the target edit page
|
||||||
the reason, and after Cancel the next Add starts with an empty form and no
|
and on the webhook edit page comes back with the reason and every value entered;
|
||||||
reason; a refused save on the target edit page and on the webhook edit page
|
the Copy button beside an entrypoint URL reads "Copied" once clicked; an
|
||||||
comes back with the reason and every value entered; the Copy button beside an
|
entrypoint's Edit button shows its edit form in place of its description and
|
||||||
entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows
|
hides until the form closes, Cancel hides the form and drops what was typed, as
|
||||||
its edit form in place of its description and hides until the form closes,
|
does leaving the page and going back to it, and Save changes the description; of
|
||||||
Cancel hides the form and drops what was typed, as does leaving the page and
|
the recent events on the webhook page only the newest starts expanded, each
|
||||||
going back to it, and Save changes the description; of the recent events on the
|
expands and collapses, and Open leads to the event's own page; of the events in
|
||||||
webhook page only the newest starts expanded, each expands and collapses, and
|
the event log only the newest starts expanded, and an event there expands and
|
||||||
Open leads to the event's own page; of the events in the event log only the
|
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
||||||
newest starts expanded, and an event there expands and collapses when its row's
|
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
||||||
caret or its ID is clicked, and from the keyboard, but not when its ID is
|
expand and collapse; and at phone width the menu button opens and closes the
|
||||||
selected with the mouse, and a delivery's attempts inside it expand and
|
mobile menu, and neither the webhook page nor the event log, with a delivery's
|
||||||
collapse; at phone width the menu button opens and closes the mobile menu, and
|
attempts open, scrolls sideways or cuts anything off at the page's or a card's
|
||||||
neither the webhook page nor the event log, with a delivery's attempts open,
|
edge. It also fails if the browser reports a console warning or error, an
|
||||||
scrolls sideways or cuts anything off at the page's or a card's edge; and a
|
uncaught exception, or anything the policy refused. `make check` and the image
|
||||||
target's Delete asks first, naming the target, sends nothing when the answer is
|
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||||
no and deletes the target when it is yes. It also fails if the browser reports a
|
built only with the `browser` build tag). Run it with `make test-browser` after
|
||||||
console warning or error, an uncaught exception, or anything the policy refused.
|
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
||||||
`make check` and the image build lint it but do not run it, and `make test`
|
runs the test in a digest-pinned headless browser image, so the host needs no
|
||||||
leaves it out (its file is built only with the `browser` build tag). Run it with
|
browser.
|
||||||
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
|
||||||
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
|
||||||
image, so the host needs no browser.
|
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -1316,9 +1363,7 @@ and commit the regenerated file with the change. Tailwind takes classes only
|
|||||||
from the files that `input.css` names in its `@source` lines; a class written in
|
from the files that `input.css` names in its `@source` lines; a class written in
|
||||||
any other file is not generated until that file is named there too. `make check`
|
any other file is not generated until that file is named there too. `make check`
|
||||||
and the image build fail when the committed file differs from what `make css`
|
and the image build fail when the committed file differs from what `make css`
|
||||||
generates. `static/css/style.css` is hand-written and is not generated. The
|
generates. `static/css/style.css` is hand-written and is not generated.
|
||||||
pages' Content-Security-Policy refuses inline styles, so a template styles an
|
|
||||||
element with a class, never a `style` attribute.
|
|
||||||
|
|
||||||
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
||||||
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
||||||
@@ -1340,22 +1385,20 @@ webhooker solves this by acting as a durable intermediary:
|
|||||||
nothing is lost if downstream targets are unavailable.
|
nothing is lost if downstream targets are unavailable.
|
||||||
|
|
||||||
2. **Guaranteed delivery** — Events are queued for delivery to each configured
|
2. **Guaranteed delivery** — Events are queued for delivery to each configured
|
||||||
target. Failed deliveries are retried with configurable backoff. Delivery
|
target. Failed deliveries are retried with configurable backoff. Every
|
||||||
attempts are logged with status codes, response bodies, and timing.
|
delivery attempt is logged with status codes, response bodies, and timing.
|
||||||
|
|
||||||
**That guarantee is at-least-once, not exactly-once.** When a send reaches
|
**That guarantee is at-least-once, not exactly-once.** When a send reaches
|
||||||
its target but the write recording that outcome fails, the delivery is
|
its target but the write recording that outcome fails, the delivery is
|
||||||
deliberately left in a recoverable state rather than marked done — losing a
|
deliberately left in a recoverable state rather than marked done — losing a
|
||||||
delivery is the worse failure — so the pending sweep picks it up about
|
delivery is the worse failure — so the pending sweep picks it up about
|
||||||
fifteen minutes later, or the next restart does, and the target receives a
|
fifteen minutes later, or the next restart does, and the target receives a
|
||||||
payload it already got. A send still in flight when the service stops is
|
payload it already got. webhooker adds no delivery identifier of its own to
|
||||||
left the same way: its attempt is not recorded, and the next start sends it
|
an outbound request, so **make your receiver idempotent** against whatever
|
||||||
again (see Shutdown). webhooker adds no delivery identifier of its own to an
|
the payload itself carries.
|
||||||
outbound request, so **make your receiver idempotent** against whatever the
|
|
||||||
payload itself carries.
|
|
||||||
|
|
||||||
3. **Observability** — Full request/response logging for every webhook received
|
3. **Observability** — Full request/response logging for every webhook received
|
||||||
and for delivery attempts. Prometheus metrics expose volume, latency, and
|
and every delivery attempted. Prometheus metrics expose volume, latency, and
|
||||||
error rates. The web UI provides real-time visibility into event flow.
|
error rates. The web UI provides real-time visibility into event flow.
|
||||||
|
|
||||||
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
|
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
|
||||||
@@ -1393,10 +1436,10 @@ It uses:
|
|||||||
management
|
management
|
||||||
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
|
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
|
||||||
- **[GORM](https://gorm.io)** for database access with
|
- **[GORM](https://gorm.io)** for database access with
|
||||||
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure
|
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime
|
||||||
Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports
|
SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on
|
||||||
`mattn/go-sqlite3`, whose stub for builds without cgo is linked into the
|
`mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker)
|
||||||
binary but never used
|
section)
|
||||||
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with
|
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with
|
||||||
TTY detection (text for dev, JSON for prod)
|
TTY detection (text for dev, JSON for prod)
|
||||||
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted
|
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted
|
||||||
@@ -1520,34 +1563,47 @@ validated as on the add target form, and the webhook and its targets are created
|
|||||||
together or not at all.
|
together or not at all.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | --------------------------------------------- |
|
| ---------------- | ------- | ----------------------------------------------------------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `user_id` | UUID | Foreign key → User |
|
| `user_id` | UUID | Foreign key → User |
|
||||||
| `name` | string | Human-readable name |
|
| `name` | string | Human-readable name |
|
||||||
| `description` | string | Optional description |
|
| `description` | string | Optional description |
|
||||||
| `retention_days` | integer | Days to retain events; 0 means retain forever |
|
| `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) |
|
||||||
|
|
||||||
**Relations:** Belongs to User. Has many Entrypoints. Has many Targets.
|
**Relations:** Belongs to User. Has many Entrypoints. Has many Targets.
|
||||||
|
|
||||||
The `retention_days` field controls how long event data is kept in the webhook's
|
The `retention_days` field controls how long event data is kept in the webhook's
|
||||||
dedicated database before automatic cleanup.
|
dedicated database before automatic cleanup.
|
||||||
|
|
||||||
The value is stored as entered. Setting `retention_days` to `0` means "retain
|
Setting `retention_days` to `0` means "retain events forever". Because the
|
||||||
events forever": the retention reaper skips the webhook entirely, and the web UI
|
column carries a default of 30, a literal zero cannot survive an insert, so a
|
||||||
displays its retention as "forever" rather than as a day count.
|
zero is rewritten on save to a sentinel of `365 * 1000` days
|
||||||
|
(`database.RetentionForeverDays`). The retention reaper recognises that sentinel
|
||||||
|
and skips the webhook entirely, and the web UI displays such a webhook's
|
||||||
|
retention as "forever" rather than as a day count.
|
||||||
|
|
||||||
The new webhook and edit forms accept `0`, or a whole number of days from `1` up
|
Submitted `retention_days` values therefore fall into three bands, not two:
|
||||||
to `database.MaxFiniteRetentionDays` (106751 days, about 292 years). Anything
|
|
||||||
else, a negative value or one above that cap included, is rejected with a 400. A
|
- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is
|
||||||
blank field gives the default of 30 days (`database.DefaultRetentionDays`) on
|
accepted as a finite retention.
|
||||||
the new webhook form, and leaves the stored value unchanged on the edit form.
|
- Above that ceiling but below the retain-forever sentinel of 365000
|
||||||
|
(`database.RetentionForeverDays`) is rejected with a 400. This is the band the
|
||||||
|
cap exists for.
|
||||||
|
- `0`, and `365000` or above, are accepted and mean retain forever, collapsing
|
||||||
|
to the sentinel — `0` in `Webhook.BeforeSave`, the large values in
|
||||||
|
`parseRetentionDays`. The large values are not out of range: the edit form
|
||||||
|
pre-fills the sentinel for a retain-forever webhook, so submitting that form
|
||||||
|
back unchanged has to keep meaning "forever".
|
||||||
|
|
||||||
|
A negative value is in none of the three: `parseRetentionDays` rejects it with a
|
||||||
|
400 before `BeforeSave` ever sees it.
|
||||||
|
|
||||||
The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`,
|
The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`,
|
||||||
an int64 nanosecond count, and a longer period overflows it. An overflowed
|
an int64 nanosecond count, and a longer period overflows it. An overflowed
|
||||||
cutoff lands in the future, where it matches every row, so the sweep would
|
cutoff lands in the future, where it matches every row, so the sweep would
|
||||||
delete every event the webhook has instead of none. The reaper also clamps the
|
delete every event the webhook has instead of none. The reaper also clamps the
|
||||||
value it reads to the cap, and reads a negative value as forever, so no stored
|
value it is given, so a row written by an older version cannot trigger that
|
||||||
value can trigger that either.
|
either.
|
||||||
|
|
||||||
#### Entrypoint
|
#### Entrypoint
|
||||||
|
|
||||||
@@ -1580,26 +1636,28 @@ A delivery destination for events. Each target defines where and how events
|
|||||||
should be forwarded.
|
should be forwarded.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `name` | string | Human-readable name |
|
| `name` | string | Human-readable name |
|
||||||
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
| `config` | JSON text | Type-specific configuration |
|
| `config` | JSON text | Type-specific configuration |
|
||||||
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
|
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Has many Deliveries.
|
**Relations:** Belongs to Webhook. Has many Deliveries.
|
||||||
|
|
||||||
**Target types:**
|
**Target types:**
|
||||||
|
|
||||||
- **`http`** — Forward the event as an HTTP POST to a configured URL. With
|
- **`http`** — Forward the event as an HTTP POST to a configured URL.
|
||||||
`max_attempts` at 1 the target makes a single attempt; above 1 it retries a
|
`max_retries` is the total number of delivery attempts, not retries on top of
|
||||||
failed delivery with exponential backoff, up to that many attempts in all,
|
the first: when `max_retries` is 0 (the default), the target operates in
|
||||||
behind a per-target circuit breaker.
|
fire-and-forget mode, a single attempt with no retries and no circuit breaker;
|
||||||
|
a value of N makes up to N attempts in all, retrying failed deliveries with
|
||||||
|
exponential backoff and protecting them with a per-target circuit breaker.
|
||||||
- **`slack`** — Post the event as a formatted message to a Slack-compatible
|
- **`slack`** — Post the event as a formatted message to a Slack-compatible
|
||||||
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
|
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
|
||||||
core as `http` and honours `max_attempts` identically, circuit breaker
|
core as `http` and honours `max_retries` identically, circuit breaker
|
||||||
included. See the Slack target section under "Per-Webhook Event Databases" for
|
included. See the Slack target section under "Per-Webhook Event Databases" for
|
||||||
the message format.
|
the message format.
|
||||||
- **`database`** — Archive the full event as a row into the target's own archive
|
- **`database`** — Archive the full event as a row into the target's own archive
|
||||||
@@ -1779,7 +1837,7 @@ is what bounds it.
|
|||||||
#### DeliveryResult
|
#### DeliveryResult
|
||||||
|
|
||||||
The result of a single delivery attempt. Every attempt (including retries) is
|
The result of a single delivery attempt. Every attempt (including retries) is
|
||||||
individually logged, except one cut off by a stop (see Shutdown).
|
individually logged for full observability.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| --------------- | ------- | ----------------------------------------- |
|
| --------------- | ------- | ----------------------------------------- |
|
||||||
@@ -1861,9 +1919,8 @@ Deliveries still `pending` or `retrying` count in neither.
|
|||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are created by
|
These indexes on the per-webhook event databases are declared in the model tags,
|
||||||
`internal/db/migrations/events/001_schema.sql`, and declared in the model tags
|
so `AutoMigrate` creates them on a fresh database:
|
||||||
to match:
|
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -1927,9 +1984,8 @@ and application state:
|
|||||||
- **Targets** — delivery destination configurations
|
- **Targets** — delivery destination configurations
|
||||||
- **APIKeys** — programmatic access credentials
|
- **APIKeys** — programmatic access credentials
|
||||||
|
|
||||||
On first startup the main database is created from its SQL files (see
|
On first startup the main database is auto-migrated, a session encryption key is
|
||||||
[Schema](#schema)), a session encryption key is generated and stored, and an
|
generated and stored, and an `admin` user is created.
|
||||||
`admin` user is created.
|
|
||||||
|
|
||||||
**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each
|
**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each
|
||||||
webhook gets its own dedicated SQLite file containing:
|
webhook gets its own dedicated SQLite file containing:
|
||||||
@@ -1941,10 +1997,9 @@ webhook gets its own dedicated SQLite file containing:
|
|||||||
the above, the deliveries per target, and each entrypoint's last event, kept
|
the above, the deliveries per target, and each entrypoint's last event, kept
|
||||||
through retention
|
through retention
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is created, from
|
Per-webhook databases are created automatically when a webhook is created. They
|
||||||
the SQL files in `internal/db/migrations/events/`. They hold no copy of the main
|
are managed by the `WebhookDBManager` component, which handles connection
|
||||||
database's tables. They are managed by the `WebhookDBManager` component, which
|
pooling, lazy opening, migrations, and cleanup.
|
||||||
handles connection pooling, lazy opening, migrations, and cleanup.
|
|
||||||
|
|
||||||
A per-webhook database that is missing or zero-length later means its webhook's
|
A per-webhook database that is missing or zero-length later means its webhook's
|
||||||
events and pending deliveries are gone. The next time it is opened, an empty one
|
events and pending deliveries are gone. The next time it is opened, an empty one
|
||||||
@@ -2028,15 +2083,14 @@ the service again.
|
|||||||
After each write the archive handle is closed and reopened, debounced to at most
|
After each write the archive handle is closed and reopened, debounced to at most
|
||||||
once per second, so an operator can move the archive file away for offline
|
once per second, so an operator can move the archive file away for offline
|
||||||
archiving without stopping the service; a moved or removed archive file is
|
archiving without stopping the service; a moved or removed archive file is
|
||||||
recreated automatically on the next write, from the SQL files in
|
recreated automatically on the next write. A zero-length archive file is written
|
||||||
`internal/db/migrations/archive/`. A zero-length archive file is written to as a
|
to as a new archive: SQLite opens it as an empty database, so it holds nothing
|
||||||
new archive: SQLite opens it as an empty database, so it holds nothing to lose.
|
to lose. An optional `expiry` in the target's config JSON (e.g.
|
||||||
An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is
|
`{"expiry":"720h"}`) is validated when the target is created — the default
|
||||||
validated when the target is created — the default (unset or the literal
|
(unset or the literal `never`) keeps rows forever — and rows older than the
|
||||||
`never`) keeps rows forever — and rows older than the expiry are pruned each
|
expiry are pruned each time the archive is (re)opened. An archive write failure
|
||||||
time the archive is (re)opened. An archive write failure is never silent
|
is never silent success: the delivery records a failed attempt with the error
|
||||||
success: the delivery records a failed attempt with the error and is marked
|
and is marked failed.
|
||||||
failed.
|
|
||||||
|
|
||||||
Because reopens only happen on writes, an archive whose target has stopped
|
Because reopens only happen on writes, an archive whose target has stopped
|
||||||
receiving events would never be pruned. A background **archive sweeper** closes
|
receiving events would never be pruned. A background **archive sweeper** closes
|
||||||
@@ -2121,22 +2175,9 @@ endpoint. That is the JSON key; the error text for a missing one reads
|
|||||||
`webhook_url is required`, which is the message, not the key.
|
`webhook_url is required`, which is the message, not the key.
|
||||||
|
|
||||||
The database uses the
|
The database uses the
|
||||||
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver.
|
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime,
|
||||||
|
though CGO is required at build time due to the transitive `mattn/go-sqlite3`
|
||||||
#### Schema
|
dependency from `gorm.io/driver/sqlite`.
|
||||||
|
|
||||||
Each kind of database file has its schema in SQL files built into the binary,
|
|
||||||
one directory per kind under `internal/db/migrations/`. In each,
|
|
||||||
`000_migration.sql` creates the `schema_migrations` table, which records the
|
|
||||||
number of every file the database has had, and `001_schema.sql` holds the whole
|
|
||||||
schema. Opening a database runs, in one transaction, every file in its directory
|
|
||||||
that `schema_migrations` does not list:
|
|
||||||
|
|
||||||
- `main/` for the main database, on connect — settings, users, API keys,
|
|
||||||
webhooks, entrypoints and targets
|
|
||||||
- `events/` for each event database, when it is lazily opened — events,
|
|
||||||
deliveries, delivery results and their running totals
|
|
||||||
- `archive/` for each archive database, on every open and reopen
|
|
||||||
|
|
||||||
### Request Flow
|
### Request Flow
|
||||||
|
|
||||||
@@ -2172,11 +2213,11 @@ External Service
|
|||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
│ HTTP Target│ │ HTTP Target│ │ Log Target │
|
│ HTTP Target│ │ HTTP Target│ │ Log Target │
|
||||||
│max_attempts│ │max_attempts│ │ (stdout) │
|
│(max_retries│ │(max_retries│ │ (stdout) │
|
||||||
│ == 1, │ │ > 1, │ └────────────┘
|
│ == 0) │ │ > 0, │ └────────────┘
|
||||||
│ one attempt│ │ backoff + │
|
│ fire+forget│ │ backoff + │
|
||||||
└────────────┘ │ circuit │
|
└────────────┘ │ circuit │
|
||||||
│ breaker │
|
│ breaker) │
|
||||||
└────────────┘
|
└────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2215,16 +2256,12 @@ This means:
|
|||||||
buffer rather than spawning more goroutines.
|
buffer rather than spawning more goroutines.
|
||||||
- **Independent results** — each worker records its own delivery result in the
|
- **Independent results** — each worker records its own delivery result in the
|
||||||
per-webhook database without coordination.
|
per-webhook database without coordination.
|
||||||
- **Graceful shutdown** — the stop hook cancels the workers' context. A worker
|
- **Graceful shutdown** — cancel the context, workers finish their current task
|
||||||
may still take tasks already queued before it exits. An `http` or `slack`
|
and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`,
|
||||||
request in flight is cut off, and one started after the cancel fails at once.
|
which bounds that wait by fx's stop timeout rather than blocking forever on a
|
||||||
Neither is recorded or counted in the metrics, and the next start sends the
|
wedged worker. On timeout it logs at `ERROR` and returns an error, and the
|
||||||
delivery again (see Shutdown). A `database` or `log` task runs as normal. The
|
goroutines that did not finish are still running — an unclean shutdown is
|
||||||
stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds
|
reported rather than hidden.
|
||||||
that wait by fx's stop timeout rather than blocking forever on a wedged
|
|
||||||
worker. On timeout it logs at `ERROR` and returns an error, and the goroutines
|
|
||||||
that did not finish are still running — an unclean shutdown is reported rather
|
|
||||||
than hidden.
|
|
||||||
|
|
||||||
**Recovery paths:**
|
**Recovery paths:**
|
||||||
|
|
||||||
@@ -2253,7 +2290,7 @@ re-inject it to every currently active target.
|
|||||||
|
|
||||||
### Circuit Breaker (HTTP and Slack Targets with Retries)
|
### Circuit Breaker (HTTP and Slack Targets with Retries)
|
||||||
|
|
||||||
`http` and `slack` targets with `max_attempts` > 1 are protected by a
|
`http` and `slack` targets with `max_retries` > 0 are protected by a
|
||||||
**per-target circuit breaker** that prevents hammering a down target with
|
**per-target circuit breaker** that prevents hammering a down target with
|
||||||
repeated failed delivery attempts. The circuit breaker is in-memory only and
|
repeated failed delivery attempts. The circuit breaker is in-memory only and
|
||||||
resets on restart (which is fine — startup recovery rescans the database
|
resets on restart (which is fine — startup recovery rescans the database
|
||||||
@@ -2294,10 +2331,10 @@ anyway).
|
|||||||
- **Cooldown:** 30 seconds in open state before probing
|
- **Cooldown:** 30 seconds in open state before probing
|
||||||
|
|
||||||
**Scope:** Circuit breakers apply to **`http` and `slack` targets with
|
**Scope:** Circuit breakers apply to **`http` and `slack` targets with
|
||||||
`max_attempts` > 1**. The Slack target is built on the same HTTP core and hands
|
`max_retries` > 0**. The Slack target is built on the same HTTP core and hands
|
||||||
its own `max_attempts` to the same retry path, so it gets a breaker with the
|
its own `max_retries` to the same retry path, so it gets a breaker with the same
|
||||||
same 5-failure / 30-second defaults. Single-attempt targets of either type
|
5-failure / 30-second defaults. Fire-and-forget targets of either type
|
||||||
(`max_attempts` == 1), database targets (local operations), and log targets
|
(`max_retries` == 0), database targets (local operations), and log targets
|
||||||
(stdout) do not use circuit breakers.
|
(stdout) do not use circuit breakers.
|
||||||
|
|
||||||
When a circuit is open and a new delivery arrives, the engine marks the delivery
|
When a circuit is open and a new delivery arrives, the engine marks the delivery
|
||||||
@@ -2330,7 +2367,7 @@ getting anywhere.
|
|||||||
| Metric | Type | Meaning |
|
| Metric | Type | Meaning |
|
||||||
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
|
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
|
||||||
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) |
|
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` |
|
||||||
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
|
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
|
||||||
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
|
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
|
||||||
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
|
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
|
||||||
@@ -2429,32 +2466,27 @@ log volume it caps rather than eliminates. A path that names no entrypoint is
|
|||||||
recorded by the handler at `DEBUG`, and the aggregate limiter logs its own
|
recorded by the handler at `DEBUG`, and the aggregate limiter logs its own
|
||||||
rejections at `DEBUG` and without the path, so neither appears at all under the
|
rejections at `DEBUG` and without the path, so neither appears at all under the
|
||||||
default level. The per-entrypoint limiter is the loud one: it logs every
|
default level. The per-entrypoint limiter is the loud one: it logs every
|
||||||
rejection at `WARN`, naming the route pattern `/h/{uuid}` rather than the
|
rejection at `WARN` with the request path, which on this route is
|
||||||
request path. A client hammering a single invented path is served
|
attacker-controlled text. A client hammering a single invented path is served
|
||||||
`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected
|
`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected
|
||||||
there, so the aggregate limit is what bounds the _number_ of those `WARN` lines
|
there, so the aggregate limit is what bounds the _number_ of those `WARN` lines
|
||||||
— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
|
— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
|
||||||
defaults, where before it there was no bound at all. The access log is bounded
|
defaults, where before it there was no bound at all. Their _width_ is bounded by
|
||||||
by neither limit: every request is recorded once at `INFO`, served or rejected
|
the field budgets below, the same ones the access log spends. The access log is
|
||||||
alike.
|
bounded by neither limit: every request is recorded once at `INFO`, served or
|
||||||
|
rejected alike.
|
||||||
|
|
||||||
What the access log does bound is the _content_ of those lines. On the receiver
|
What the access log does bound is the _content_ of those lines. A 3xx or 4xx
|
||||||
route it logs the route pattern `/h/{uuid}` whatever the status, because the
|
response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the
|
||||||
concrete path carries the entrypoint UUID, which is the entrypoint's credential
|
literal `(unmatched)` when the request hit no route at all — in place of the
|
||||||
(see
|
concrete URL. Those are the outcomes an unauthenticated client can drive for
|
||||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
|
free: 404 and 429 on any invented receiver path, a login redirect on any
|
||||||
A request chi never routed, because it matched no route or because a middleware
|
invented profile path. Logging the URL there would let a flood write text of its
|
||||||
answered it first, logs the literal `(unmatched)` whatever the status. In the
|
own choosing, at a length of its own choosing, into the log. 2xx and 5xx
|
||||||
`dev` environment the CORS middleware answers a preflight that way, so a
|
responses keep the concrete path — a success resolved against a static route or
|
||||||
preflight to the receiver does not log its UUID either. On every other route, a
|
against the operator's own data (on the receiver, against a stored entrypoint
|
||||||
3xx or 4xx response logs the chi route pattern, such as `/user/{username}//`, in
|
UUID), and a 5xx is a bug in this service, where the exact path is the evidence
|
||||||
place of the concrete URL. Those are the outcomes an unauthenticated client can
|
and no client can provoke one at will.
|
||||||
drive for free: a 404 on any invented path, a login redirect on any invented
|
|
||||||
profile path. Logging the URL there would let a flood write text of its own
|
|
||||||
choosing, at a length of its own choosing, into the log. 2xx and 5xx responses
|
|
||||||
keep the concrete path — a success resolved against a static route or against
|
|
||||||
the operator's own data, and a 5xx is a bug in this service, where the exact
|
|
||||||
path is the evidence and no client can provoke one at will.
|
|
||||||
|
|
||||||
The query string is never logged; it is replaced by the fixed marker
|
The query string is never logged; it is replaced by the fixed marker
|
||||||
`?(redacted)`. It is client-chosen on every route, and
|
`?(redacted)`. It is client-chosen on every route, and
|
||||||
@@ -2466,11 +2498,6 @@ to, `notice`, which names the line a page shows after an action, and the event
|
|||||||
log's `show`, which picks the events it lists. A query string sent to an
|
log's `show`, which picks the events it lists. A query string sent to an
|
||||||
entrypoint is not lost either: the event stores it, and the event log shows it.
|
entrypoint is not lost either: the event stores it, and the event log shows it.
|
||||||
|
|
||||||
The `referer` field keeps only the scheme and host of the request's `Referer`,
|
|
||||||
and logs `(redacted)` for one without them, because its path can be an
|
|
||||||
entrypoint URL: a sender that follows the `http://` to `https://` redirect in
|
|
||||||
the nginx example sends the URL it was redirected from.
|
|
||||||
|
|
||||||
Client-supplied request content does not leave the host by the other route
|
Client-supplied request content does not leave the host by the other route
|
||||||
either. The Sentry SDK attaches the request to every event it captures,
|
either. The Sentry SDK attaches the request to every event it captures,
|
||||||
independently of the access log, and `SendDefaultPII=false` does not cover all
|
independently of the access log, and `SendDefaultPII=false` does not cover all
|
||||||
@@ -2480,18 +2507,14 @@ are both taken unconditionally, the body precisely because these handlers call
|
|||||||
body with `(redacted)`, drops cookies and the remote-address environment, and
|
body with `(redacted)`, drops cookies and the remote-address environment, and
|
||||||
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
||||||
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`.
|
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`.
|
||||||
`Referer` is sent as its scheme and host only, because its path can be an
|
|
||||||
entrypoint URL; `internal/referer.SchemeAndHost` does that reduction for both
|
|
||||||
the access log's `referer` field and this hook.
|
|
||||||
|
|
||||||
The same hook rewrites the request URL. The SDK builds it as
|
The same hook rewrites the request URL. The SDK builds it as
|
||||||
`scheme://host/path` from the concrete path, which on the receiver route is
|
`scheme://host/path` from the concrete path, which on the receiver route is
|
||||||
`/h/<uuid>` in full — and that UUID is a write capability, not an identifier:
|
`/h/<uuid>` in full — and that UUID is a write capability, not an identifier:
|
||||||
anyone holding it can post events this service accepts and its targets then
|
anyone holding it can post events this service accepts and its targets then
|
||||||
deliver. What is sent is the chi route pattern instead: `http://host/h/{uuid}`.
|
deliver. A tracker has its own retention, access control and deletion policy, so
|
||||||
A tracker has its own retention, access control and deletion policy, so the
|
the rule the access log follows above does not carry across that boundary. What
|
||||||
pattern is sent on every route, including the 2xx and 5xx responses outside the
|
is sent is the chi route pattern instead: `http://host/h/{uuid}`.
|
||||||
receiver for which the access log keeps the concrete path.
|
|
||||||
|
|
||||||
The scheme and the host are kept, and everything else in the URL is discarded
|
The scheme and the host are kept, and everything else in the URL is discarded
|
||||||
rather than edited, so a future SDK version that starts appending a query string
|
rather than edited, so a future SDK version that starts appending a query string
|
||||||
@@ -2553,9 +2576,8 @@ receiver route. What survives still names the failing route — scheme, host,
|
|||||||
route pattern, method — and `X-Request-Id` ties the event to the local access
|
route pattern, method — and `X-Request-Id` ties the event to the local access
|
||||||
log line that holds the rest. Nothing dropped is needed for the likeliest use,
|
log line that holds the rest. Nothing dropped is needed for the likeliest use,
|
||||||
debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and
|
debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and
|
||||||
`Referer`; the latter two are kept (`Referer` as its scheme and host, which is
|
`Referer`; the latter two are kept, and the first is the scheme of the retained
|
||||||
all the check compares), and the first is the scheme of the retained URL,
|
URL, because the SDK derives that scheme from
|
||||||
because the SDK derives that scheme from
|
|
||||||
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate
|
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate
|
||||||
is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this
|
is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this
|
||||||
service now uses everywhere it decides transport: the SDK reports `http` for the
|
service now uses everywhere it decides transport: the SDK reports `http` for the
|
||||||
@@ -2621,7 +2643,9 @@ than it:
|
|||||||
| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ |
|
| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ |
|
||||||
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
|
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
|
||||||
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
|
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
|
||||||
|
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
|
||||||
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
||||||
|
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
|
||||||
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
|
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
|
||||||
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
|
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
|
||||||
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
|
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
|
||||||
@@ -2649,12 +2673,13 @@ wider of the two handlers spends more on than the client spent sending them.
|
|||||||
Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is
|
Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is
|
||||||
what the figure above states, and every row establishes it.
|
what the figure above states, and every row establishes it.
|
||||||
|
|
||||||
Two of the sites go further and bound the whole flood's output — the total bytes
|
Three of the sites go further and bound the whole flood's output — the total
|
||||||
a run of distinct invented values wrote, which is the shape an operator sizing
|
bytes a run of distinct invented values wrote, which is the shape an operator
|
||||||
storage cares about. They are `request body exceeds limit`
|
sizing storage cares about. They are `request body exceeds limit`
|
||||||
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`) and `user not found`
|
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`),
|
||||||
(through `assertBoundedFlood`). The other rows carry no aggregate assertion; the
|
`entrypoint not found` and `user not found` (the last two through
|
||||||
per-line ceiling is what they establish.
|
`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line
|
||||||
|
ceiling is what they establish.
|
||||||
|
|
||||||
`internal/logfield/logfield_test.go` measures the per-rune charge against what
|
`internal/logfield/logfield_test.go` measures the per-rune charge against what
|
||||||
the handlers really emit, over roughly 3,000 code points on each, so an
|
the handlers really emit, over roughly 3,000 code points on each, so an
|
||||||
@@ -2672,7 +2697,7 @@ everything else, so its lines take the level the operator set and the handler
|
|||||||
`internal/logger` selected, and every value it emits is spent through the same
|
`internal/logger` selected, and every value it emits is spent through the same
|
||||||
`internal/logfield` budget. A record-not-found is not logged as an error: it is
|
`internal/logfield` budget. A record-not-found is not logged as an error: it is
|
||||||
the expected outcome on both of those paths, and each handler already records
|
the expected outcome on both of those paths, and each handler already records
|
||||||
its own miss at `DEBUG`, without the SQL and within the ceiling above. Slow
|
its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow
|
||||||
statements are kept, at `WARN`, above the same 200 ms threshold GORM used and
|
statements are kept, at `WARN`, above the same 200 ms threshold GORM used and
|
||||||
with the statement bounded, because that report is the one thing GORM's logger
|
with the statement bounded, because that report is the one thing GORM's logger
|
||||||
gave an operator that nothing else here does. The adapter orders its cases
|
gave an operator that nothing else here does. The adapter orders its cases
|
||||||
@@ -3007,6 +3032,7 @@ webhooker/
|
|||||||
│ ├── database/
|
│ ├── database/
|
||||||
│ │ ├── base_model.go # BaseModel with UUID primary keys
|
│ │ ├── base_model.go # BaseModel with UUID primary keys
|
||||||
│ │ ├── database.go # GORM connection, migrations, admin seed
|
│ │ ├── database.go # GORM connection, migrations, admin seed
|
||||||
|
│ │ ├── models.go # AutoMigrate for config-tier models
|
||||||
│ │ ├── model_setting.go # Setting entity (key-value app config)
|
│ │ ├── model_setting.go # Setting entity (key-value app config)
|
||||||
│ │ ├── model_user.go # User entity
|
│ │ ├── model_user.go # User entity
|
||||||
│ │ ├── model_webhook.go # Webhook entity
|
│ │ ├── model_webhook.go # Webhook entity
|
||||||
@@ -3023,11 +3049,6 @@ webhooker/
|
|||||||
│ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages
|
│ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages
|
||||||
│ ├── datadir/
|
│ ├── datadir/
|
||||||
│ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance)
|
│ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance)
|
||||||
│ ├── db/migrations/
|
|
||||||
│ │ ├── migrations.go # Applies a database file's SQL files when it is opened
|
|
||||||
│ │ ├── main/ # 000_migration.sql and 001_schema.sql for webhooker.db
|
|
||||||
│ │ ├── events/ # The same for each events-{uuid}.db
|
|
||||||
│ │ └── archive/ # The same for each archive file
|
|
||||||
│ ├── globals/
|
│ ├── globals/
|
||||||
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
||||||
│ ├── gormlog/
|
│ ├── gormlog/
|
||||||
@@ -3087,8 +3108,6 @@ webhooker/
|
|||||||
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
|
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
|
||||||
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
|
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
|
||||||
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages
|
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages
|
||||||
│ ├── referer/
|
|
||||||
│ │ └── referer.go # SchemeAndHost: cuts a Referer down to its scheme and host, for the access log and the Sentry hook
|
|
||||||
│ ├── reqtls/
|
│ ├── reqtls/
|
||||||
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
|
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
|
||||||
│ ├── server/
|
│ ├── server/
|
||||||
@@ -3103,8 +3122,8 @@ webhooker/
|
|||||||
│ ├── static.go # //go:embed directive
|
│ ├── static.go # //go:embed directive
|
||||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||||
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline, the rule hiding x-cloak elements
|
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
|
||||||
│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components
|
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
@@ -3158,9 +3177,8 @@ Applied to all routes in this order:
|
|||||||
|
|
||||||
1. **RequestID** — Generate unique request IDs (chi built-in)
|
1. **RequestID** — Generate unique request IDs (chi built-in)
|
||||||
2. **SecurityHeaders** — Production security headers on every response (HSTS
|
2. **SecurityHeaders** — Production security headers on every response (HSTS
|
||||||
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP
|
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
|
||||||
`default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy,
|
Referrer-Policy, Permissions-Policy)
|
||||||
Permissions-Policy)
|
|
||||||
3. **Logging** — Structured request logging (method, URL, status, latency,
|
3. **Logging** — Structured request logging (method, URL, status, latency,
|
||||||
remote IP, client IP, user agent, request ID)
|
remote IP, client IP, user agent, request ID)
|
||||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
@@ -3245,12 +3263,6 @@ credential check, see [The login endpoint](#the-login-endpoint).
|
|||||||
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
|
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
|
||||||
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
|
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
|
||||||
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
|
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
|
||||||
- The Content-Security-Policy is
|
|
||||||
`default-src 'self'; script-src 'self'; style-src 'self'`: a page takes
|
|
||||||
scripts and styles only from files webhooker serves, and the browser runs no
|
|
||||||
inline script or event handler and ignores any `<style>` element or `style`
|
|
||||||
attribute in a page. The pages need none: their scripts are in `static/js/`
|
|
||||||
and their styles in `static/css/`
|
|
||||||
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
||||||
middleware that runs before CSRF parses the form
|
middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
||||||
@@ -3318,8 +3330,7 @@ stop-hook log:
|
|||||||
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
|
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
|
||||||
seconds**) and by what the hooks before it left, then a Sentry flush if
|
seconds**) and by what the hooks before it left, then a Sentry flush if
|
||||||
`SENTRY_DSN` is set
|
`SENTRY_DSN` is set
|
||||||
4. `delivery.Engine` — cuts off any `http` or `slack` request in flight, waits
|
4. `delivery.Engine` — waits for its workers, then closes the archive databases
|
||||||
for its workers, then closes the archive databases
|
|
||||||
5. `healthcheck`
|
5. `healthcheck`
|
||||||
6. `WebhookDBManager`
|
6. `WebhookDBManager`
|
||||||
7. the database close
|
7. the database close
|
||||||
@@ -3330,12 +3341,6 @@ by the stop context, so a wedge there consumes the 5 seconds before the HTTP
|
|||||||
server hook is ever entered. The hooks after the server are microsecond-scale in
|
server hook is ever entered. The hooks after the server are microsecond-scale in
|
||||||
normal operation.
|
normal operation.
|
||||||
|
|
||||||
The delivery engine cancels a delivery request in flight. That attempt is not
|
|
||||||
recorded or counted in the metrics, and the delivery keeps its `pending` or
|
|
||||||
`retrying` status, so startup recovery sends it again on the next start (see
|
|
||||||
Recovery paths under Bounded Worker Pool). If the receiver already had it, it
|
|
||||||
receives it twice: the at-least-once duplicate described under Rationale.
|
|
||||||
|
|
||||||
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
|
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
|
||||||
the two equal, a drain that used its whole budget would exhaust the sequence
|
the two equal, a drain that used its whole budget would exhaust the sequence
|
||||||
budget at the instant it finished, and every later hook — the delivery engine,
|
budget at the instant it finished, and every later hook — the delivery engine,
|
||||||
@@ -3458,12 +3463,14 @@ fixed independently of the compiler's:
|
|||||||
a step's log, and then fails. `make test` builds this stage alone.
|
a step's log, and then fails. `make test` builds this stage alone.
|
||||||
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
|
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
|
||||||
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
||||||
file from each), and runs `make build` (which extracts Alpine.js from `3p/`
|
file from each), runs `make build` (which extracts Alpine.js from `3p/`
|
||||||
first). `make build` compiles with cgo off, so the binary is statically
|
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
|
||||||
linked and runs on the Alpine runtime image. The version is the `VERSION`
|
so it runs on musl. Both builds go through `make build`, the relink adding
|
||||||
build arg if one is given, otherwise derived from the `.git` in the context,
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
|
||||||
and the stage fails if a context with `.git` would stamp an empty version,
|
the version. The version is the `VERSION` build arg if one is given,
|
||||||
`dev` or `unknown` (see [Version stamping](#version-stamping)).
|
otherwise derived from the `.git` in the context, and the stage fails if a
|
||||||
|
context with `.git` would stamp an empty version, `dev` or `unknown` (see
|
||||||
|
[Version stamping](#version-stamping)).
|
||||||
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
||||||
all SQLite databases, exposes port 8080, and includes a health check against
|
all SQLite databases, exposes port 8080, and includes a health check against
|
||||||
@@ -3475,6 +3482,11 @@ The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly
|
|||||||
rather than `make fmt-check`, `make lint` and `make test`: those targets build
|
rather than `make fmt-check`, `make lint` and `make test`: those targets build
|
||||||
docker stages, which would need a docker daemon inside this build.
|
docker stages, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
|
The lint phase and the Go stages use Debian rather than Alpine because
|
||||||
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does
|
||||||
|
not compile against musl. Only the final binary is statically linked, which is
|
||||||
|
what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then
|
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then
|
||||||
`script/check`, then builds the image, whose build runs the lint and test phases
|
`script/check`, then builds the image, whose build runs the lint and test phases
|
||||||
and the stylesheet and Markdown checks again. A build that succeeds is a repo
|
and the stylesheet and Markdown checks again. A build that succeeds is a repo
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ One issue per unit of work, one branch and one PR per issue:
|
|||||||
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||||
releases are cut from `main` separately.
|
releases are cut from `main` separately.
|
||||||
|
|
||||||
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
|
Issue branches do NOT touch this file — the manager maintains it on `next`.
|
||||||
is done and what is under way. This file keeps no list of either.
|
Every branch editing `TODO.md` conflicts with every other (#112).
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
What is still open for 1.0.0 is in its milestone
|
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
|
authoritative list, and the only place to read a count or a state of play from.
|
||||||
only @sneak tags it.
|
This file records where the project is, not what is in flight: a sentence whose
|
||||||
|
truth depends on a branch being unmerged is wrong the moment it merges, and this
|
||||||
|
file has been wrong that way before.
|
||||||
|
|
||||||
|
The durability defect that held the tag has landed
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
|
||||||
|
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
|
||||||
|
fails leaves its delivery in a recoverable state rather than a lying one, and
|
||||||
|
recovery skips a delivery that already has a successful result row. Final
|
||||||
|
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
|
||||||
|
still shows open is what remains before `v1.0.0`.
|
||||||
|
|
||||||
Delivery is at-least-once by design, not by accident: a send whose result row
|
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||||
does not land is attempted again, so a receiver can see a duplicate. That is
|
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||||
@@ -28,31 +38,338 @@ under Rationale. It is not a defect to re-file.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
|
Clear the rest of the open 1.0.0 milestone
|
||||||
into `main` is a separate act from tagging and waits on neither of those: `next`
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
|
||||||
is kept mergeable at all times, which is the point of the branch.
|
`next` into `main` is a separate act from tagging and waits on neither of those:
|
||||||
|
`next` is kept mergeable at all times, which is the point of the branch.
|
||||||
|
|
||||||
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
|
||||||
|
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
|
||||||
|
hostname, an empty value or a value carrying a port is a startup error, and
|
||||||
|
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
|
||||||
|
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||||
|
removed the shutdown race: `httpServer` is built in the constructor rather
|
||||||
|
than assigned from the serving goroutine, which orders the write before every
|
||||||
|
fx hook and rules out the nil dereference a SIGTERM arriving first would have
|
||||||
|
caused, and `sentryEnabled` is an `atomic.Bool`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/226)
|
||||||
|
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
|
||||||
|
is the authentication secret, so the per-entrypoint shared secret, the
|
||||||
|
`internal/signature` package, the receiver check, the model fields and the
|
||||||
|
forms are all gone. This reverses the feature that landed earlier in the same
|
||||||
|
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
|
||||||
|
https://git.eeqj.de/sneak/webhooker/issues/279)
|
||||||
|
- 2026-08-24 Stamp the build version into the binary and render it in the UI
|
||||||
|
footer. `script/version` is the single source — `$VERSION`, else
|
||||||
|
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
|
||||||
|
binary and a `make docker` image from one checkout report the same thing, and
|
||||||
|
nothing in it varies between two builds of the same commit, which the release
|
||||||
|
gate's byte-identical assertion would catch
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
||||||
|
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
|
||||||
|
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
|
||||||
|
with the environment left at its `dev` default, the session cookie silently
|
||||||
|
lost `Secure` while the CSRF cookie on the same response kept it.
|
||||||
|
`X-Forwarded-Proto` is now matched case-insensitively on its first
|
||||||
|
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
|
||||||
|
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
|
||||||
|
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
|
||||||
|
failing delete committed whatever had already succeeded, hard-deleted the
|
||||||
|
per-webhook event database anyway, and redirected as though it had worked —
|
||||||
|
orphaned config plus permanently destroyed history, reported as success. All
|
||||||
|
three delete positions now roll back with the event database intact
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
||||||
|
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
||||||
|
`(deleted)`, rather than leaving the event log unable to say where a delivery
|
||||||
|
went. A deleted target's credentials stay masked exactly as a live one's, and
|
||||||
|
it cannot become deliverable again through the receiver, resubmit, replay, the
|
||||||
|
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
|
||||||
|
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
|
||||||
|
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
|
||||||
|
carries the chi route pattern, and `method` folds anything chi cannot route
|
||||||
|
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
|
||||||
|
300 random method tokens took the series count from 106 to 7,631, and path
|
||||||
|
flooding reached 62,532 — and a label audit across a live scrape found no
|
||||||
|
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
||||||
|
https://git.eeqj.de/sneak/webhooker/issues/261)
|
||||||
|
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
|
||||||
|
silently became 0 — fire-and-forget — including on the edit path, where it
|
||||||
|
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
|
||||||
|
is the `max` both templates already declared
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/221)
|
||||||
|
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
|
||||||
|
under development can be tested against real captured traffic. Per-delivery
|
||||||
|
replay cannot serve that: it re-sends one finished delivery to its own
|
||||||
|
original target, and a target created for a dev backend has no prior delivery
|
||||||
|
to replay. Resubmit re-injects the stored event at the top of the receiver
|
||||||
|
path and fans it out to whatever targets are active now
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
||||||
|
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
|
||||||
|
on one directory cannot both deliver
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
||||||
|
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
|
||||||
|
returned as soon as the serving goroutine was spawned, so a failed listen left
|
||||||
|
fx reporting RUNNING and a live process with nothing bound — invisible to
|
||||||
|
systemd and Docker restart policies
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/200)
|
||||||
|
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
||||||
|
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
|
||||||
|
placeholders rather than bound values
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
|
||||||
|
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
|
||||||
|
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
|
||||||
|
trace recorder in for the logging adapter, and that recorder does not
|
||||||
|
implement `gorm.ParamsFilter`, so those statements logged their bound values
|
||||||
|
interpolated and bypassed the suppression above. The two units gated green
|
||||||
|
against a `next` that lacked the other, and `next` went red when both landed
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
||||||
|
- 2026-08-20 Render per-attempt delivery detail in the event log
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
||||||
|
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
|
||||||
|
- 2026-08-20 Expose delivery metrics on `/metrics`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
|
||||||
|
restore and upgrade procedures
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
||||||
|
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
|
||||||
|
admin bootstrap password was printed once among roughly 45 fx lines, and under
|
||||||
|
`docker run -d` went to container logs subject to rotation; there was no reset
|
||||||
|
path at all, so recovery meant hand-deleting the users row, documented
|
||||||
|
nowhere. The password is read from stdin or generated, never from argv where
|
||||||
|
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
|
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
|
||||||
|
SSRF guard, so a self-hosted proxy can forward into the operator's own
|
||||||
|
network. The guard's always-blocked set cannot be reopened by configuration
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
||||||
|
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
|
||||||
|
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
|
||||||
|
- 2026-08-20 Add a target edit form with headers and timeout fields
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
||||||
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
|
||||||
|
the org-wide backstop. `go test` applies `-timeout` per package, and
|
||||||
|
`internal/handlers` had grown past the old budget: a cache-defeated build
|
||||||
|
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
|
||||||
|
breached 30s. The measurement table lives in the script (#194)
|
||||||
|
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
|
||||||
|
and still mandated a 20s test target with a 30s timeout, which the org
|
||||||
|
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
|
||||||
|
reading it as one nearly produced a PR against `prompts` proposing a change
|
||||||
|
already merged there (#196)
|
||||||
|
- 2026-08-18 Report handler panics through the logger and answer 500. chi
|
||||||
|
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
|
||||||
|
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
|
||||||
|
writing a byte: the recovery never ran, the client got a dropped connection
|
||||||
|
instead of a 500, and the original panic was lost. A local middleware replaces
|
||||||
|
it, bounded by `MaxPanicLogLineBytes` (#187)
|
||||||
|
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
|
||||||
|
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
|
||||||
|
false, so **every record-not-found printed the fully interpolated SQL to
|
||||||
|
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
|
||||||
|
submitted username on the login form, at no level the operator set and outside
|
||||||
|
`internal/logger` entirely. Three call sites, not the two the issue named
|
||||||
|
(#178)
|
||||||
|
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
|
||||||
|
reachable unauthenticated, found by reading every `slog` call in the tree
|
||||||
|
rather than only the one reported; the budget moved to a shared
|
||||||
|
`internal/logfield` so no second truncation exists. `DEBUG` being off by
|
||||||
|
default is not a bound and is not treated as one (#176)
|
||||||
|
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
|
||||||
|
non-fatal `assert` on an acquire result was dereferenced on the next line, so
|
||||||
|
one timing miss killed the whole `internal/middleware` binary and reddened CI
|
||||||
|
for unrelated PRs. The fix also removed a real production race — `acquire`
|
||||||
|
could shed a request with a slot standing free, because Go picks uniformly
|
||||||
|
among ready `select` cases (#186)
|
||||||
|
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
|
||||||
|
The receiver's path carries the entrypoint capability token, so every Sentry
|
||||||
|
event from `/webhook/{uuid}` shipped a live credential to a third party.
|
||||||
|
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
|
||||||
|
reduced to an allowlist (#179)
|
||||||
|
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
|
||||||
|
query string, so a login could be driven by URL parameters — putting the
|
||||||
|
password somewhere that lands in access logs, proxy logs and browser history
|
||||||
|
(#160)
|
||||||
|
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
|
||||||
|
flood of wrong passwords cannot lock out the account it is guessing at. The
|
||||||
|
manager took this decision rather than stall the queue; it is flagged on the
|
||||||
|
issue for reversal (#150)
|
||||||
|
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
|
||||||
|
wrong in both directions from version skew and shared caches. `script/lint`
|
||||||
|
asserts the summary line, because `--no-cache-filter` silently ignores a stage
|
||||||
|
name it does not match — the flag that makes the gate meaningful fails open
|
||||||
|
(#109)
|
||||||
|
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
|
||||||
|
truncates for rendering, and that truncated value was the only way to read a
|
||||||
|
body, so the full payload was unreachable (#157)
|
||||||
|
- 2026-08-18 Bound the access log line against client-chosen text.
|
||||||
|
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
|
||||||
|
escaping cannot multiply a field past its allowance (#146)
|
||||||
|
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
|
||||||
|
skipped run rolls up green, so a commit that was never tested reported success
|
||||||
|
(#152)
|
||||||
|
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
|
||||||
|
hooks are bounded by a deadline the orchestrator will actually honour rather
|
||||||
|
than being killed mid-flush (#134)
|
||||||
|
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
|
||||||
|
2^64 addresses, so per-address keying let one client mint unlimited buckets.
|
||||||
|
Manager decision, recorded on the issue (#125)
|
||||||
|
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
|
||||||
|
including claims about behaviour the code does not have (#151)
|
||||||
|
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
||||||
|
`static/vendor.sha256` instead of committing the minified blob, so the
|
||||||
|
dependency is pinned by hash rather than by trust (#145)
|
||||||
|
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
|
||||||
|
large stored payload cannot be read into memory just to be truncated for
|
||||||
|
display (#135)
|
||||||
|
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
|
||||||
|
bearer credential in its path or query, and was rendered verbatim. Manager
|
||||||
|
decision to mask unconditionally (#115)
|
||||||
|
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
|
||||||
|
cannot hold the process past its grace period (#102)
|
||||||
|
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
|
||||||
|
a template error part-way through cannot commit a 200 and then fail — the
|
||||||
|
response is written only once it is whole (#123)
|
||||||
|
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
|
||||||
|
codec accepted cookies the session layer considered expired, so the cap was
|
||||||
|
enforced in one place and not the other (#108)
|
||||||
|
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
|
||||||
|
default silently discards forwarded headers and every client rate-limits as
|
||||||
|
the proxy's address (#149)
|
||||||
|
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
|
||||||
|
`/webhook/*` route. The existing limiter keyed on the request path and
|
||||||
|
`/webhook/{uuid}` matches any single segment, so a client that invented a
|
||||||
|
fresh path per request minted a fresh bucket per request: the limit on the
|
||||||
|
only unauthenticated endpoint bounded nothing in aggregate, and every request
|
||||||
|
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
|
||||||
|
client address alone now bounds that, chained in front of the unchanged
|
||||||
|
per-entrypoint limiter (#139)
|
||||||
|
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
|
||||||
|
promised manual redelivery in the present tense in three places when nothing
|
||||||
|
implements it (the same false claim also sat in the doc comment that was its
|
||||||
|
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
|
||||||
|
itself omitted five landed units (#141)
|
||||||
|
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
|
||||||
|
writes a build-context fingerprint before calling `script/cibuild`, so a code
|
||||||
|
commit invalidates the `COPY` layer of the lint and builder stages while a
|
||||||
|
docs-only commit still replays from cache; a superseding run also rewrites the
|
||||||
|
`failure` status Gitea leaves on commits it cancelled and never tested.
|
||||||
|
Verified by pushing a deliberately broken test and watching CI go red (#119)
|
||||||
|
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
|
||||||
|
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
|
||||||
|
panicking two goroutines with no recover after startup had already reported
|
||||||
|
success (#140)
|
||||||
|
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
|
||||||
|
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
|
||||||
|
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
|
||||||
|
on the unauthenticated receiver. Semantics proven unchanged by differential
|
||||||
|
testing against the previous implementation (#133)
|
||||||
|
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
||||||
|
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
|
||||||
|
function; running off the end falls back to the peer address (#124)
|
||||||
|
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
|
||||||
|
all three rate limiters key on the connection's own address unless the direct
|
||||||
|
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
|
||||||
|
left for the first non-proxy hop. Default trusts nothing, and a
|
||||||
|
set-but-unparseable value aborts startup. Before this, any client could mint a
|
||||||
|
fresh bucket or drain another's by rotating a spoofed header (#88)
|
||||||
|
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
|
||||||
|
settings placeholder removed, a progressive-enhancement copy button for the
|
||||||
|
entrypoint URL, and retention form copy that states the actual policy
|
||||||
|
(deletion by the reaper, 0 retains forever) (#57)
|
||||||
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
|
||||||
|
the request URL in `*url.Error`, so every transport failure persisted the full
|
||||||
|
Slack webhook URL into the per-webhook event database via
|
||||||
|
`DeliveryResult.Error`, a field a future REST API would have served.
|
||||||
|
`maskURLError` drops path, query and userinfo while preserving the wrapped
|
||||||
|
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
|
||||||
|
failures still read differently (#118)
|
||||||
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
|
||||||
|
path so one entrypoint cannot exhaust another's budget; over-limit requests
|
||||||
|
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
|
||||||
|
endpoint with no limit at all (#64)
|
||||||
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
|
`MaxBodySize` is now first in all four form-parsing route groups, so an
|
||||||
|
oversized request is rejected with 413 instead of being read in full by the
|
||||||
|
CSRF middleware before any cap applied (#90)
|
||||||
|
- 2026-08-11 Mask target config on the source detail page, which rendered the
|
||||||
|
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
|
||||||
|
credential that cannot be revoked per-holder. Config reaches the template only
|
||||||
|
as a `TargetView` of labelled fields, and header values are rendered as a
|
||||||
|
count (#113)
|
||||||
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
|
||||||
|
written in `BeforeSave` so the GORM column default cannot win the race. Also
|
||||||
|
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
|
||||||
|
`time.Duration` and wrapped the cutoff into the future, where every row
|
||||||
|
matched and the sweep deleted everything (#79)
|
||||||
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
|
||||||
|
with the 7-day absolute cap kept as an independent backstop that activity
|
||||||
|
never extends (#66)
|
||||||
|
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||||
|
orphaned `retrying` delivery whose target type no longer supports retries,
|
||||||
|
recording a `DeliveryResult` with the reason instead of leaving the delivery
|
||||||
|
stuck forever (#82)
|
||||||
|
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
|
||||||
|
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
|
||||||
|
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
|
||||||
|
seconds after boot: the proxy silently stopped delivering webhooks entirely,
|
||||||
|
and the reaper never ran a single sweep under its default one-hour interval
|
||||||
|
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
|
||||||
|
`database` target) evicts the cached archive writer and closes its handle
|
||||||
|
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
|
||||||
|
`ArchiveSweeper` prunes idle archives on the existing
|
||||||
|
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
||||||
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
|
environment values: `envInt` removed in favour of `envPositiveInt` plus a
|
||||||
|
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
|
||||||
|
defaults apply only to unset variables (#80)
|
||||||
|
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
|
||||||
|
deleting expired events, deliveries, and delivery results from each
|
||||||
|
per-webhook event database (#63)
|
||||||
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
|
||||||
|
canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
|
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
|
||||||
|
surfaced lint findings
|
||||||
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
|
shims, README Entrypoints section
|
||||||
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
|
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
|
||||||
|
HTTP and behind reverse proxies (#54)
|
||||||
|
- 2026-03-17 root path redirects based on auth state (#52)
|
||||||
|
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
|
||||||
|
rebinding defense, and per-IP login rate limiting (#42)
|
||||||
|
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||||
|
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
|
||||||
|
DATA_DIR default and clarified env docs (#46)
|
||||||
|
- 2026-03-05 security headers middleware, session regeneration on login, request
|
||||||
|
body size limits (#41)
|
||||||
|
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
|
||||||
|
the build-architecture global (#31)
|
||||||
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
|
||||||
|
engine with bounded worker pool and circuit breaker, parallel fan-out,
|
||||||
|
per-webhook event databases, management UI (#16)
|
||||||
|
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
|
||||||
|
(#6)
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Per-webhook rate limits, set in the web UI and enforced in the receiver
|
- Delivery status and retry management UI. Replay of a terminally failed
|
||||||
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
|
delivery and per-attempt detail already landed
|
||||||
sets for the whole server (see Rate Limiting in the README)
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
- API key authentication for programmatic access (the `APIKey` model exists, but
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
nothing creates a key and no middleware checks one)
|
- Per-webhook rate limiting in the receiver handler (per-webhook config plus
|
||||||
|
handler enforcement; global limits must not apply to receiver endpoints)
|
||||||
|
- API key authentication for programmatic access (APIKey model exists; Bearer
|
||||||
|
token middleware does not)
|
||||||
- REST API v1
|
- REST API v1
|
||||||
- CRUD for webhooks, entrypoints, targets
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
- event redelivery endpoint
|
- event redelivery endpoint
|
||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics in the web UI: response times summarised per webhook or per target,
|
- Analytics dashboard: success rates, response times, volume
|
||||||
and figures for all webhooks together. The webhook page already shows event,
|
- A remember-me option at login
|
||||||
delivery and failure counts and failure percentages, the target list counts
|
|
||||||
each target's deliveries, the event log and each event's page show every
|
|
||||||
attempt's duration, and `/metrics` carries server-wide counts and delivery
|
|
||||||
durations
|
|
||||||
- A remember-me option at login. Every session already survives a browser
|
|
||||||
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
|
|
||||||
(default 24 hours) without activity
|
|
||||||
- Password reset flow for a forgotten password over the web. The authenticated
|
- Password reset flow for a forgotten password over the web. The authenticated
|
||||||
password _change_ flow already landed, and a lost password is recoverable from
|
password _change_ flow already landed, and a lost password is recoverable from
|
||||||
the console with `webhooker resetpw`
|
the console with `webhooker resetpw`
|
||||||
@@ -60,13 +377,7 @@ is kept mergeable at all times, which is the point of the branch.
|
|||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
- SNS and S3 delivery targets
|
- SNS and S3 delivery targets
|
||||||
- data transformations (e.g. a configurable Slack message format; the Slack
|
- data transformations (e.g. webhook to Slack message formatting)
|
||||||
target sends one fixed format today)
|
- JSONL file delivery with periodic S3 upload
|
||||||
- JSONL file delivery with periodic S3 upload (the `database` target already
|
- webhook event search and filtering
|
||||||
archives events to SQLite files, rotated hourly, daily or monthly if set,
|
- multi-user with role-based access control
|
||||||
and the webhook page offers them as one gzipped JSON download)
|
|
||||||
- webhook event search, and filtering beyond the event log's failed and
|
|
||||||
pending views
|
|
||||||
- multi-user with role-based access control (each webhook already belongs to
|
|
||||||
one account, but there is only the account made at first boot and no way
|
|
||||||
to add another)
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/banner"
|
"sneak.berlin/go/webhooker/internal/banner"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -250,21 +249,12 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = parseModels(
|
|
||||||
db, &Setting{}, &User{}, &APIKey{},
|
|
||||||
&Webhook{}, &Entrypoint{}, &Target{},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connecting to %s: %w", dbPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies the main database's SQL files. See
|
|
||||||
// internal/db/migrations.
|
|
||||||
func (d *Database) migrate() error {
|
func (d *Database) migrate() error {
|
||||||
err := migrations.Apply(d.db, migrations.Main)
|
// Run GORM auto-migrations
|
||||||
|
err := d.Migrate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to run database migrations",
|
"failed to run database migrations",
|
||||||
@@ -279,21 +269,6 @@ func (d *Database) migrate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseModels has GORM parse each model for db before the handle is
|
|
||||||
// shared. GORM parses a model the first time a handle uses it, and two
|
|
||||||
// goroutines doing that at once for models that refer to each other,
|
|
||||||
// as Webhook and Target do, race inside GORM.
|
|
||||||
func parseModels(db *gorm.DB, models ...any) error {
|
|
||||||
for _, model := range models {
|
|
||||||
err := (&gorm.Statement{DB: db}).Parse(model)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parsing model %T: %w", model, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureAdminUser creates the bootstrap admin account when the
|
// ensureAdminUser creates the bootstrap admin account when the
|
||||||
// deployment has no users at all.
|
// deployment has no users at all.
|
||||||
func (d *Database) ensureAdminUser() error {
|
func (d *Database) ensureAdminUser() error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -17,14 +18,14 @@ const omitAssociationsCallback = "webhooker:omit_associations"
|
|||||||
//
|
//
|
||||||
// A per-webhook database holds the event tier only, but Delivery
|
// A per-webhook database holds the event tier only, but Delivery
|
||||||
// declares belongs-to Event and Target and the delivery engine fills
|
// declares belongs-to Event and Target and the delivery engine fills
|
||||||
// both in memory before writing. Without this guard GORM upserts those
|
// both in memory before writing. Without this guard GORM upserts
|
||||||
// parent rows here on the delivery and retry write paths, and the
|
// those parent rows here on the delivery and retry write paths,
|
||||||
// target, whose config holds destination URLs and bearer credentials,
|
// copying targets.config, which holds destination URLs and bearer
|
||||||
// goes to a targets table this file does not have, which fails the
|
// credentials, into the file most likely to be backed up or handed
|
||||||
// write. Registering the guard on the connection
|
// to someone else. Registering the guard on the connection covers
|
||||||
// covers every write path, including writes inside a transaction and
|
// every write path, including writes inside a transaction and write
|
||||||
// write paths added later. Every event-tier row this file holds is
|
// paths added later. Every event-tier row this file holds is written
|
||||||
// written explicitly, so nothing depends on the automatic save.
|
// explicitly, so nothing depends on the automatic save.
|
||||||
func omitAssociations(db *gorm.DB) error {
|
func omitAssociations(db *gorm.DB) error {
|
||||||
omit := func(tx *gorm.DB) {
|
omit := func(tx *gorm.DB) {
|
||||||
tx.Statement.Omits = append(
|
tx.Statement.Omits = append(
|
||||||
@@ -52,3 +53,107 @@ func omitAssociations(db *gorm.DB) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows
|
||||||
|
// stamps into a per-webhook database once it has removed any leaked
|
||||||
|
// target rows *and* the VACUUM that removes their bytes has returned.
|
||||||
|
// Nothing else in the tree uses user_version, so 0 means "not swept
|
||||||
|
// by this build".
|
||||||
|
//
|
||||||
|
// The stamp, not the DELETE, is what records that a file is done. A
|
||||||
|
// DELETE commits on its own, so a sweep that is interrupted or whose
|
||||||
|
// VACUUM fails leaves a file whose rows are gone but whose credential
|
||||||
|
// bytes are still in the free pages -- indistinguishable, by row
|
||||||
|
// count, from a file that never leaked. Both leave the stamp unset,
|
||||||
|
// so the next open sweeps again.
|
||||||
|
const eventDBSweptVersion = 1
|
||||||
|
|
||||||
|
// purgeTargetRows deletes target rows that an earlier build's
|
||||||
|
// association upsert wrote into a per-webhook database, and rewrites
|
||||||
|
// the file so their bytes are gone with them. AutoMigrate creates a
|
||||||
|
// targets table in every one of these files because Delivery declares
|
||||||
|
// a belongs-to Target, but nothing in the event tier may put rows in
|
||||||
|
// it. The rows it did put there are junk, not history: they carry an
|
||||||
|
// empty webhook_id, and delivery rows resolve their target against
|
||||||
|
// the main database, so nothing here refers to them.
|
||||||
|
//
|
||||||
|
// The DELETE only unlinks the rows: modernc.org/sqlite leaves
|
||||||
|
// secure_delete at SQLite's default of off, so the credential bytes
|
||||||
|
// stay readable in the file's free pages and a backup of a swept file
|
||||||
|
// would still hand them over. VACUUM rewrites the file without them.
|
||||||
|
//
|
||||||
|
// This runs before every migration and is gated on
|
||||||
|
// eventDBSweptVersion, so a file pays for the rewrite once, on the
|
||||||
|
// first open that finds it unstamped, and every open after that is a
|
||||||
|
// PRAGMA read. A file this build created is stamped before its
|
||||||
|
// targets table exists, so it never vacuums at all. A failure here
|
||||||
|
// fails the open with the stamp left unset, so the sweep is retried
|
||||||
|
// rather than skipped -- a webhook whose file cannot be swept stays
|
||||||
|
// unusable instead of quietly serving from a file that still holds
|
||||||
|
// recoverable credentials.
|
||||||
|
func purgeTargetRows(
|
||||||
|
db *gorm.DB, log *slog.Logger, webhookID string,
|
||||||
|
) error {
|
||||||
|
var version int
|
||||||
|
|
||||||
|
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
|
||||||
|
err := db.Raw("PRAGMA user_version").Row().Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"reading sweep marker of webhook database %s: %w",
|
||||||
|
webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version >= eventDBSweptVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var purged int64
|
||||||
|
|
||||||
|
if db.Migrator().HasTable("targets") {
|
||||||
|
res := db.Exec("DELETE FROM targets")
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"purging target rows from webhook database %s: %w",
|
||||||
|
webhookID, res.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
purged = res.RowsAffected
|
||||||
|
|
||||||
|
// Unconditional: a zero row count here does not mean there is
|
||||||
|
// nothing to remove, only that no *live* row is left. See
|
||||||
|
// eventDBSweptVersion.
|
||||||
|
err = db.Exec("VACUUM").Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"purged %d leaked target rows from webhook database "+
|
||||||
|
"%s but vacuuming it failed, so the deleted "+
|
||||||
|
"target credentials are still recoverable from "+
|
||||||
|
"the file; it stays marked unswept and the next "+
|
||||||
|
"open retries: %w",
|
||||||
|
purged, webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Exec(fmt.Sprintf(
|
||||||
|
"PRAGMA user_version = %d", eventDBSweptVersion,
|
||||||
|
)).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"marking webhook database %s swept: %w", webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if purged > 0 {
|
||||||
|
log.Warn(
|
||||||
|
"purged leaked target rows from per-webhook database",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"rows", purged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,392 @@
|
|||||||
package database_test
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testDataDirPerm is the mode the test data directory is created
|
||||||
|
// with.
|
||||||
|
const testDataDirPerm = 0o750
|
||||||
|
|
||||||
|
// eventDBDataDir returns a data directory that a WebhookDBManager
|
||||||
|
// can be pointed at.
|
||||||
|
func eventDBDataDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := filepath.Join(t.TempDir(), "events")
|
||||||
|
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRawEventDB opens the per-webhook database file directly,
|
||||||
|
// without the manager, so a test can put a file on disk in a state
|
||||||
|
// the manager has to cope with, or inspect one afterwards.
|
||||||
|
func openRawEventDB(
|
||||||
|
t *testing.T, dataDir, webhookID string,
|
||||||
|
) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(
|
||||||
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
||||||
|
)
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
fmt.Sprintf("file:%s?mode=rwc", path),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventDBFileBytes reads a per-webhook database file off disk, so a
|
||||||
|
// test can assert on what the file itself still holds rather than on
|
||||||
|
// what a query returns.
|
||||||
|
func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // reads a file the test just created under t.TempDir()
|
||||||
|
raw, err := os.ReadFile(filepath.Join(
|
||||||
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventDBUserVersion returns the PRAGMA user_version of a per-webhook
|
||||||
|
// database file, which is the marker purgeTargetRows stamps once it
|
||||||
|
// has swept and vacuumed.
|
||||||
|
func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var version int
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.QueryRowContext(
|
||||||
|
t.Context(), "PRAGMA user_version",
|
||||||
|
).Scan(&version))
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearEventDBSweptMarker resets the sweep marker to 0, which is what
|
||||||
|
// a file written by a build without the sweep looks like. Tests that
|
||||||
|
// seed a leaked row have to create the file through the manager to
|
||||||
|
// get the real targets table shape, and that stamps it.
|
||||||
|
func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countTargetRows returns the number of rows in the targets table of
|
||||||
|
// a per-webhook database file, or -1 if the table does not exist.
|
||||||
|
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var tables int
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
"SELECT count(*) FROM sqlite_master "+
|
||||||
|
"WHERE type = 'table' AND name = 'targets'",
|
||||||
|
).Scan(&tables))
|
||||||
|
|
||||||
|
if tables == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int
|
||||||
|
|
||||||
|
require.NoError(t, sqlDB.QueryRowContext(
|
||||||
|
t.Context(), "SELECT count(*) FROM targets",
|
||||||
|
).Scan(&rows))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenPurgesLeakedTargetRows covers the sweep for event
|
||||||
|
// databases written by a build that let GORM upsert target rows
|
||||||
|
// into them: opening the database clears them, and opening it again
|
||||||
|
// is a no-op.
|
||||||
|
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
// Create the file the way the application does, so the targets
|
||||||
|
// table has exactly the shape AutoMigrate gives it, then write
|
||||||
|
// a leaked row into it the way the association upsert did.
|
||||||
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err := initial.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, initial.CloseAll())
|
||||||
|
|
||||||
|
seed := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
|
||||||
|
_, err = seed.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
"INSERT INTO targets "+
|
||||||
|
"(id, webhook_id, name, type, config) "+
|
||||||
|
"VALUES (?, '', ?, ?, ?)",
|
||||||
|
uuid.New().String(),
|
||||||
|
"leaked-target",
|
||||||
|
"slack",
|
||||||
|
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, countTargetRows(t, seed))
|
||||||
|
clearEventDBSweptMarker(t, seed)
|
||||||
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
check := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Zero(t, countTargetRows(t, check))
|
||||||
|
assert.Equal(
|
||||||
|
t, 1, eventDBUserVersion(t, check),
|
||||||
|
"a completed sweep must mark the file so later opens skip it",
|
||||||
|
)
|
||||||
|
require.NoError(t, check.Close())
|
||||||
|
|
||||||
|
// Idempotent: a second open leaves it at zero and does not
|
||||||
|
// error.
|
||||||
|
again := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err = again.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, again.CloseAll())
|
||||||
|
|
||||||
|
recheck := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Zero(t, countTargetRows(t, recheck))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level
|
||||||
|
// that matters for a backup handed to someone else: the leaked
|
||||||
|
// credential must be gone from the raw bytes of the file, not merely
|
||||||
|
// unreachable by query. A bare DELETE unlinks the row and leaves the
|
||||||
|
// bytes readable in the free pages, so this fails without the VACUUM
|
||||||
|
// in purgeTargetRows.
|
||||||
|
func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
||||||
|
|
||||||
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err := initial.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, initial.CloseAll())
|
||||||
|
|
||||||
|
seed := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
|
||||||
|
_, err = seed.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
"INSERT INTO targets "+
|
||||||
|
"(id, webhook_id, name, type, config) "+
|
||||||
|
"VALUES (?, '', ?, ?, ?)",
|
||||||
|
uuid.New().String(),
|
||||||
|
"leaked-target",
|
||||||
|
"slack",
|
||||||
|
fmt.Sprintf(
|
||||||
|
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
clearEventDBSweptMarker(t, seed)
|
||||||
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
|
// The seed has to be in the file for its absence later to mean
|
||||||
|
// anything.
|
||||||
|
require.True(
|
||||||
|
t,
|
||||||
|
bytes.Contains(
|
||||||
|
eventDBFileBytes(t, dataDir, webhookID),
|
||||||
|
[]byte(credential),
|
||||||
|
),
|
||||||
|
"seeded credential is not in the file, so this test proves nothing",
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
assert.NotContains(
|
||||||
|
t,
|
||||||
|
string(eventDBFileBytes(t, dataDir, webhookID)),
|
||||||
|
credential,
|
||||||
|
"leaked credential is still recoverable from the raw file",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count
|
||||||
|
// cannot see: the rows are already deleted but the file was never
|
||||||
|
// vacuumed, because an earlier sweep died between the two or its
|
||||||
|
// VACUUM failed. The credential bytes are still recoverable, and the
|
||||||
|
// unset marker is the only thing that says so, so the next open must
|
||||||
|
// vacuum rather than conclude from the empty table that there is
|
||||||
|
// nothing to do.
|
||||||
|
func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
||||||
|
|
||||||
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err := initial.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, initial.CloseAll())
|
||||||
|
|
||||||
|
seed := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
|
||||||
|
_, err = seed.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
"INSERT INTO targets "+
|
||||||
|
"(id, webhook_id, name, type, config) "+
|
||||||
|
"VALUES (?, '', ?, ?, ?)",
|
||||||
|
uuid.New().String(),
|
||||||
|
"leaked-target",
|
||||||
|
"slack",
|
||||||
|
fmt.Sprintf(
|
||||||
|
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Exactly the state an interrupted sweep leaves: rows gone,
|
||||||
|
// marker unset, bytes still in the free pages.
|
||||||
|
_, err = seed.ExecContext(t.Context(), "DELETE FROM targets")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, countTargetRows(t, seed))
|
||||||
|
clearEventDBSweptMarker(t, seed)
|
||||||
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
|
require.True(
|
||||||
|
t,
|
||||||
|
bytes.Contains(
|
||||||
|
eventDBFileBytes(t, dataDir, webhookID),
|
||||||
|
[]byte(credential),
|
||||||
|
),
|
||||||
|
"the deleted row's bytes must still be in the file, or this "+
|
||||||
|
"test proves nothing",
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
assert.NotContains(
|
||||||
|
t,
|
||||||
|
string(eventDBFileBytes(t, dataDir, webhookID)),
|
||||||
|
credential,
|
||||||
|
"an interrupted sweep was not retried, so the credential is "+
|
||||||
|
"still recoverable from the raw file",
|
||||||
|
)
|
||||||
|
|
||||||
|
check := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Equal(t, 1, eventDBUserVersion(t, check))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenSkipsSweptDatabase covers the other half of the marker: a
|
||||||
|
// file this build created is marked without ever being vacuumed, and
|
||||||
|
// a marked file is not swept again.
|
||||||
|
func TestOpenSkipsSweptDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
marked := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Equal(t, 1, eventDBUserVersion(t, marked))
|
||||||
|
|
||||||
|
// A marked file is left alone, so a row written into it survives
|
||||||
|
// a reopen. Nothing writes target rows any more; this stands in
|
||||||
|
// for the sweep having run.
|
||||||
|
_, err = marked.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
"INSERT INTO targets "+
|
||||||
|
"(id, webhook_id, name, type, config) "+
|
||||||
|
"VALUES (?, '', ?, ?, ?)",
|
||||||
|
uuid.New().String(), "sentinel", "slack", `{}`,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, marked.Close())
|
||||||
|
|
||||||
|
again := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
_, err = again.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, again.CloseAll())
|
||||||
|
|
||||||
|
check := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Equal(
|
||||||
|
t, 1, countTargetRows(t, check),
|
||||||
|
"a marked file must not be swept again",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenSucceedsWithoutTargetsTable covers an existing event
|
||||||
|
// database that never grew a targets table. The sweep must not fail
|
||||||
|
// startup on it.
|
||||||
|
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
seed := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
|
||||||
|
_, err := seed.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
"CREATE TABLE events (id text PRIMARY KEY)",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, db)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
}
|
||||||
|
|
||||||
// TestEventDBCreateOmitsAssociations covers the connection-level
|
// TestEventDBCreateOmitsAssociations covers the connection-level
|
||||||
// guard directly: a Delivery carrying its Event and Target in
|
// guard directly: a Delivery carrying its Event and Target in
|
||||||
// memory, written through the manager's handle, must store only the
|
// memory, written through the manager's handle, must store only the
|
||||||
@@ -17,9 +394,10 @@ import (
|
|||||||
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := eventDBDataDir(t)
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -29,7 +407,6 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
|||||||
Name: "leaky-target",
|
Name: "leaky-target",
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
target.ID = uuid.New().String()
|
target.ID = uuid.New().String()
|
||||||
|
|
||||||
@@ -55,9 +432,8 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
|||||||
require.NoError(t, db.Model(d).
|
require.NoError(t, db.Model(d).
|
||||||
Update("status", database.DeliveryStatusDelivered).
|
Update("status", database.DeliveryStatusDelivered).
|
||||||
Error)
|
Error)
|
||||||
|
|
||||||
// The file has no targets table, so the create and update above
|
|
||||||
// would have failed had GORM tried to save the target.
|
|
||||||
assert.False(t, db.Migrator().HasTable(&database.Target{}))
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
check := openRawEventDB(t, dataDir, webhookID)
|
||||||
|
assert.Zero(t, countTargetRows(t, check))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new
|
// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that opening a
|
||||||
// per-webhook database has these indexes.
|
// per-webhook database that predates these indexes creates them. It
|
||||||
|
// stands in for an older database file by dropping the indexes
|
||||||
|
// AutoMigrate just created, then reopening the same file.
|
||||||
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -40,9 +42,27 @@ func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
|||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A fresh database has them.
|
||||||
|
for _, ix := range indexes {
|
||||||
|
require.True(t, db.Migrator().HasIndex(ix.model, ix.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for a database file created before the indexes existed.
|
||||||
|
for _, ix := range indexes {
|
||||||
|
require.NoError(t, db.Migrator().DropIndex(ix.model, ix.name))
|
||||||
|
require.False(t, db.Migrator().HasIndex(ix.model, ix.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the cached connection so the next open reopens the file and
|
||||||
|
// runs AutoMigrate against it, as a restart would.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
db, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, ix := range indexes {
|
for _, ix := range indexes {
|
||||||
assert.True(t, db.Migrator().HasIndex(ix.model, ix.name),
|
assert.True(t, db.Migrator().HasIndex(ix.model, ix.name),
|
||||||
"a new database should have %s", ix.name)
|
"opening the existing database should create %s", ix.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ type Event struct {
|
|||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver. It is not a foreign key: the source event can be
|
// the receiver, which is every event created before the column
|
||||||
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// 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:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
|||||||
Targets: []database.Target{{
|
Targets: []database.Target{{
|
||||||
Name: "log",
|
Name: "log",
|
||||||
Type: database.TargetTypeLog,
|
Type: database.TargetTypeLog,
|
||||||
MaxAttempts: 1,
|
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Create(&stored).Error)
|
require.NoError(t, db.Create(&stored).Error)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
|||||||
Name: keptField,
|
Name: keptField,
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -100,7 +99,6 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
|||||||
Targets: []database.Target{{
|
Targets: []database.Target{{
|
||||||
Name: "slack",
|
Name: "slack",
|
||||||
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ type Target struct {
|
|||||||
// handler that marshals the model itself.
|
// handler that marshals the model itself.
|
||||||
Config string `gorm:"type:text" json:"-"` // JSON configuration
|
Config string `gorm:"type:text" json:"-"` // JSON configuration
|
||||||
|
|
||||||
// MaxAttempts is how many times an HTTP or Slack target attempts
|
// For HTTP targets (max_retries=0 means fire-and-forget,
|
||||||
// each delivery in all, at least 1. 1 is a single attempt with no
|
// >0 enables retries with backoff)
|
||||||
// circuit breaker; above 1, a failed attempt is retried with
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
// backoff behind the breaker. The table's check constraint refuses
|
|
||||||
// a target saved with less than 1, whatever its type.
|
|
||||||
MaxAttempts int `gorm:"not null;check:max_attempts >= 1" json:"maxAttempts,omitempty"`
|
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
|||||||
@@ -1,44 +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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newHTTPTarget returns an http target that makes maxAttempts attempts
|
|
||||||
// at each delivery.
|
|
||||||
func newHTTPTarget(maxAttempts int) *database.Target {
|
|
||||||
return &database.Target{
|
|
||||||
WebhookID: uuid.New().String(),
|
|
||||||
Name: "target",
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Config: `{"url":"https://example.com/"}`,
|
|
||||||
MaxAttempts: maxAttempts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_RefusesZeroAttempts relies on the targets table
|
|
||||||
// alone: nothing in the model checks the number, so the check
|
|
||||||
// constraint is what keeps a target with 0 attempts out of the
|
|
||||||
// database.
|
|
||||||
func TestTargetCreate_RefusesZeroAttempts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
err := db.Create(newHTTPTarget(0)).Error
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "CHECK constraint failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTargetCreate_AcceptsOneAttempt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
require.NoError(t, db.Create(newHTTPTarget(1)).Error)
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,27 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// DefaultRetentionDays is the event retention period the new
|
// DefaultRetentionDays is the event retention period applied to a
|
||||||
// webhook form applies when its retention field is left blank.
|
// webhook created without an explicit retention value. It is the
|
||||||
|
// single source of truth for that policy and must stay in sync
|
||||||
|
// with the `gorm:"default:30"` column default on
|
||||||
|
// Webhook.RetentionDays below; a struct tag cannot reference a
|
||||||
|
// constant, so a test asserts the two agree.
|
||||||
DefaultRetentionDays = 30
|
DefaultRetentionDays = 30
|
||||||
|
|
||||||
|
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
||||||
|
// "retain events forever". Users express that intent as 0, which
|
||||||
|
// Webhook.BeforeSave rewrites to this value: the column default
|
||||||
|
// substitutes DefaultRetentionDays for a zero value at insert
|
||||||
|
// time, so a zero can never survive a round trip to the database.
|
||||||
|
// Nothing outside this file may hardcode the number.
|
||||||
|
RetentionForeverDays = 365 * 1000
|
||||||
|
|
||||||
// MaxFiniteRetentionDays is the largest finite retention period the
|
// MaxFiniteRetentionDays is the largest finite retention period the
|
||||||
// reaper's cutoff arithmetic can represent, and therefore the
|
// reaper's cutoff arithmetic can represent, and therefore the
|
||||||
// largest one a caller may request. It is derived from that
|
// largest one a caller may request. It is derived from that
|
||||||
@@ -24,7 +38,9 @@ const (
|
|||||||
// turns the cutoff into a timestamp in the far future that matches
|
// turns the cutoff into a timestamp in the far future that matches
|
||||||
// every row in the webhook's database. That is why this bound is
|
// every row in the webhook's database. That is why this bound is
|
||||||
// enforced on input and why retentionCutoff saturates underneath
|
// enforced on input and why retentionCutoff saturates underneath
|
||||||
// it.
|
// it. Note that RetentionForeverDays deliberately sits above this
|
||||||
|
// ceiling: such webhooks are skipped before any cutoff is
|
||||||
|
// computed, and never reach the arithmetic at all.
|
||||||
MaxFiniteRetentionDays = int(
|
MaxFiniteRetentionDays = int(
|
||||||
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
||||||
)
|
)
|
||||||
@@ -32,10 +48,12 @@ const (
|
|||||||
|
|
||||||
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
||||||
//
|
//
|
||||||
// Every method below takes a pointer receiver, so handlers put a
|
// Every method below takes a pointer receiver. BeforeSave has to,
|
||||||
// *Webhook into template data: html/template cannot call a pointer
|
// because it mutates the record and GORM only invokes hooks declared
|
||||||
// method on a value held in a map, because a map element is not
|
// that way; the display helpers follow suit so the receiver kinds do
|
||||||
// addressable.
|
// not mix. Handlers therefore put a *Webhook into template data:
|
||||||
|
// html/template cannot call a pointer method on a value held in a map,
|
||||||
|
// because a map element is not addressable.
|
||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
@@ -43,9 +61,10 @@ type Webhook struct {
|
|||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
|
||||||
// RetentionDays is the number of days to retain events, and 0
|
// RetentionDays is the number of days to retain events. A value of
|
||||||
// means retain them forever.
|
// RetentionForeverDays means retain forever. The column default
|
||||||
RetentionDays int `json:"retentionDays"`
|
// must equal DefaultRetentionDays.
|
||||||
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// User.Webhooks leads back here, and the JSON could loop.
|
// User.Webhooks leads back here, and the JSON could loop.
|
||||||
@@ -54,17 +73,36 @@ type Webhook struct {
|
|||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// retainsForever reports whether a RetentionDays value means "keep
|
// BeforeSave normalises RetentionDays on every insert and update. A
|
||||||
// events indefinitely", which 0 does. It is the single definition of
|
// non-positive value is the user's way of asking for "retain forever",
|
||||||
// that question, shared by Webhook.RetainsForever and by the reaper's
|
// which is stored as the RetentionForeverDays sentinel.
|
||||||
|
//
|
||||||
|
// This has to happen in a hook rather than at the call sites. GORM
|
||||||
|
// substitutes the column default (DefaultRetentionDays) for a zero
|
||||||
|
// value while building the insert statement, which runs after
|
||||||
|
// BeforeSave; rewriting any later than this loses that race and the
|
||||||
|
// row lands at 30 days. Living on the model also means a future call
|
||||||
|
// site — a REST API, a fixture, a migration — cannot bypass it.
|
||||||
|
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||||
|
if w.RetentionDays <= 0 {
|
||||||
|
w.RetentionDays = RetentionForeverDays
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainsForever reports whether a stored RetentionDays value means
|
||||||
|
// "keep events indefinitely". It is the single definition of that
|
||||||
|
// question, shared by Webhook.RetainsForever and by the reaper's
|
||||||
// cutoff computation so the two cannot disagree about which webhooks
|
// cutoff computation so the two cannot disagree about which webhooks
|
||||||
// are exempt from reaping.
|
// are exempt from reaping.
|
||||||
//
|
//
|
||||||
// A negative value, which the forms refuse, is read the same way: as a
|
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||||
// day count it would put the cutoff in the future, where it matches
|
// and, defensively, the non-positive values that rows written before
|
||||||
// every event the webhook has.
|
// the sentinel existed may still carry.
|
||||||
func retainsForever(retentionDays int) bool {
|
func retainsForever(retentionDays int) bool {
|
||||||
return retentionDays <= 0
|
return retentionDays <= 0 ||
|
||||||
|
retentionDays >= RetentionForeverDays
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetainsForever reports whether this webhook's events are kept
|
// RetainsForever reports whether this webhook's events are kept
|
||||||
@@ -81,8 +119,7 @@ func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RetentionLabel returns the webhook's retention policy as display
|
// RetentionLabel returns the webhook's retention policy as display
|
||||||
// text: "forever" rather than "0 days" for a webhook that keeps its
|
// text, so that no template has to know about the sentinel value.
|
||||||
// events.
|
|
||||||
func (w *Webhook) RetentionLabel() string {
|
func (w *Webhook) RetentionLabel() string {
|
||||||
if w.RetainsForever() {
|
if w.RetainsForever() {
|
||||||
return "forever"
|
return "forever"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package database_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -45,7 +47,8 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newWebhookWithRetention creates a webhook through the ordinary Create
|
// newWebhookWithRetention creates a webhook through the ordinary Create
|
||||||
// path, as the new webhook form does.
|
// path, so the BeforeSave hook and the GORM column default both apply
|
||||||
|
// exactly as they do in production.
|
||||||
func newWebhookWithRetention(
|
func newWebhookWithRetention(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
@@ -64,7 +67,7 @@ func newWebhookWithRetention(
|
|||||||
return wh.ID
|
return wh.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
|
func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := startedTestDB(t)
|
db := startedTestDB(t)
|
||||||
@@ -72,10 +75,31 @@ func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
|
|||||||
wh := &database.Webhook{RetentionDays: 0}
|
wh := &database.Webhook{RetentionDays: 0}
|
||||||
id := newWebhookWithRetention(t, db, wh)
|
id := newWebhookWithRetention(t, db, wh)
|
||||||
|
|
||||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetention(t, db, id),
|
||||||
|
"a zero retention must be stored as the sentinel, "+
|
||||||
|
"not replaced by the column default",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
|
func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := startedTestDB(t)
|
||||||
|
|
||||||
|
wh := &database.Webhook{RetentionDays: -5}
|
||||||
|
id := newWebhookWithRetention(t, db, wh)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetention(t, db, id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := startedTestDB(t)
|
db := startedTestDB(t)
|
||||||
@@ -86,9 +110,10 @@ func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
|
|||||||
assert.Equal(t, 7, storedRetention(t, db, id))
|
assert.Equal(t, 7, storedRetention(t, db, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day
|
// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
|
||||||
// count to 0 through the same Save call the edit handler makes.
|
// fires on update as well as insert, via the same Save call the edit
|
||||||
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
|
// handler makes.
|
||||||
|
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := startedTestDB(t)
|
db := startedTestDB(t)
|
||||||
@@ -100,7 +125,28 @@ func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
|
|||||||
wh.RetentionDays = 0
|
wh.RetentionDays = 0
|
||||||
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
|
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
|
||||||
|
|
||||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetention(t, db, id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place
|
||||||
|
// the default lives twice: a struct tag cannot reference a constant, so
|
||||||
|
// this asserts the tag and DefaultRetentionDays agree.
|
||||||
|
func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
field, ok := reflect.TypeFor[database.Webhook]().
|
||||||
|
FieldByName("RetentionDays")
|
||||||
|
require.True(t, ok, "Webhook.RetentionDays must exist")
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"default:"+strconv.Itoa(database.DefaultRetentionDays),
|
||||||
|
field.Tag.Get("gorm"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
|
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
|
||||||
@@ -130,6 +176,14 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
|
|||||||
time.Duration(overCeiling*hoursPerDay)*time.Hour,
|
time.Duration(overCeiling*hoursPerDay)*time.Hour,
|
||||||
"one day past the ceiling must overflow",
|
"one day past the ceiling must overflow",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Less(
|
||||||
|
t,
|
||||||
|
database.MaxFiniteRetentionDays,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
"the sentinel sits above the ceiling and is only safe "+
|
||||||
|
"because retain-forever webhooks skip the arithmetic",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||||
@@ -141,8 +195,16 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
|||||||
forever bool
|
forever bool
|
||||||
label string
|
label string
|
||||||
}{
|
}{
|
||||||
{"zero", 0, true, testForeverLabel},
|
{
|
||||||
{"negative", -1, true, testForeverLabel},
|
"sentinel",
|
||||||
|
database.RetentionForeverDays, true, testForeverLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"above sentinel",
|
||||||
|
database.RetentionForeverDays + 1, true, testForeverLabel,
|
||||||
|
},
|
||||||
|
{"legacy zero", 0, true, testForeverLabel},
|
||||||
|
{"legacy negative", -1, true, testForeverLabel},
|
||||||
{"default", database.DefaultRetentionDays, false, "30 days"},
|
{"default", database.DefaultRetentionDays, false, "30 days"},
|
||||||
{"one day", 1, false, "1 day"},
|
{"one day", 1, false, "1 day"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
|
func (d *Database) Migrate() error {
|
||||||
|
return d.db.AutoMigrate(
|
||||||
|
&Setting{},
|
||||||
|
&User{},
|
||||||
|
&APIKey{},
|
||||||
|
&Webhook{},
|
||||||
|
&Entrypoint{},
|
||||||
|
&Target{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -243,8 +243,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
|
|
||||||
// retentionCutoff returns the timestamp before which a webhook's
|
// retentionCutoff returns the timestamp before which a webhook's
|
||||||
// events have expired, and whether any cutoff applies at all. It
|
// events have expired, and whether any cutoff applies at all. It
|
||||||
// reports false for a retention of 0, which means retain forever, so
|
// reports false for a retain-forever policy, so no DELETE is issued.
|
||||||
// no DELETE is issued.
|
|
||||||
//
|
//
|
||||||
// The day count is clamped to MaxFiniteRetentionDays first. This is
|
// The day count is clamped to MaxFiniteRetentionDays first. This is
|
||||||
// defense in depth rather than decoration: a time.Duration is an int64
|
// defense in depth rather than decoration: a time.Duration is an int64
|
||||||
@@ -254,7 +253,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
// row in the database: the sweep then deletes every event, delivery,
|
// row in the database: the sweep then deletes every event, delivery,
|
||||||
// and delivery result, including ones created seconds ago. Rejecting
|
// and delivery result, including ones created seconds ago. Rejecting
|
||||||
// out-of-range input at the form is the primary guard; saturating here
|
// out-of-range input at the form is the primary guard; saturating here
|
||||||
// means a value that reaches the row by any other path cannot turn a
|
// means an old row, a migration, or a future call site cannot turn a
|
||||||
// too-large retention into total data loss.
|
// too-large retention into total data loss.
|
||||||
func retentionCutoff(
|
func retentionCutoff(
|
||||||
now time.Time,
|
now time.Time,
|
||||||
|
|||||||
@@ -85,6 +85,41 @@ func createWebhook(
|
|||||||
db.Omit(clause.Associations).Create(wh).Error,
|
db.Omit(clause.Associations).Create(wh).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Webhook.BeforeSave rewrites a non-positive RetentionDays to the
|
||||||
|
// retain-forever sentinel, and the column's GORM default would
|
||||||
|
// otherwise substitute 30. Force the requested value with a
|
||||||
|
// column-level update so tests can plant legacy rows that predate
|
||||||
|
// the sentinel and still carry a literal 0 or negative value.
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Model(wh).
|
||||||
|
Update("retention_days", retentionDays).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wh.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhookNormally inserts a webhook through the ordinary Create
|
||||||
|
// path, with no column-level forcing, so Webhook.BeforeSave applies
|
||||||
|
// exactly as it does in production. Passing 0 therefore yields a row
|
||||||
|
// holding the RetentionForeverDays sentinel.
|
||||||
|
func createWebhookNormally(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
retentionDays int,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Name: testWebhookName,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
return wh.ID
|
return wh.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,19 +281,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
|||||||
assertChainPresent(t, db, recent)
|
assertChainPresent(t, db, recent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the
|
// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
|
||||||
// end-to-end retain-forever path: a webhook created with a retention
|
// end-to-end retain-forever path: a webhook created the normal way with
|
||||||
// of 0 reads back 0, and the reaper leaves its ancient events alone
|
// a requested retention of 0 lands on the RetentionForeverDays
|
||||||
// while still reaping a finite-retention webhook in the very same
|
// sentinel, and the reaper leaves its ancient events alone while still
|
||||||
// sweep.
|
// reaping a finite-retention webhook in the very same sweep.
|
||||||
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
|
func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
foreverID := createWebhook(t, env.mainDB.DB(), 0)
|
foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
|
||||||
|
|
||||||
var stored database.Webhook
|
var stored database.Webhook
|
||||||
|
|
||||||
@@ -267,12 +302,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
|
|||||||
env.mainDB.DB().Where("id = ?", foreverID).
|
env.mainDB.DB().Where("id = ?", foreverID).
|
||||||
First(&stored).Error,
|
First(&stored).Error,
|
||||||
)
|
)
|
||||||
require.Zero(
|
require.Equal(
|
||||||
t, stored.RetentionDays,
|
t,
|
||||||
"a retention of 0 must be stored as 0",
|
database.RetentionForeverDays,
|
||||||
|
stored.RetentionDays,
|
||||||
|
"a requested retention of 0 must persist as the sentinel",
|
||||||
)
|
)
|
||||||
|
|
||||||
finiteID := createWebhook(t, env.mainDB.DB(), 30)
|
finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
foreverDB, err := env.mgr.GetDB(foreverID)
|
foreverDB, err := env.mgr.GetDB(foreverID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -298,10 +335,14 @@ func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
|
|||||||
// negative, so subtracting it moves the cutoff into the far future,
|
// negative, so subtracting it moves the cutoff into the far future,
|
||||||
// where "created_at < cutoff" matches every row: an event created a
|
// where "created_at < cutoff" matches every row: an event created a
|
||||||
// moment ago, and its delivery and delivery result, were all deleted
|
// moment ago, and its delivery and delivery result, were all deleted
|
||||||
// on the first sweep. 200000 is inside that band.
|
// on the first sweep. 200000 is inside that band and below the
|
||||||
|
// retain-forever sentinel, so it is treated as a finite policy and
|
||||||
|
// really does reach the arithmetic.
|
||||||
//
|
//
|
||||||
// The form refuses such a value; the point of the test is that a row
|
// The row is planted at the column level because such a value can no
|
||||||
// written by any other path still cannot trigger the wipe.
|
// longer be submitted through the form; the point of the test is that
|
||||||
|
// a row from an older version, or a future call site, still cannot
|
||||||
|
// trigger the wipe.
|
||||||
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -317,6 +358,12 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|||||||
database.MaxFiniteRetentionDays,
|
database.MaxFiniteRetentionDays,
|
||||||
"the test value must exceed what the cutoff can represent",
|
"the test value must exceed what the cutoff can represent",
|
||||||
)
|
)
|
||||||
|
require.Less(
|
||||||
|
t,
|
||||||
|
overflowingRetentionDays,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
"the test value must not be treated as retain-forever",
|
||||||
|
)
|
||||||
|
|
||||||
webhookID := createWebhook(
|
webhookID := createWebhook(
|
||||||
t, env.mainDB.DB(), overflowingRetentionDays,
|
t, env.mainDB.DB(), overflowingRetentionDays,
|
||||||
@@ -332,15 +379,14 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|||||||
assertChainPresent(t, db, fresh)
|
assertChainPresent(t, db, fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative
|
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
|
||||||
// retention, which the forms refuse, keeps every event rather than
|
|
||||||
// putting the cutoff in the future, where it would match them all.
|
|
||||||
func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
webhookID := createWebhook(t, env.mainDB.DB(), -1)
|
// A legacy row written before the sentinel existed still carries a
|
||||||
|
// literal 0; the <= 0 guard must keep honouring it.
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 0)
|
||||||
|
|
||||||
db, err := env.mgr.GetDB(webhookID)
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// withFixedNow returns db with GORM's clock stopped at a time in UTC,
|
|
||||||
// so that the created and updated times it writes come back from
|
|
||||||
// SQLite equal to the row written, which holds them in UTC.
|
|
||||||
func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) {
|
|
||||||
at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC)
|
|
||||||
|
|
||||||
return db.Session(&gorm.Session{
|
|
||||||
NowFunc: func() time.Time { return at },
|
|
||||||
}), at
|
|
||||||
}
|
|
||||||
|
|
||||||
// newBaseModel returns a BaseModel with a new ID.
|
|
||||||
func newBaseModel() database.BaseModel {
|
|
||||||
return database.BaseModel{ID: uuid.New().String()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRoundTrip writes row, which must be the only row of its
|
|
||||||
// table, and reads back the same row.
|
|
||||||
func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, db.Create(row).Error)
|
|
||||||
|
|
||||||
var got T
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&got).Error)
|
|
||||||
assert.Equal(t, *row, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMainSchemaHoldsEveryModel writes a row of each main database
|
|
||||||
// model, every field set, into a database made from its SQL files.
|
|
||||||
func TestMainSchemaHoldsEveryModel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mainDB, err := database.Open(
|
|
||||||
t.TempDir(), slog.New(slog.DiscardHandler),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = mainDB.Close() })
|
|
||||||
|
|
||||||
db, at := withFixedNow(mainDB.DB())
|
|
||||||
|
|
||||||
assertRoundTrip(t, db, &database.Setting{
|
|
||||||
Key: "session_key", Value: "value",
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.User{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
Username: "user",
|
|
||||||
Password: "password hash",
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.APIKey{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
UserID: uuid.New().String(),
|
|
||||||
Key: "key",
|
|
||||||
Description: "API key description",
|
|
||||||
LastUsedAt: &at,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.Webhook{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
UserID: uuid.New().String(),
|
|
||||||
Name: "webhook",
|
|
||||||
Description: "webhook description",
|
|
||||||
RetentionDays: 7,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.Entrypoint{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
WebhookID: uuid.New().String(),
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
Description: "entrypoint description",
|
|
||||||
Active: true,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.Target{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
WebhookID: uuid.New().String(),
|
|
||||||
Name: "target",
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Active: true,
|
|
||||||
Config: `{"url":"https://example.com/"}`,
|
|
||||||
MaxAttempts: 3,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventSchemaHoldsEveryModel does the same for a webhook's event
|
|
||||||
// database, which the manager makes from its SQL files.
|
|
||||||
func TestEventSchemaHoldsEveryModel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = mgr.CloseAll() })
|
|
||||||
|
|
||||||
events, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
db, at := withFixedNow(events)
|
|
||||||
source := uuid.New().String()
|
|
||||||
|
|
||||||
assertRoundTrip(t, db, &database.Event{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
WebhookID: uuid.New().String(),
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
RawQuery: "a=1",
|
|
||||||
Headers: `{"X":"Y"}`,
|
|
||||||
Body: "body",
|
|
||||||
ContentType: "text/plain",
|
|
||||||
BodyBytes: 4,
|
|
||||||
ResubmittedFromID: &source,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.Delivery{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
EventID: uuid.New().String(),
|
|
||||||
TargetID: uuid.New().String(),
|
|
||||||
Status: database.DeliveryStatusDelivered,
|
|
||||||
FinishedAt: &at,
|
|
||||||
Replay: true,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.DeliveryResult{
|
|
||||||
BaseModel: newBaseModel(),
|
|
||||||
DeliveryID: uuid.New().String(),
|
|
||||||
AttemptNum: 2,
|
|
||||||
Success: true,
|
|
||||||
StatusCode: 200,
|
|
||||||
ResponseBody: "ok",
|
|
||||||
Error: "error",
|
|
||||||
Duration: 15,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.TargetTotals{
|
|
||||||
TargetID: uuid.New().String(),
|
|
||||||
Deliveries: 5,
|
|
||||||
Delivered: 3,
|
|
||||||
Failed: 1,
|
|
||||||
DeliveriesRemoved: 2,
|
|
||||||
FailedRemoved: 1,
|
|
||||||
})
|
|
||||||
assertRoundTrip(t, db, &database.EntrypointTotals{
|
|
||||||
EntrypointID: uuid.New().String(), LastEventAt: at,
|
|
||||||
})
|
|
||||||
|
|
||||||
// A new event database has its one row of event totals already.
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: 3, EventsRemoved: 1, LastEventAt: &at,
|
|
||||||
}))
|
|
||||||
|
|
||||||
var totals []database.EventTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&totals).Error)
|
|
||||||
assert.Equal(t, []database.EventTotals{{
|
|
||||||
ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at,
|
|
||||||
}}, totals)
|
|
||||||
}
|
|
||||||
@@ -118,8 +118,9 @@ const SQLiteFilePerm fs.FileMode = 0o600
|
|||||||
// SQLite still reports the absence rather than this function
|
// SQLite still reports the absence rather than this function
|
||||||
// materializing an empty database the caller asked not to create.
|
// materializing an empty database the caller asked not to create.
|
||||||
//
|
//
|
||||||
// The mode is set on every open, whoever created the file, so a
|
// Chmod of a file that already exists is what tightens a data
|
||||||
// database or sidecar found at a wider mode is tightened too.
|
// directory an earlier build left at 0644 — including a developer's
|
||||||
|
// own scratch directory — without any migration machinery.
|
||||||
func reserveSQLiteFile(path string, create bool) error {
|
func reserveSQLiteFile(path string, create bool) error {
|
||||||
if create {
|
if create {
|
||||||
// gosec G304: the path is the database file the caller asked
|
// gosec G304: the path is the database file the caller asked
|
||||||
@@ -241,19 +242,7 @@ func SQLiteDSN(path, mode string) string {
|
|||||||
)
|
)
|
||||||
q.Add("_pragma", "journal_mode(WAL)")
|
q.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
|
||||||
// SQLite reads a file: URI's path only up to the first ? or #, and
|
return "file:" + path + "?" + q.Encode()
|
||||||
// decodes %XX escapes in it, so url.URL escapes the path. OmitHost
|
|
||||||
// keeps a relative path as "file:data/x.db"; written
|
|
||||||
// "file://data/x.db", SQLite would reject "data" as the URI's
|
|
||||||
// authority.
|
|
||||||
dsn := url.URL{
|
|
||||||
Scheme: "file",
|
|
||||||
Path: path,
|
|
||||||
OmitHost: true,
|
|
||||||
RawQuery: q.Encode(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return dsn.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSQLite opens the SQLite file at path with the service's
|
// OpenSQLite opens the SQLite file at path with the service's
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package database_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -114,79 +112,6 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
|
|||||||
assert.Equal(t, "10000", busy)
|
assert.Equal(t, "10000", busy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name
|
|
||||||
// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or
|
|
||||||
// #, and decodes %41 to A, so an unescaped path sent every database in
|
|
||||||
// that directory to one file outside it.
|
|
||||||
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
|
|
||||||
require.NoError(t, os.Mkdir(dir, 0o700))
|
|
||||||
|
|
||||||
for _, name := range []string{
|
|
||||||
database.MainDBFileName,
|
|
||||||
"events-" + uuid.New().String() + ".db",
|
|
||||||
} {
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
|
|
||||||
sqlDB, err := database.OpenSQLite(
|
|
||||||
path, database.SQLiteModeCreate,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
|
|
||||||
|
|
||||||
assertOpenedFile(t, sqlDB, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOpenSQLiteOpensARelativePath opens a database at a path relative
|
|
||||||
// to the working directory, which is what a relative DATA_DIR such as
|
|
||||||
// the README's DATA_DIR=../webhooker-data gives OpenSQLite.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
|
||||||
func TestOpenSQLiteOpensARelativePath(t *testing.T) {
|
|
||||||
t.Chdir(t.TempDir())
|
|
||||||
require.NoError(t, os.Mkdir("data", 0o700))
|
|
||||||
|
|
||||||
path := filepath.Join("data", database.MainDBFileName)
|
|
||||||
|
|
||||||
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, sqlDB.Close()) }()
|
|
||||||
|
|
||||||
assertOpenedFile(t, sqlDB, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertOpenedFile checks that sqlDB is the SQLite file at path.
|
|
||||||
// database_list names the file SQLite actually opened, as an absolute
|
|
||||||
// path with symlinks resolved, so the files are compared, not the names.
|
|
||||||
func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var (
|
|
||||||
seq int
|
|
||||||
schema, file string
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, sqlDB.
|
|
||||||
QueryRowContext(t.Context(), "pragma database_list").
|
|
||||||
Scan(&seq, &schema, &file))
|
|
||||||
|
|
||||||
asked, err := os.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
opened, err := os.Stat(file)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, os.SameFile(asked, opened),
|
|
||||||
"asked for %s, SQLite opened %s", path, file,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
||||||
// reproduction in
|
// reproduction in
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
|
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -371,7 +370,18 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = migrations.Apply(db, migrations.Events)
|
err = purgeTargetRows(db, m.log, webhookID)
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations for event-tier models only
|
||||||
|
err = db.AutoMigrate(
|
||||||
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
|
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
@@ -381,15 +391,14 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = parseModels(
|
// A new database gets its row of event totals, all zero. Target
|
||||||
db, &Event{}, &Delivery{}, &DeliveryResult{},
|
// totals rows are created by the first delivery to each target.
|
||||||
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
err = db.FirstOrCreate(&EventTotals{}).Error
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"connecting to webhook database %s: %w",
|
"creating event totals for webhook database %s: %w",
|
||||||
webhookID, err,
|
webhookID, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Migration 000: the table recording which migrations this file has
|
|
||||||
-- had. Applied before the others; it records itself.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- Migration 001: the schema of a database target's archive file. The
|
|
||||||
-- archivedEvent model in internal/delivery must agree with it.
|
|
||||||
|
|
||||||
CREATE TABLE archived_events (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
event_id text,
|
|
||||||
webhook_id text,
|
|
||||||
entrypoint_id text,
|
|
||||||
method text,
|
|
||||||
raw_query text,
|
|
||||||
headers text,
|
|
||||||
body text,
|
|
||||||
content_type text,
|
|
||||||
archived_at datetime
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_archived_events_event_id ON archived_events (event_id);
|
|
||||||
CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Migration 000: the table recording which migrations this file has
|
|
||||||
-- had. Applied before the others; it records itself.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
-- Migration 001: the schema of a webhook's event database. The models
|
|
||||||
-- in internal/database must agree with it. The webhook, entrypoint and
|
|
||||||
-- target ids here name rows in the main database, which has those
|
|
||||||
-- tables; this file does not.
|
|
||||||
|
|
||||||
CREATE TABLE events (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
webhook_id uuid NOT NULL,
|
|
||||||
entrypoint_id uuid NOT NULL,
|
|
||||||
method text NOT NULL,
|
|
||||||
raw_query text,
|
|
||||||
headers text,
|
|
||||||
body text,
|
|
||||||
content_type text,
|
|
||||||
body_bytes integer NOT NULL,
|
|
||||||
resubmitted_from_id uuid,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_events_created_at ON events (created_at);
|
|
||||||
CREATE INDEX idx_events_deleted_at ON events (deleted_at);
|
|
||||||
CREATE INDEX idx_events_deleted_at_created_at
|
|
||||||
ON events (deleted_at, created_at);
|
|
||||||
CREATE INDEX idx_events_resubmitted_from_id
|
|
||||||
ON events (resubmitted_from_id, deleted_at);
|
|
||||||
CREATE INDEX idx_events_entrypoint_id
|
|
||||||
ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at);
|
|
||||||
|
|
||||||
CREATE TABLE deliveries (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
event_id uuid NOT NULL,
|
|
||||||
target_id uuid NOT NULL,
|
|
||||||
status text NOT NULL DEFAULT 'pending',
|
|
||||||
finished_at datetime,
|
|
||||||
replay numeric NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_events_deliveries
|
|
||||||
FOREIGN KEY (event_id) REFERENCES events (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at);
|
|
||||||
CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at);
|
|
||||||
CREATE INDEX idx_deliveries_status
|
|
||||||
ON deliveries (status, deleted_at, finished_at, target_id);
|
|
||||||
|
|
||||||
CREATE TABLE delivery_results (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
delivery_id uuid NOT NULL,
|
|
||||||
attempt_num integer NOT NULL,
|
|
||||||
success numeric,
|
|
||||||
status_code integer,
|
|
||||||
response_body text,
|
|
||||||
error text,
|
|
||||||
duration integer,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_deliveries_delivery_results
|
|
||||||
FOREIGN KEY (delivery_id) REFERENCES deliveries (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_delivery_results_deleted_at
|
|
||||||
ON delivery_results (deleted_at);
|
|
||||||
CREATE INDEX idx_delivery_results_delivery_id
|
|
||||||
ON delivery_results (delivery_id, deleted_at);
|
|
||||||
|
|
||||||
CREATE TABLE event_totals (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
events integer NOT NULL,
|
|
||||||
events_removed integer NOT NULL,
|
|
||||||
last_event_at datetime
|
|
||||||
);
|
|
||||||
|
|
||||||
-- The one row of event totals, all zero.
|
|
||||||
INSERT INTO event_totals (events, events_removed) VALUES (0, 0);
|
|
||||||
|
|
||||||
CREATE TABLE target_totals (
|
|
||||||
target_id uuid,
|
|
||||||
deliveries integer NOT NULL,
|
|
||||||
delivered integer NOT NULL,
|
|
||||||
failed integer NOT NULL,
|
|
||||||
deliveries_removed integer NOT NULL,
|
|
||||||
failed_removed integer NOT NULL,
|
|
||||||
PRIMARY KEY (target_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE entrypoint_totals (
|
|
||||||
entrypoint_id uuid,
|
|
||||||
last_event_at datetime NOT NULL,
|
|
||||||
PRIMARY KEY (entrypoint_id)
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Migration 000: the table recording which migrations this file has
|
|
||||||
-- had. Applied before the others; it records itself.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
-- Migration 001: the main database's schema. The models in
|
|
||||||
-- internal/database must agree with it.
|
|
||||||
|
|
||||||
CREATE TABLE settings (
|
|
||||||
"key" text,
|
|
||||||
value text NOT NULL,
|
|
||||||
PRIMARY KEY ("key")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
username text NOT NULL,
|
|
||||||
password text NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT chk_users_username
|
|
||||||
CHECK (length(CAST(username AS BLOB)) <= 1024)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
|
|
||||||
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
|
||||||
|
|
||||||
CREATE TABLE api_keys (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
"key" text NOT NULL,
|
|
||||||
description text,
|
|
||||||
last_used_at datetime,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at);
|
|
||||||
CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key");
|
|
||||||
|
|
||||||
CREATE TABLE webhooks (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
description text,
|
|
||||||
retention_days integer,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at);
|
|
||||||
|
|
||||||
CREATE TABLE entrypoints (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
webhook_id uuid NOT NULL,
|
|
||||||
path text NOT NULL,
|
|
||||||
description text,
|
|
||||||
active numeric DEFAULT true,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_webhooks_entrypoints
|
|
||||||
FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at);
|
|
||||||
CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path);
|
|
||||||
|
|
||||||
CREATE TABLE targets (
|
|
||||||
id uuid,
|
|
||||||
created_at datetime,
|
|
||||||
updated_at datetime,
|
|
||||||
deleted_at datetime,
|
|
||||||
webhook_id uuid NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
type text NOT NULL,
|
|
||||||
active numeric DEFAULT true,
|
|
||||||
config text,
|
|
||||||
max_attempts integer NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_webhooks_targets
|
|
||||||
FOREIGN KEY (webhook_id) REFERENCES webhooks (id),
|
|
||||||
CONSTRAINT chk_targets_max_attempts CHECK (max_attempts >= 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_targets_deleted_at ON targets (deleted_at);
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Package migrations holds the schema of each kind of SQLite file
|
|
||||||
// webhooker writes, as numbered SQL files embedded in the binary, and
|
|
||||||
// applies it when a file is opened.
|
|
||||||
//
|
|
||||||
// Each kind of file has its own directory. In each, 000_migration.sql
|
|
||||||
// creates only the schema_migrations table, and 001_schema.sql holds
|
|
||||||
// the whole schema. Before 1.0 a schema change is an edit to
|
|
||||||
// 001_schema.sql, never a new file.
|
|
||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The directories, one for each kind of database file.
|
|
||||||
const (
|
|
||||||
// Main is the main database, webhooker.db.
|
|
||||||
Main = "main"
|
|
||||||
|
|
||||||
// Events is a webhook's event database, events-{uuid}.db.
|
|
||||||
Events = "events"
|
|
||||||
|
|
||||||
// Archive is a database target's archive file.
|
|
||||||
Archive = "archive"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed main/*.sql events/*.sql archive/*.sql
|
|
||||||
var files embed.FS
|
|
||||||
|
|
||||||
// Apply runs each SQL file in dir, which is Main, Events or Archive,
|
|
||||||
// that the database has not had yet, in the order of the number its
|
|
||||||
// name starts with, and records that number in schema_migrations.
|
|
||||||
// 000_migration.sql creates that table and records itself, so it runs
|
|
||||||
// first, and only when the table is missing.
|
|
||||||
//
|
|
||||||
// It all runs in one transaction, so a failure leaves the file as it
|
|
||||||
// was.
|
|
||||||
func Apply(db *gorm.DB, dir string) error {
|
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var tables int
|
|
||||||
|
|
||||||
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
|
|
||||||
err := tx.Raw(
|
|
||||||
"SELECT count(*) FROM sqlite_master "+
|
|
||||||
"WHERE type = 'table' AND name = ?",
|
|
||||||
"schema_migrations",
|
|
||||||
).Row().Scan(&tables)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("looking for schema_migrations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tables == 0 {
|
|
||||||
err = run(tx, path.Join(dir, "000_migration.sql"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fs.Glob lists the files in name order, which is number order.
|
|
||||||
names, err := fs.Glob(files, path.Join(dir, "*.sql"))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("listing migrations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range names {
|
|
||||||
err = applyOnce(tx, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyOnce runs the migration file name and records its number,
|
|
||||||
// unless schema_migrations already has that number.
|
|
||||||
func applyOnce(tx *gorm.DB, name string) error {
|
|
||||||
number, _, _ := strings.Cut(path.Base(name), "_")
|
|
||||||
|
|
||||||
version, err := strconv.Atoi(number)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("numbering migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var applied int
|
|
||||||
|
|
||||||
err = tx.Raw(
|
|
||||||
"SELECT count(*) FROM schema_migrations WHERE version = ?",
|
|
||||||
version,
|
|
||||||
).Row().Scan(&applied)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checking migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if applied > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = run(tx, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Exec(
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)", version,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("recording migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// run executes every statement in the migration file name.
|
|
||||||
func run(tx *gorm.DB, name string) error {
|
|
||||||
script, err := files.ReadFile(name)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Exec(string(script)).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("applying migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package migrations_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestApplyRunsEachFileOnce applies each kind of database file's
|
|
||||||
// migrations to a new file twice. The first time records every file.
|
|
||||||
// The second finds them all recorded and runs none, which is the only
|
|
||||||
// way it can succeed: 001_schema.sql creates tables that then exist.
|
|
||||||
func TestApplyRunsEachFileOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, dir := range []string{
|
|
||||||
migrations.Main, migrations.Events, migrations.Archive,
|
|
||||||
} {
|
|
||||||
t.Run(dir, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
|
||||||
"sqlite", filepath.Join(t.TempDir(), "test.db"),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
db, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
|
||||||
&gorm.Config{
|
|
||||||
Logger: gormlog.New(slog.New(slog.DiscardHandler)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, migrations.Apply(db, dir))
|
|
||||||
require.NoError(t, migrations.Apply(db, dir))
|
|
||||||
|
|
||||||
var versions string
|
|
||||||
|
|
||||||
require.NoError(t, sqlDB.QueryRowContext(t.Context(),
|
|
||||||
"SELECT group_concat(version) FROM schema_migrations",
|
|
||||||
).Scan(&versions))
|
|
||||||
assert.Equal(t, "0,1", versions)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -201,7 +201,8 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
// sweepTarget prunes the archive of a single database target.
|
// sweepTarget prunes the archive of a single database target.
|
||||||
// A missing, empty, or "never" expiry parses as a zero duration
|
// A missing, empty, or "never" expiry parses as a zero duration
|
||||||
// and is skipped entirely, so those archives are never pruned.
|
// and is skipped entirely, so those archives keep exactly the
|
||||||
|
// behaviour they had before the sweep existed.
|
||||||
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||||
expiry, err := parseArchiveExpiry(target.Config)
|
expiry, err := parseArchiveExpiry(target.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
@@ -129,7 +128,6 @@ func (env *archiveEnv) addDatabaseTarget(
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: configJSON,
|
Config: configJSON,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
@@ -174,7 +172,9 @@ func seedArchiveFile(
|
|||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
|
require.NoError(
|
||||||
|
t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}),
|
||||||
|
)
|
||||||
|
|
||||||
for i, at := range archivedAt {
|
for i, at := range archivedAt {
|
||||||
row := delivery.ExportArchivedEvent{
|
row := delivery.ExportArchivedEvent{
|
||||||
@@ -781,9 +781,9 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// The spec is stronger than that — a "never" archive is skipped
|
// The spec is stronger than that — a "never" archive is skipped
|
||||||
// before any file is touched — so the archive here exists but has
|
// before any file is touched — so the archive here exists but has
|
||||||
// never been migrated. Opening it at all would apply the archive
|
// never been migrated. Opening it at all would run AutoMigrate
|
||||||
// schema and create the archive table, which is exactly what must
|
// and create the archive table, which is exactly what must not
|
||||||
// not happen.
|
// happen.
|
||||||
func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -807,7 +807,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
|||||||
|
|
||||||
// seedUnmigratedArchive creates an archive file that exists but
|
// seedUnmigratedArchive creates an archive file that exists but
|
||||||
// carries no archive schema, so any open of it is observable: the
|
// carries no archive schema, so any open of it is observable: the
|
||||||
// archive table appears only if something applied the schema.
|
// archive table appears only if something ran AutoMigrate.
|
||||||
func seedUnmigratedArchive(t *testing.T, path string) {
|
func seedUnmigratedArchive(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ func targetTotals(
|
|||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||||
// write records for the webhook page's statistics: the time a delivery
|
// write records for the webhook page's statistics: the time a delivery
|
||||||
// finished, set only when it becomes delivered or failed and stored in
|
// finished, set only when it becomes delivered or failed, and one more
|
||||||
// the same form as created_at, and one more on its target's delivered
|
// on its target's delivered or failed total.
|
||||||
// or failed total.
|
|
||||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -76,16 +75,6 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
|||||||
if tt.finished {
|
if tt.finished {
|
||||||
require.NotNil(t, stored.FinishedAt)
|
require.NotNil(t, stored.FinishedAt)
|
||||||
assert.False(t, stored.FinishedAt.Before(before))
|
assert.False(t, stored.FinishedAt.Before(before))
|
||||||
|
|
||||||
var createdAt, finishedAt string
|
|
||||||
|
|
||||||
require.NoError(t, db.Raw(
|
|
||||||
`SELECT CAST(created_at AS TEXT),
|
|
||||||
CAST(finished_at AS TEXT)
|
|
||||||
FROM deliveries WHERE id = ?`, d.ID,
|
|
||||||
).Row().Scan(&createdAt, &finishedAt))
|
|
||||||
assertStoredTime(t, createdAt)
|
|
||||||
assertStoredTime(t, finishedAt)
|
|
||||||
} else {
|
} else {
|
||||||
assert.Nil(t, stored.FinishedAt)
|
assert.Nil(t, stored.FinishedAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ type Task struct {
|
|||||||
TargetName string
|
TargetName string
|
||||||
TargetType database.TargetType
|
TargetType database.TargetType
|
||||||
TargetConfig string
|
TargetConfig string
|
||||||
MaxAttempts int
|
MaxRetries int
|
||||||
|
|
||||||
Method string
|
Method string
|
||||||
RawQuery string
|
RawQuery string
|
||||||
@@ -1702,10 +1702,7 @@ func writeDeliveryStatus(
|
|||||||
}).
|
}).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
// GORM's clock, the one that stamps created_at. A bare
|
"finished_at": time.Now(),
|
||||||
// time.Now() carries a monotonic clock reading, which the
|
|
||||||
// driver would store as an " m=+…" suffix.
|
|
||||||
"finished_at": tx.NowFunc(),
|
|
||||||
})
|
})
|
||||||
if res.Error != nil || res.RowsAffected == 0 {
|
if res.Error != nil || res.RowsAffected == 0 {
|
||||||
return res.Error
|
return res.Error
|
||||||
@@ -1772,7 +1769,7 @@ func buildTargetFromTask(task *Task) database.Target {
|
|||||||
Name: task.TargetName,
|
Name: task.TargetName,
|
||||||
Type: task.TargetType,
|
Type: task.TargetType,
|
||||||
Config: task.TargetConfig,
|
Config: task.TargetConfig,
|
||||||
MaxAttempts: task.MaxAttempts,
|
MaxRetries: task.MaxRetries,
|
||||||
}
|
}
|
||||||
|
|
||||||
target.ID = task.TargetID
|
target.ID = task.TargetID
|
||||||
@@ -1909,13 +1906,12 @@ func (e *Engine) takeForRedispatch(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GORM's clock, as writeDeliveryStatus explains.
|
|
||||||
res := webhookDB.
|
res := webhookDB.
|
||||||
Model(&database.Delivery{}).
|
Model(&database.Delivery{}).
|
||||||
Where(
|
Where(
|
||||||
"id = ? AND status = ?", deliveryID, eligible,
|
"id = ? AND status = ?", deliveryID, eligible,
|
||||||
).
|
).
|
||||||
UpdateColumn("updated_at", webhookDB.NowFunc())
|
UpdateColumn("updated_at", time.Now())
|
||||||
|
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
e.log.Error(
|
e.log.Error(
|
||||||
@@ -2028,7 +2024,7 @@ func (e *Engine) countAttemptsBatch(
|
|||||||
// One delivery id per recorded attempt, tallied here rather than
|
// One delivery id per recorded attempt, tallied here rather than
|
||||||
// grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which
|
// grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which
|
||||||
// a GROUP BY into a struct would need, and an attempt row per
|
// a GROUP BY into a struct would need, and an attempt row per
|
||||||
// delivery is bounded by the target's MaxAttempts.
|
// delivery is bounded by the target's MaxRetries.
|
||||||
var attemptIDs []string
|
var attemptIDs []string
|
||||||
|
|
||||||
err := webhookDB.
|
err := webhookDB.
|
||||||
@@ -2106,7 +2102,7 @@ func buildRecoveryTask(
|
|||||||
TargetName: target.Name,
|
TargetName: target.Name,
|
||||||
TargetType: target.Type,
|
TargetType: target.Type,
|
||||||
TargetConfig: target.Config,
|
TargetConfig: target.Config,
|
||||||
MaxAttempts: target.MaxAttempts,
|
MaxRetries: target.MaxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
RawQuery: event.RawQuery,
|
RawQuery: event.RawQuery,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func iTask(
|
|||||||
d database.Delivery,
|
d database.Delivery,
|
||||||
event database.Event,
|
event database.Event,
|
||||||
webhookID, targetID, name, config string,
|
webhookID, targetID, name, config string,
|
||||||
maxAttempts, attemptNum int,
|
maxRetries, attemptNum int,
|
||||||
body *string,
|
body *string,
|
||||||
) delivery.Task {
|
) delivery.Task {
|
||||||
return delivery.Task{
|
return delivery.Task{
|
||||||
@@ -181,7 +181,7 @@ func iTask(
|
|||||||
TargetName: name,
|
TargetName: name,
|
||||||
TargetType: database.TargetTypeHTTP,
|
TargetType: database.TargetTypeHTTP,
|
||||||
TargetConfig: config,
|
TargetConfig: config,
|
||||||
MaxAttempts: maxAttempts,
|
MaxRetries: maxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
ContentType: event.ContentType,
|
ContentType: event.ContentType,
|
||||||
@@ -242,7 +242,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
|
|||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"test-target", cfg, 1, 1, &bodyStr,
|
"test-target", cfg, 0, 1, &bodyStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(
|
s.Engine.ExportProcessNewTask(
|
||||||
@@ -293,7 +293,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
|
|||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"test-large", cfg, 1, 1, nil,
|
"test-large", cfg, 0, 1, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(
|
s.Engine.ExportProcessNewTask(
|
||||||
@@ -320,7 +320,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
|
|||||||
TargetName: "test",
|
TargetName: "test",
|
||||||
TargetType: database.TargetTypeHTTP,
|
TargetType: database.TargetTypeHTTP,
|
||||||
TargetConfig: iHTTPConfig("http://localhost:9999"),
|
TargetConfig: iHTTPConfig("http://localhost:9999"),
|
||||||
MaxAttempts: 1,
|
MaxRetries: 0,
|
||||||
Body: nil,
|
Body: nil,
|
||||||
AttemptNum: 1,
|
AttemptNum: 1,
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
|
|||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"lifecycle-test", "",
|
"lifecycle-test", "",
|
||||||
1, 1, &bodyStr,
|
0, 1, &bodyStr,
|
||||||
)
|
)
|
||||||
task.TargetType = database.TargetTypeLog
|
task.TargetType = database.TargetTypeLog
|
||||||
|
|
||||||
@@ -645,7 +645,7 @@ func TestRecoverPendingDeliveries(t *testing.T) {
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, "recovery-target",
|
s.WebhookID, "recovery-target",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
iSeedPendingDeliveries(
|
iSeedPendingDeliveries(
|
||||||
@@ -686,7 +686,7 @@ func iCreateTarget(
|
|||||||
targetID, webhookID, name string,
|
targetID, webhookID, name string,
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
config string,
|
config string,
|
||||||
maxAttempts int,
|
maxRetries int,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -696,7 +696,7 @@ func iCreateTarget(
|
|||||||
Type: targetType,
|
Type: targetType,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: config,
|
Config: config,
|
||||||
MaxAttempts: maxAttempts,
|
MaxRetries: maxRetries,
|
||||||
}
|
}
|
||||||
target.ID = targetID
|
target.ID = targetID
|
||||||
|
|
||||||
@@ -963,12 +963,29 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
|
|||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The per-webhook database has no targets table, so writing a
|
// The table exists in the per-webhook database because GORM
|
||||||
// target row there would have failed the status write above.
|
// migrates the Delivery relation's model alongside it. It must
|
||||||
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
|
// stay empty.
|
||||||
|
var targetRows int64
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Table("targets").
|
||||||
|
Count(&targetRows).Error)
|
||||||
|
|
||||||
|
assert.Zero(t, targetRows,
|
||||||
"orphaned-retry terminal failure wrote a target row "+
|
"orphaned-retry terminal failure wrote a target row "+
|
||||||
"into the per-webhook event database",
|
"into the per-webhook event database",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var configs []string
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Table("targets").
|
||||||
|
Pluck("config", &configs).Error)
|
||||||
|
|
||||||
|
assert.NotContains(
|
||||||
|
t, strings.Join(configs, " "), hookURL,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverSingleRetry_UnknownTargetType(
|
func TestRecoverSingleRetry_UnknownTargetType(
|
||||||
@@ -1077,7 +1094,7 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, "recover-target",
|
s.WebhookID, "recover-target",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
@@ -1232,7 +1249,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
|||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"auth-target", string(cfgJSON),
|
"auth-target", string(cfgJSON),
|
||||||
1, 1, &bodyStr,
|
0, 1, &bodyStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(
|
s.Engine.ExportProcessNewTask(
|
||||||
@@ -1277,7 +1294,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
|
|||||||
|
|
||||||
task, d := iHTTPTaskAndDelivery(
|
task, d := iHTTPTaskAndDelivery(
|
||||||
event, del, "timeout-target",
|
event, del, "timeout-target",
|
||||||
string(cfgJSON), 1, 1,
|
string(cfgJSON), 0, 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
||||||
@@ -1339,7 +1356,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
|
|||||||
bodyStr := event.Body
|
bodyStr := event.Body
|
||||||
task := iTask(
|
task := iTask(
|
||||||
del, event, s.WebhookID, targetID,
|
del, event, s.WebhookID, targetID,
|
||||||
"cut-target", cfgJSON, 1, 1, &bodyStr,
|
"cut-target", cfgJSON, 0, 1, &bodyStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||||
@@ -1403,7 +1420,7 @@ func iHTTPTaskAndDelivery(
|
|||||||
event database.Event,
|
event database.Event,
|
||||||
del database.Delivery,
|
del database.Delivery,
|
||||||
name, config string,
|
name, config string,
|
||||||
maxAttempts, attemptNum int,
|
maxRetries, attemptNum int,
|
||||||
) (*delivery.Task, *database.Delivery) {
|
) (*delivery.Task, *database.Delivery) {
|
||||||
task := &delivery.Task{
|
task := &delivery.Task{
|
||||||
DeliveryID: del.ID,
|
DeliveryID: del.ID,
|
||||||
@@ -1413,7 +1430,7 @@ func iHTTPTaskAndDelivery(
|
|||||||
TargetName: name,
|
TargetName: name,
|
||||||
TargetType: database.TargetTypeHTTP,
|
TargetType: database.TargetTypeHTTP,
|
||||||
TargetConfig: config,
|
TargetConfig: config,
|
||||||
MaxAttempts: maxAttempts,
|
MaxRetries: maxRetries,
|
||||||
AttemptNum: attemptNum,
|
AttemptNum: attemptNum,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,7 +1443,6 @@ func iHTTPTaskAndDelivery(
|
|||||||
Name: name,
|
Name: name,
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: config,
|
Config: config,
|
||||||
MaxAttempts: maxAttempts,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = del.ID
|
d.ID = del.ID
|
||||||
@@ -1467,7 +1483,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
task, d := iHTTPTaskAndDelivery(
|
task, d := iHTTPTaskAndDelivery(
|
||||||
event, del, "bad-config", `not-json`, 1, 1,
|
event, del, "bad-config", `not-json`, 0, 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
||||||
@@ -1491,7 +1507,7 @@ func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
task, d := iHTTPTaskAndDelivery(
|
task, d := iHTTPTaskAndDelivery(
|
||||||
event, del, "bad-config", `not-json`, 1, 1,
|
event, del, "bad-config", `not-json`, 0, 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, db.Exec("drop table delivery_results").Error)
|
require.NoError(t, db.Exec("drop table delivery_results").Error)
|
||||||
|
|||||||
@@ -2,16 +2,11 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -149,7 +144,7 @@ func seedLogTask(
|
|||||||
bodyStr := event.Body
|
bodyStr := event.Body
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"hook-context-test", "", 1, 1, &bodyStr,
|
"hook-context-test", "", 0, 1, &bodyStr,
|
||||||
)
|
)
|
||||||
task.TargetType = database.TargetTypeLog
|
task.TargetType = database.TargetTypeLog
|
||||||
|
|
||||||
@@ -277,154 +272,6 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedHeldDelivery gives the setup's webhook an http target with
|
|
||||||
// maxAttempts, pointed at a receiver that keeps every request open until
|
|
||||||
// the client gives up on it, and seeds one delivery to that target at
|
|
||||||
// status. It returns the task that sends attempt attemptNum of the
|
|
||||||
// delivery, and a channel that is closed when a request reaches the
|
|
||||||
// receiver.
|
|
||||||
func seedHeldDelivery(
|
|
||||||
t *testing.T,
|
|
||||||
s iSetup,
|
|
||||||
maxAttempts int,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
attemptNum int,
|
|
||||||
) (delivery.Task, <-chan struct{}) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
arrived := make(chan struct{})
|
|
||||||
|
|
||||||
var once sync.Once
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
// The server notices the client hanging up only once the
|
|
||||||
// body has been read; until then r.Context() never ends.
|
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
|
||||||
|
|
||||||
once.Do(func() { close(arrived) })
|
|
||||||
|
|
||||||
<-r.Context().Done()
|
|
||||||
},
|
|
||||||
))
|
|
||||||
t.Cleanup(ts.Close)
|
|
||||||
|
|
||||||
cfg := iHTTPConfig(ts.URL)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
|
||||||
iCreateTarget(
|
|
||||||
t, s.MainDB, targetID, s.WebhookID, "held",
|
|
||||||
database.TargetTypeHTTP, cfg, maxAttempts,
|
|
||||||
)
|
|
||||||
|
|
||||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
|
|
||||||
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
|
|
||||||
|
|
||||||
bodyStr := event.Body
|
|
||||||
|
|
||||||
return iTask(
|
|
||||||
d, event, s.WebhookID, targetID, "held", cfg,
|
|
||||||
maxAttempts, attemptNum, &bodyStr,
|
|
||||||
), arrived
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopDuringAttempt waits until the receiver holds an attempt open, then
|
|
||||||
// stops the engine through its real stop hook.
|
|
||||||
func stopDuringAttempt(
|
|
||||||
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-arrived:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("the delivery attempt never reached the receiver")
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireRequeued runs restart recovery on the stopped engine and
|
|
||||||
// requires it to queue task's attempt again on ch.
|
|
||||||
func requireRequeued(
|
|
||||||
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
s.Engine.ExportRecoverWebhookDeliveries(
|
|
||||||
context.Background(), s.WebhookID,
|
|
||||||
)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-ch:
|
|
||||||
assert.Equal(t, task.DeliveryID, got.DeliveryID)
|
|
||||||
assert.Equal(t, task.AttemptNum, got.AttemptNum)
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("restart recovery did not queue the delivery again")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
|
|
||||||
// test for a stop that turned an attempt in flight into a terminal
|
|
||||||
// failure. Stopping the engine cancels the request; for a target with
|
|
||||||
// no retries that was the delivery's only attempt, recorded as failed
|
|
||||||
// with "context canceled", and nothing ever sent it again. The
|
|
||||||
// delivery must instead stay pending, with no result and no count in
|
|
||||||
// the attempt metrics, for restart recovery to send.
|
|
||||||
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
reg := mIsolate(t, s)
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
|
||||||
|
|
||||||
task, arrived := seedHeldDelivery(
|
|
||||||
t, s, 1, database.DeliveryStatusPending, 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
s.Engine.Notify([]delivery.Task{task})
|
|
||||||
stopDuringAttempt(t, lc, arrived)
|
|
||||||
|
|
||||||
iAssertStatus(
|
|
||||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
|
|
||||||
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
|
|
||||||
assert.Zero(t, mHTTPDurations(t, reg))
|
|
||||||
|
|
||||||
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
|
|
||||||
// for a target with retries, on its last attempt: the cancelled attempt
|
|
||||||
// used to exhaust the retries and end the delivery failed. It must stay
|
|
||||||
// retrying with only its earlier attempt recorded, and restart recovery
|
|
||||||
// must send that last attempt again.
|
|
||||||
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const maxAttempts = 2
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
lc := startEngineViaHook(t, s.Engine)
|
|
||||||
|
|
||||||
task, arrived := seedHeldDelivery(
|
|
||||||
t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts,
|
|
||||||
)
|
|
||||||
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
|
|
||||||
|
|
||||||
s.Engine.ExportScheduleRetry(task, 0)
|
|
||||||
stopDuringAttempt(t, lc, arrived)
|
|
||||||
|
|
||||||
iAssertStatus(
|
|
||||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
|
|
||||||
|
|
||||||
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverToArchive gives the setup's webhook a database target,
|
// deliverToArchive gives the setup's webhook a database target,
|
||||||
// runs one delivery to it through the running engine, and returns
|
// runs one delivery to it through the running engine, and returns
|
||||||
// the target's ID and archive file path. The archive writer holds
|
// the target's ID and archive file path. The archive writer holds
|
||||||
@@ -438,7 +285,6 @@ func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
|||||||
WebhookID: s.WebhookID,
|
WebhookID: s.WebhookID,
|
||||||
Name: "archive",
|
Name: "archive",
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -21,11 +22,12 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,18 +37,35 @@ const testContentType = "application/json"
|
|||||||
func testWebhookDB(t *testing.T) *gorm.DB {
|
func testWebhookDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Opened by the manager the service uses, so these tests cannot
|
dbPath := filepath.Join(
|
||||||
// pass against a schema, journal and locking settings or write
|
t.TempDir(), "events-test.db",
|
||||||
// guard production does not use.
|
|
||||||
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
|
||||||
t, t.TempDir(), slog.New(slog.DiscardHandler),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = mgr.CloseAll() })
|
// Opened the way the service opens a per-webhook database, so
|
||||||
|
// these tests cannot pass against journal and locking settings
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
// production does not use.
|
||||||
|
sqlDB, err := database.OpenSQLite(
|
||||||
|
dbPath, database.SQLiteModeCreate,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
db, err := gorm.Open(
|
||||||
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, db.AutoMigrate(
|
||||||
|
&database.Event{},
|
||||||
|
&database.Delivery{},
|
||||||
|
&database.DeliveryResult{},
|
||||||
|
&database.EventTotals{},
|
||||||
|
&database.TargetTotals{},
|
||||||
|
))
|
||||||
|
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,18 +136,6 @@ func seedDelivery(
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
|
|
||||||
// is in the form GORM stores created_at in: the layout of
|
|
||||||
// time.Time.String, with no " m=+…" monotonic clock reading after it.
|
|
||||||
func assertStoredTime(t *testing.T, text string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := time.Parse(
|
|
||||||
"2006-01-02 15:04:05.999999999 -0700 MST", text,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err, "stored as %q", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpDeliveryFixture holds the task and delivery needed
|
// httpDeliveryFixture holds the task and delivery needed
|
||||||
// for HTTP delivery tests.
|
// for HTTP delivery tests.
|
||||||
type httpDeliveryFixture struct {
|
type httpDeliveryFixture struct {
|
||||||
@@ -142,7 +149,7 @@ func buildHTTPFixture(
|
|||||||
dlv database.Delivery,
|
dlv database.Delivery,
|
||||||
event database.Event,
|
event database.Event,
|
||||||
targetID, name, config string,
|
targetID, name, config string,
|
||||||
maxAttempts, attemptNum int,
|
maxRetries, attemptNum int,
|
||||||
) httpDeliveryFixture {
|
) httpDeliveryFixture {
|
||||||
task := &delivery.Task{
|
task := &delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
@@ -152,7 +159,7 @@ func buildHTTPFixture(
|
|||||||
TargetName: name,
|
TargetName: name,
|
||||||
TargetType: database.TargetTypeHTTP,
|
TargetType: database.TargetTypeHTTP,
|
||||||
TargetConfig: config,
|
TargetConfig: config,
|
||||||
MaxAttempts: maxAttempts,
|
MaxRetries: maxRetries,
|
||||||
AttemptNum: attemptNum,
|
AttemptNum: attemptNum,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +172,7 @@ func buildHTTPFixture(
|
|||||||
Name: name,
|
Name: name,
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: config,
|
Config: config,
|
||||||
MaxAttempts: maxAttempts,
|
MaxRetries: maxRetries,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -277,7 +284,7 @@ func TestDeliverHTTP_Success(t *testing.T) {
|
|||||||
|
|
||||||
cfg := newHTTPTargetConfig(ts.URL)
|
cfg := newHTTPTargetConfig(ts.URL)
|
||||||
fix := buildHTTPFixture(
|
fix := buildHTTPFixture(
|
||||||
dlv, event, targetID, "test-http", cfg, 1, 1,
|
dlv, event, targetID, "test-http", cfg, 0, 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
e.ExportDeliverHTTP(
|
e.ExportDeliverHTTP(
|
||||||
@@ -324,7 +331,7 @@ func TestDeliverHTTP_Failure(t *testing.T) {
|
|||||||
cfg := newHTTPTargetConfig(ts.URL)
|
cfg := newHTTPTargetConfig(ts.URL)
|
||||||
fix := buildHTTPFixture(
|
fix := buildHTTPFixture(
|
||||||
dlv, event, targetID,
|
dlv, event, targetID,
|
||||||
"test-http-fail", cfg, 1, 1,
|
"test-http-fail", cfg, 0, 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
e.ExportDeliverHTTP(
|
e.ExportDeliverHTTP(
|
||||||
@@ -473,23 +480,14 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeliverHTTP_MaxAttemptsExhausted proves a target with three
|
func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
|
||||||
// delivery attempts makes at most three: the first two failures are
|
|
||||||
// retried, the third is final, and each attempt goes through the
|
|
||||||
// target's circuit breaker.
|
|
||||||
func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const maxAttempts = 3
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
|
|
||||||
var requests atomic.Int32
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
requests.Add(1)
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -498,103 +496,26 @@ func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
|
|||||||
|
|
||||||
e := testEngine(t, 1)
|
e := testEngine(t, 1)
|
||||||
targetID := uuid.New().String()
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
// It opens on the last failure only if every attempt reached it.
|
|
||||||
cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute)
|
|
||||||
e.ExportSetCircuitBreaker(targetID, cb)
|
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"retry":"exhaust"}`)
|
event := seedEvent(t, db, `{"retry":"exhaust"}`)
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, db, event.ID, targetID,
|
t, db, event.ID, targetID,
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusRetrying,
|
||||||
)
|
|
||||||
|
|
||||||
cfg := newHTTPTargetConfig(ts.URL)
|
|
||||||
sched := &recordingScheduler{}
|
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
||||||
fix := buildHTTPFixture(
|
|
||||||
dlv, event, targetID,
|
|
||||||
"test-http-exhaust", cfg, maxAttempts, attempt,
|
|
||||||
)
|
|
||||||
|
|
||||||
e.ExportDeliverHTTPWithScheduler(
|
|
||||||
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, int32(maxAttempts), requests.Load())
|
|
||||||
assert.Len(t, sched.delays, maxAttempts-1,
|
|
||||||
"every failure but the last should be retried",
|
|
||||||
)
|
|
||||||
assertDeliveryStatus(t, db, dlv.ID,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
)
|
|
||||||
assert.Equal(t, delivery.CircuitOpen, cb.State(),
|
|
||||||
"every attempt should be counted by the circuit breaker",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target
|
|
||||||
// with one delivery attempt makes exactly that attempt, even while
|
|
||||||
// its circuit breaker is open, and that a failure is final.
|
|
||||||
func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
|
|
||||||
var requests atomic.Int32
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
|
||||||
http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
requests.Add(1)
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
cb := e.ExportGetCircuitBreaker(targetID)
|
|
||||||
|
|
||||||
for range delivery.ExportDefaultFailureThreshold {
|
|
||||||
cb.RecordFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, delivery.CircuitOpen, cb.State())
|
|
||||||
|
|
||||||
event := seedEvent(t, db, `{"retry":"none"}`)
|
|
||||||
|
|
||||||
dlv := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg := newHTTPTargetConfig(ts.URL)
|
cfg := newHTTPTargetConfig(ts.URL)
|
||||||
fix := buildHTTPFixture(
|
fix := buildHTTPFixture(
|
||||||
dlv, event, targetID,
|
dlv, event, targetID,
|
||||||
"test-http-single", cfg, 1, 1,
|
"test-http-exhaust", cfg, 3, 3,
|
||||||
)
|
|
||||||
sched := &recordingScheduler{}
|
|
||||||
|
|
||||||
e.ExportDeliverHTTPWithScheduler(
|
|
||||||
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, int32(1), requests.Load(),
|
e.ExportDeliverHTTP(
|
||||||
"the open circuit breaker should not hold the attempt back",
|
context.TODO(), db, fix.Delivery, fix.Task,
|
||||||
)
|
)
|
||||||
assert.Empty(t, sched.delays, "no retry should be scheduled")
|
|
||||||
assertDeliveryStatus(t, db, dlv.ID,
|
assertDeliveryStatus(t, db, dlv.ID,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
assertDeliveryResult(
|
|
||||||
t, db, dlv.ID, false, http.StatusBadGateway,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeliverHTTP_SchedulesRetryOnFailure(
|
func TestDeliverHTTP_SchedulesRetryOnFailure(
|
||||||
@@ -816,7 +737,6 @@ func seedConcurrencyTasks(
|
|||||||
Name: fmt.Sprintf("task-%d", i),
|
Name: fmt.Sprintf("task-%d", i),
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: targetCfg,
|
Config: targetCfg,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tasks[i].ID = dlv.ID
|
tasks[i].ID = dlv.ID
|
||||||
@@ -828,7 +748,7 @@ func seedConcurrencyTasks(
|
|||||||
TargetName: tasks[i].Target.Name,
|
TargetName: tasks[i].Target.Name,
|
||||||
TargetType: tasks[i].Target.Type,
|
TargetType: tasks[i].Target.Type,
|
||||||
TargetConfig: tasks[i].Target.Config,
|
TargetConfig: tasks[i].Target.Config,
|
||||||
MaxAttempts: 1,
|
MaxRetries: 0,
|
||||||
AttemptNum: 1,
|
AttemptNum: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1778,7 +1698,6 @@ func buildSlackDelivery(
|
|||||||
Name: name,
|
Name: name,
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: slackCfg,
|
Config: slackCfg,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -1965,7 +1884,6 @@ func TestDeliverSlack_InvalidConfig(t *testing.T) {
|
|||||||
Name: "test-slack-bad",
|
Name: "test-slack-bad",
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"not_webhook_url":"missing"}`,
|
Config: `{"not_webhook_url":"missing"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -2024,7 +1942,6 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
|
|||||||
task := &delivery.Task{
|
task := &delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
TargetType: database.TargetTypeSlack,
|
TargetType: database.TargetTypeSlack,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ExportProcessDelivery(
|
e.ExportProcessDelivery(
|
||||||
@@ -2144,7 +2061,7 @@ func buildSlackRetryDelivery(
|
|||||||
Name: "test-slack-retry",
|
Name: "test-slack-retry",
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
MaxAttempts: 5,
|
MaxRetries: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
d.ID = dlv.ID
|
d.ID = dlv.ID
|
||||||
@@ -2182,7 +2099,7 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
|
|||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
TargetType: database.TargetTypeSlack,
|
TargetType: database.TargetTypeSlack,
|
||||||
MaxAttempts: 5,
|
MaxRetries: 5,
|
||||||
AttemptNum: 1,
|
AttemptNum: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
|
|||||||
)
|
)
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"leaky-target", cfg, 1, 1, &body,
|
"leaky-target", cfg, 0, 1, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ func tsSeedTarget(
|
|||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: config,
|
Config: config,
|
||||||
Active: true,
|
Active: true,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, mainDB.Create(&target).Error)
|
require.NoError(t, mainDB.Create(&target).Error)
|
||||||
@@ -177,7 +176,7 @@ func tsTask(
|
|||||||
TargetName: target.Name,
|
TargetName: target.Name,
|
||||||
TargetType: database.TargetTypeSlack,
|
TargetType: database.TargetTypeSlack,
|
||||||
TargetConfig: target.Config,
|
TargetConfig: target.Config,
|
||||||
MaxAttempts: 1,
|
MaxRetries: 0,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
ContentType: event.ContentType,
|
ContentType: event.ContentType,
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
task := &Task{
|
task := &Task{
|
||||||
DeliveryID: d.ID,
|
DeliveryID: d.ID,
|
||||||
TargetID: d.TargetID,
|
TargetID: d.TargetID,
|
||||||
MaxAttempts: d.Target.MaxAttempts,
|
|
||||||
AttemptNum: 1,
|
AttemptNum: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func fSweepSetup(
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, name,
|
s.WebhookID, name,
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, s.MainDB.Create(&database.Webhook{
|
require.NoError(t, s.MainDB.Create(&database.Webhook{
|
||||||
@@ -229,7 +229,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, "released",
|
s.WebhookID, "released",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
@@ -462,8 +462,7 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
|||||||
|
|
||||||
// TestRedispatchStampsTheRow pins the cadence control: a stranded
|
// TestRedispatchStampsTheRow pins the cadence control: a stranded
|
||||||
// delivery that has just been handed out is not selected again by the
|
// delivery that has just been handed out is not selected again by the
|
||||||
// next tick a minute later. The stamp is stored in the same form as
|
// next tick a minute later.
|
||||||
// created_at.
|
|
||||||
func TestRedispatchStampsTheRow(t *testing.T) {
|
func TestRedispatchStampsTheRow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -494,13 +493,4 @@ func TestRedispatchStampsTheRow(t *testing.T) {
|
|||||||
"a re-dispatched delivery must be stamped so the "+
|
"a re-dispatched delivery must be stamped so the "+
|
||||||
"next tick does not select it again",
|
"next tick does not select it again",
|
||||||
)
|
)
|
||||||
|
|
||||||
var createdAt, updatedAt string
|
|
||||||
|
|
||||||
require.NoError(t, s.WebhookDB.Raw(
|
|
||||||
`SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT)
|
|
||||||
FROM deliveries WHERE id = ?`, d.ID,
|
|
||||||
).Row().Scan(&createdAt, &updatedAt))
|
|
||||||
assertStoredTime(t, createdAt)
|
|
||||||
assertStoredTime(t, updatedAt)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,20 +291,20 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
|
|||||||
body := event.Body
|
body := event.Body
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
|
||||||
// An attempt budget above the failure threshold, so the breaker
|
// A retry budget above the failure threshold, so the breaker
|
||||||
// rather than the budget is what stops the delivery.
|
// rather than the budget is what stops the delivery.
|
||||||
maxAttempts := delivery.ExportDefaultFailureThreshold + 5
|
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||||
|
|
||||||
// The retries below are only run if their target still exists;
|
// The retries below are only run if their target still exists;
|
||||||
// see https://git.eeqj.de/sneak/webhooker/issues/107.
|
// see https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||||
iCreateTarget(
|
iCreateTarget(
|
||||||
t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
|
t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
|
||||||
database.TargetTypeHTTP, cfg, maxAttempts,
|
database.TargetTypeHTTP, cfg, maxRetries,
|
||||||
)
|
)
|
||||||
|
|
||||||
first := iTask(
|
first := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-trip", cfg, maxAttempts, 1, &body,
|
"metrics-trip", cfg, maxRetries, 1, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
||||||
@@ -316,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
|
|||||||
ExportDefaultFailureThreshold; attempt++ {
|
ExportDefaultFailureThreshold; attempt++ {
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-trip", cfg, maxAttempts, attempt, &body,
|
"metrics-trip", cfg, maxRetries, attempt, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessRetryTask(
|
s.Engine.ExportProcessRetryTask(
|
||||||
@@ -364,16 +364,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
|||||||
|
|
||||||
body := event.Body
|
body := event.Body
|
||||||
cfg := iHTTPConfig(ts.URL)
|
cfg := iHTTPConfig(ts.URL)
|
||||||
maxAttempts := delivery.ExportDefaultFailureThreshold + 5
|
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||||
|
|
||||||
iCreateTarget(
|
iCreateTarget(
|
||||||
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
|
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
|
||||||
database.TargetTypeHTTP, cfg, maxAttempts,
|
database.TargetTypeHTTP, cfg, maxRetries,
|
||||||
)
|
)
|
||||||
|
|
||||||
first := iTask(
|
first := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-blocked", cfg, maxAttempts, 1, &body,
|
"metrics-blocked", cfg, maxRetries, 1, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
||||||
@@ -382,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
|||||||
ExportDefaultFailureThreshold; attempt++ {
|
ExportDefaultFailureThreshold; attempt++ {
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-blocked", cfg, maxAttempts, attempt, &body,
|
"metrics-blocked", cfg, maxRetries, attempt, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessRetryTask(context.TODO(), &task)
|
s.Engine.ExportProcessRetryTask(context.TODO(), &task)
|
||||||
@@ -405,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
|||||||
|
|
||||||
blocked := iTask(
|
blocked := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"metrics-blocked", cfg, maxAttempts,
|
"metrics-blocked", cfg, maxRetries,
|
||||||
delivery.ExportDefaultFailureThreshold+1, &body,
|
delivery.ExportDefaultFailureThreshold+1, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, "already-delivered",
|
s.WebhookID, "already-delivered",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
@@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
|
|||||||
|
|
||||||
iCreateTarget(t, s.MainDB, targetID,
|
iCreateTarget(t, s.MainDB, targetID,
|
||||||
s.WebhookID, "numbering",
|
s.WebhookID, "numbering",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
@@ -357,14 +357,13 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
|
|||||||
Name: "unwritable",
|
Name: "unwritable",
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: iHTTPConfig(ts.URL),
|
Config: iHTTPConfig(ts.URL),
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
full.ID = d.ID
|
full.ID = d.ID
|
||||||
|
|
||||||
s.Engine.ExportDeliverHTTP(
|
s.Engine.ExportDeliverHTTP(
|
||||||
context.Background(), s.WebhookDB, full,
|
context.Background(), s.WebhookDB, full,
|
||||||
&delivery.Task{DeliveryID: d.ID, MaxAttempts: 1, AttemptNum: 1},
|
&delivery.Task{DeliveryID: d.ID, AttemptNum: 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -448,7 +447,7 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
|||||||
Name: "unwritable",
|
Name: "unwritable",
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: iHTTPConfig(ts.URL),
|
Config: iHTTPConfig(ts.URL),
|
||||||
MaxAttempts: 3,
|
MaxRetries: 3,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
full.ID = d.ID
|
full.ID = d.ID
|
||||||
@@ -458,8 +457,9 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
|||||||
s.Engine.ExportDeliverHTTPWithScheduler(
|
s.Engine.ExportDeliverHTTPWithScheduler(
|
||||||
context.Background(), s.WebhookDB, full,
|
context.Background(), s.WebhookDB, full,
|
||||||
&delivery.Task{
|
&delivery.Task{
|
||||||
DeliveryID: d.ID, TargetID: targetID,
|
DeliveryID: d.ID,
|
||||||
MaxAttempts: 3, AttemptNum: 1,
|
TargetID: targetID,
|
||||||
|
AttemptNum: 1,
|
||||||
},
|
},
|
||||||
sched,
|
sched,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func deliverWithProbeHeaders(
|
|||||||
body := event.Body
|
body := event.Body
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, targetID,
|
d, event, s.WebhookID, targetID,
|
||||||
"redirect-target", string(cfg), 1, 1, &body,
|
"redirect-target", string(cfg), 0, 1, &body,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ type Scheduler interface {
|
|||||||
// Engine.observeAttempt, alongside the DeliveryResult it records for
|
// Engine.observeAttempt, alongside the DeliveryResult it records for
|
||||||
// it. Deliver is also entered for attempts that never happen — an
|
// it. Deliver is also entered for attempts that never happen — an
|
||||||
// open circuit breaker refuses one — so the count cannot be taken
|
// open circuit breaker refuses one — so the count cannot be taken
|
||||||
// from around this call. An attempt cut off by a stop is neither
|
// from around this call.
|
||||||
// counted nor recorded.
|
|
||||||
type Target interface {
|
type Target interface {
|
||||||
Deliver(
|
Deliver(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func unavailableConfigFields() []ConfigField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// slackConfigFields describes a Slack target: its masked
|
// slackConfigFields describes a Slack target: its masked
|
||||||
// webhook URL and its number of delivery attempts. Only the
|
// webhook URL and its retry count. Only the masked URL is
|
||||||
// masked URL is shown; the full URL is the credential.
|
// shown; the full URL is the credential.
|
||||||
func slackConfigFields(t *database.Target) []ConfigField {
|
func slackConfigFields(t *database.Target) []ConfigField {
|
||||||
cfg, err := parseSlackConfig(t.Config)
|
cfg, err := parseSlackConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,7 +131,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
|
|||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Webhook URL",
|
Label: "Webhook URL",
|
||||||
Value: cfg.MaskedWebhookURL(),
|
Value: cfg.MaskedWebhookURL(),
|
||||||
}, maxAttemptsField(t)}
|
}, maxRetriesField(t)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpConfigFields describes an HTTP target: its destination
|
// httpConfigFields describes an HTTP target: its destination
|
||||||
@@ -178,18 +178,23 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fields = append(fields, maxAttemptsField(t))
|
fields = append(fields, maxRetriesField(t))
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxAttemptsField describes a target's number of delivery
|
// maxRetriesField describes a target's retry count, which lives
|
||||||
// attempts, which lives on the target row rather than in its
|
// on the target row rather than in its configuration blob. A
|
||||||
// configuration blob.
|
// stored 0 makes a single attempt, so it is shown as 1.
|
||||||
func maxAttemptsField(t *database.Target) ConfigField {
|
func maxRetriesField(t *database.Target) ConfigField {
|
||||||
|
attempts := strconv.Itoa(t.MaxRetries)
|
||||||
|
if t.MaxRetries == 0 {
|
||||||
|
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
||||||
|
}
|
||||||
|
|
||||||
return ConfigField{
|
return ConfigField{
|
||||||
Label: "Delivery attempts",
|
Label: "Delivery attempts",
|
||||||
Value: strconv.Itoa(t.MaxAttempts),
|
Value: attempts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
viewMaxAttempts = "Delivery attempts"
|
viewMaxRetries = "Delivery attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -171,7 +171,6 @@ func slackTarget() database.Target {
|
|||||||
Active: true,
|
Active: true,
|
||||||
Config: `{"webhookUrl":"` +
|
Config: `{"webhookUrl":"` +
|
||||||
slackWebhookURL + `"}`,
|
slackWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,20 +190,19 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Webhook URL": slackMaskedURL,
|
"Webhook URL": slackMaskedURL,
|
||||||
viewMaxAttempts: "1",
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_SlackAttempts proves a Slack target shows
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
||||||
// its number of delivery attempts the same way an HTTP target
|
// its retry count the same way an HTTP target does.
|
||||||
// does.
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
||||||
func TestNewTargetViews_SlackAttempts(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
target := slackTarget()
|
target := slackTarget()
|
||||||
target.MaxAttempts = 2
|
target.MaxRetries = 2
|
||||||
|
|
||||||
view := viewFor(t, target)
|
view := viewFor(t, target)
|
||||||
|
|
||||||
@@ -212,7 +210,7 @@ func TestNewTargetViews_SlackAttempts(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Webhook URL": slackMaskedURL,
|
"Webhook URL": slackMaskedURL,
|
||||||
viewMaxAttempts: "2",
|
viewMaxRetries: "2",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -227,7 +225,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
`"timeout":30,` +
|
`"timeout":30,` +
|
||||||
`"headers":{"Authorization":"Bearer sekrit"},` +
|
`"headers":{"Authorization":"Bearer sekrit"},` +
|
||||||
`"forwardQuery":true}`,
|
`"forwardQuery":true}`,
|
||||||
MaxAttempts: 5,
|
MaxRetries: 5,
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -239,7 +237,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Query string": "passed on to this target",
|
"Query string": "passed on to this target",
|
||||||
viewMaxAttempts: "5",
|
viewMaxRetries: "5",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
@@ -250,22 +248,19 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is
|
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
||||||
// shown as the plain number, like any other.
|
|
||||||
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
view := viewFor(t, database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + viewExampleHook + `"}`,
|
Config: `{"url":"` + viewExampleHook + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
viewMaxAttempts: "1",
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -281,7 +276,6 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
|||||||
view := viewFor(t, database.Target{
|
view := viewFor(t, database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + slackWebhookURL + `"}`,
|
Config: `{"url":"` + slackWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -406,21 +400,17 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|||||||
"unparseable json": {
|
"unparseable json": {
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: blob,
|
Config: blob,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
"empty slack config": {
|
"empty slack config": {
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
"slack config without url": {
|
"slack config without url": {
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{}`,
|
Config: `{}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
"unparseable http json": {
|
"unparseable http json": {
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":`,
|
Config: `{"url":`,
|
||||||
MaxAttempts: 1,
|
|
||||||
},
|
},
|
||||||
"unparseable archive json": {
|
"unparseable archive json": {
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -277,11 +276,7 @@ func (w *archiveWriter) write(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GORM's clock, the one that stamps created_at in the other
|
row.ArchivedAt = time.Now()
|
||||||
// databases, so this is stored in the same form. A bare time.Now()
|
|
||||||
// carries a monotonic clock reading, which the driver would store
|
|
||||||
// as an " m=+…" suffix.
|
|
||||||
row.ArchivedAt = w.db.NowFunc()
|
|
||||||
|
|
||||||
err := w.db.Create(&row).Error
|
err := w.db.Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -340,7 +335,7 @@ func (w *archiveWriter) openMode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = migrations.Apply(gdb, migrations.Archive)
|
err = gdb.AutoMigrate(&archivedEvent{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -150,13 +150,6 @@ func TestArchiveWriter_WritesRow(t *testing.T) {
|
|||||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||||
|
|
||||||
var archivedAt string
|
|
||||||
|
|
||||||
require.NoError(t, w.DB().Raw(
|
|
||||||
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
|
|
||||||
).Row().Scan(&archivedAt))
|
|
||||||
assertStoredTime(t, archivedAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool {
|
|||||||
// one pair silently overwriting the other.
|
// one pair silently overwriting the other.
|
||||||
//
|
//
|
||||||
// An input with no pairs yields an empty map, which omitempty drops
|
// An input with no pairs yields an empty map, which omitempty drops
|
||||||
// from the stored config, so a target configured with no headers
|
// from the stored config: a target configured with no headers keeps
|
||||||
// stores no headers key at all.
|
// the same config JSON it had before this field existed.
|
||||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
Config: `{"url":"https://example.com/h",` +
|
Config: `{"url":"https://example.com/h",` +
|
||||||
`"headers":{"Authorization":"Bearer abc"},` +
|
`"headers":{"Authorization":"Bearer abc"},` +
|
||||||
`"timeout":9}`,
|
`"timeout":9}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://example.com/h", form.URL)
|
assert.Equal(t, "https://example.com/h", form.URL)
|
||||||
@@ -199,7 +198,6 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"https://hooks.example/s"}`,
|
Config: `{"webhookUrl":"https://hooks.example/s"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://hooks.example/s", form.URL)
|
assert.Equal(t, "https://hooks.example/s", form.URL)
|
||||||
@@ -244,9 +242,9 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := []*database.Target{
|
cases := []*database.Target{
|
||||||
{Type: database.TargetTypeHTTP, Config: "not json", MaxAttempts: 1},
|
{Type: database.TargetTypeHTTP, Config: "not json"},
|
||||||
{Type: database.TargetTypeHTTP, Config: `{}`, MaxAttempts: 1},
|
{Type: database.TargetTypeHTTP, Config: `{}`},
|
||||||
{Type: database.TargetTypeSlack, Config: "", MaxAttempts: 1},
|
{Type: database.TargetTypeSlack, Config: ""},
|
||||||
{
|
{
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"soon"}`,
|
Config: `{"expiry":"soon"}`,
|
||||||
|
|||||||
@@ -53,72 +53,34 @@ type httpCore struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deliver runs one delivery attempt through the retry core.
|
// deliver runs one delivery attempt through the retry core.
|
||||||
// A maxAttempts of 1 is a single attempt, recorded without
|
// A maxRetries of 0 is fire-and-forget: a single attempt is
|
||||||
// consulting a circuit breaker. A higher maxAttempts gates the
|
// recorded and no circuit breaker is consulted. A positive
|
||||||
// attempt on the circuit breaker and schedules a backed-off retry
|
// maxRetries gates the attempt on the circuit breaker and
|
||||||
// on failure, until maxAttempts attempts have been made.
|
// schedules a backed-off retry on failure.
|
||||||
//
|
|
||||||
// ctx is the context attempt sends its request with. Stopping the
|
|
||||||
// engine cancels it; see interruptedByStop.
|
|
||||||
func (c *httpCore) deliver(
|
func (c *httpCore) deliver(
|
||||||
ctx context.Context,
|
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
task *Task,
|
task *Task,
|
||||||
sched Scheduler,
|
sched Scheduler,
|
||||||
maxAttempts int,
|
maxRetries int,
|
||||||
attempt func() attemptResult,
|
attempt func() attemptResult,
|
||||||
) {
|
) {
|
||||||
if maxAttempts == 1 {
|
if maxRetries == 0 {
|
||||||
c.fireAndForget(ctx, webhookDB, d, attempt())
|
c.fireAndForget(webhookDB, d, attempt())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.withRetry(
|
c.withRetry(
|
||||||
ctx, webhookDB, d, task, sched, maxAttempts, attempt,
|
webhookDB, d, task, sched, maxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// interruptedByStop reports whether an attempt failed because the
|
|
||||||
// engine is stopping, which cancels the context its request is made
|
|
||||||
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
|
|
||||||
// breaker outcome, no status change, no count in the attempt metrics.
|
|
||||||
// The delivery stays pending or retrying, and startup recovery sends
|
|
||||||
// it again on the next start.
|
|
||||||
//
|
|
||||||
// A target's own timeout leaves that context alone, so its attempt is
|
|
||||||
// still recorded as the failure it is.
|
|
||||||
func (c *httpCore) interruptedByStop(
|
|
||||||
ctx context.Context,
|
|
||||||
d *database.Delivery,
|
|
||||||
res attemptResult,
|
|
||||||
) bool {
|
|
||||||
if res.success || ctx.Err() == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
c.eng.log.Warn(
|
|
||||||
"delivery attempt interrupted by shutdown; not recorded, "+
|
|
||||||
"it is sent again on the next start",
|
|
||||||
"delivery_id", d.ID,
|
|
||||||
"event_id", d.EventID,
|
|
||||||
"target_id", d.TargetID,
|
|
||||||
)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *httpCore) fireAndForget(
|
func (c *httpCore) fireAndForget(
|
||||||
ctx context.Context,
|
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
res attemptResult,
|
res attemptResult,
|
||||||
) {
|
) {
|
||||||
if c.interruptedByStop(ctx, d, res) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
err := c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
@@ -148,12 +110,11 @@ func (c *httpCore) fireAndForget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *httpCore) withRetry(
|
func (c *httpCore) withRetry(
|
||||||
ctx context.Context,
|
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
task *Task,
|
task *Task,
|
||||||
sched Scheduler,
|
sched Scheduler,
|
||||||
maxAttempts int,
|
maxRetries int,
|
||||||
attempt func() attemptResult,
|
attempt func() attemptResult,
|
||||||
) {
|
) {
|
||||||
cb := c.getCircuitBreaker(task.TargetID)
|
cb := c.getCircuitBreaker(task.TargetID)
|
||||||
@@ -170,10 +131,6 @@ func (c *httpCore) withRetry(
|
|||||||
|
|
||||||
res := attempt()
|
res := attempt()
|
||||||
|
|
||||||
if c.interruptedByStop(ctx, d, res) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
err := c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
@@ -205,7 +162,7 @@ func (c *httpCore) withRetry(
|
|||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
|
|
||||||
c.handleRetry(
|
c.handleRetry(
|
||||||
webhookDB, d, task, sched, maxAttempts, attemptNum,
|
webhookDB, d, task, sched, maxRetries, attemptNum,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +223,10 @@ func (c *httpCore) handleRetry(
|
|||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
task *Task,
|
task *Task,
|
||||||
sched Scheduler,
|
sched Scheduler,
|
||||||
maxAttempts int,
|
maxRetries int,
|
||||||
attemptNum int,
|
attemptNum int,
|
||||||
) {
|
) {
|
||||||
if attemptNum >= maxAttempts {
|
if attemptNum >= maxRetries {
|
||||||
c.eng.settleStatus(
|
c.eng.settleStatus(
|
||||||
webhookDB, d, d.Target.Type,
|
webhookDB, d, d.Target.Type,
|
||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
@@ -438,8 +395,8 @@ func (t *httpTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.deliver(
|
t.deliver(
|
||||||
ctx, webhookDB, d, task, sched,
|
webhookDB, d, task, sched,
|
||||||
d.Target.MaxAttempts, attempt,
|
d.Target.MaxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
|
|||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
task := iTask(
|
task := iTask(
|
||||||
d, event, s.WebhookID, d.TargetID, "query", cfg, 1, 1, &event.Body,
|
d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body,
|
||||||
)
|
)
|
||||||
task.RawQuery = eventQuery
|
task.RawQuery = eventQuery
|
||||||
|
|
||||||
@@ -146,7 +146,6 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
|
|||||||
target := &database.Target{
|
target := &database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
|
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deliverWithQuery(t, target.Config)
|
deliverWithQuery(t, target.Config)
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
const RedactionMarker = "(redacted)"
|
const RedactionMarker = "(redacted)"
|
||||||
|
|
||||||
// Redactor removes one target's own credential material from
|
// Redactor removes one target's own credential material from
|
||||||
// a delivery response body, whose text the target's remote
|
// text that target's remote peer chose: a delivery response
|
||||||
// peer chose, and from a stored delivery error as a second
|
// body, or a delivery error stored before the delivery path
|
||||||
// line behind the delivery path's own URL masking.
|
// learned to mask the URLs it embeds.
|
||||||
//
|
//
|
||||||
// It removes byte-identical echoes of strings taken from the
|
// It removes byte-identical echoes of strings taken from the
|
||||||
// target's stored configuration, and nothing else. Anything
|
// target's stored configuration, and nothing else. Anything
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ func TestRedactor_RemovesSlackWebhookURL(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
got := r.Redact("no_service for " + redactWebhookURL)
|
got := r.Redact("no_service for " + redactWebhookURL)
|
||||||
@@ -55,7 +54,6 @@ func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Every cut position inside the credential, not just a
|
// Every cut position inside the credential, not just a
|
||||||
@@ -98,7 +96,6 @@ func TestRedactor_RedactsCredentialShapedHeaderValues(
|
|||||||
`"X-Credential":"HHHHHHHHHHHH",` +
|
`"X-Credential":"HHHHHHHHHHHH",` +
|
||||||
`"Accept":"application/json",` +
|
`"Accept":"application/json",` +
|
||||||
`"User-Agent":"webhooker/1.0"}}`,
|
`"User-Agent":"webhooker/1.0"}}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, secret := range []string{
|
for _, secret := range []string{
|
||||||
@@ -139,7 +136,6 @@ func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) {
|
|||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"https://example.com/in",` +
|
Config: `{"url":"https://example.com/in",` +
|
||||||
`"headers":{"X-Api-Key":"ab"}}`,
|
`"headers":{"X-Api-Key":"ab"}}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = "rabbit"
|
const response = "rabbit"
|
||||||
@@ -156,7 +152,6 @@ func TestRedactor_RemovesBarePath(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
got := r.Redact("POST " + redactSecretPath + " 404")
|
got := r.Redact("POST " + redactSecretPath + " 404")
|
||||||
@@ -190,7 +185,6 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + raw + `"}`,
|
Config: `{"url":"` + raw + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, echoed := range []string{
|
for _, echoed := range []string{
|
||||||
@@ -240,7 +234,6 @@ func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + dest + `"}`,
|
Config: `{"url":"` + dest + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for echoed, want := range echoes {
|
for echoed, want := range echoes {
|
||||||
@@ -262,7 +255,6 @@ func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeSlack,
|
Type: database.TargetTypeSlack,
|
||||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(t, response, r.Redact(response))
|
assert.Equal(t, response, r.Redact(response))
|
||||||
@@ -289,7 +281,6 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
|
|||||||
r := delivery.NewRedactor(&database.Target{
|
r := delivery.NewRedactor(&database.Target{
|
||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + dest + `"}`,
|
Config: `{"url":"` + dest + `"}`,
|
||||||
MaxAttempts: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = "ok=false error=channel_not_found"
|
const body = "ok=false error=channel_not_found"
|
||||||
@@ -323,8 +314,8 @@ func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) {
|
|||||||
for _, tgt := range []database.Target{
|
for _, tgt := range []database.Target{
|
||||||
{Type: database.TargetTypeLog},
|
{Type: database.TargetTypeLog},
|
||||||
{Type: database.TargetTypeDatabase},
|
{Type: database.TargetTypeDatabase},
|
||||||
{Type: database.TargetTypeSlack, Config: "not json", MaxAttempts: 1},
|
{Type: database.TargetTypeSlack, Config: "not json"},
|
||||||
{Type: database.TargetTypeHTTP, Config: "", MaxAttempts: 1},
|
{Type: database.TargetTypeHTTP, Config: ""},
|
||||||
} {
|
} {
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, text,
|
t, text,
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ type SlackTargetConfig struct {
|
|||||||
// slackTarget delivers events to Slack incoming webhooks. It
|
// slackTarget delivers events to Slack incoming webhooks. It
|
||||||
// formats the event into a Slack message and posts it as
|
// formats the event into a Slack message and posts it as
|
||||||
// JSON. It shares the retry core with the HTTP target: a
|
// JSON. It shares the retry core with the HTTP target: a
|
||||||
// MaxAttempts of 1 is a single attempt, while a higher one
|
// MaxRetries of 0 stays single-attempt fire-and-forget
|
||||||
// adds backoff and circuit breaking.
|
// (preserving existing Slack targets), while a positive
|
||||||
|
// MaxRetries adds backoff and circuit breaking.
|
||||||
type slackTarget struct {
|
type slackTarget struct {
|
||||||
*httpCore
|
*httpCore
|
||||||
|
|
||||||
@@ -81,8 +82,8 @@ func (t *slackTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.deliver(
|
t.deliver(
|
||||||
ctx, webhookDB, d, task, sched,
|
webhookDB, d, task, sched,
|
||||||
d.Target.MaxAttempts, attempt,
|
d.Target.MaxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,9 +312,13 @@ func TestFailMissingTarget_WritesNoTargetRow(
|
|||||||
database.DeliveryStatusFailed,
|
database.DeliveryStatusFailed,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The per-webhook database has no targets table, so writing a
|
var configs []string
|
||||||
// target row there would have failed the status write above.
|
|
||||||
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
|
require.NoError(t, s.WebhookDB.
|
||||||
|
Table("targets").
|
||||||
|
Pluck("config", &configs).Error)
|
||||||
|
|
||||||
|
assert.Empty(t, configs,
|
||||||
"the deleted-target terminal path wrote a target row "+
|
"the deleted-target terminal path wrote a target row "+
|
||||||
"into the per-webhook event database",
|
"into the per-webhook event database",
|
||||||
)
|
)
|
||||||
@@ -743,7 +747,7 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
|
|||||||
|
|
||||||
iCreateTarget(
|
iCreateTarget(
|
||||||
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
|
t, s.MainDB, targetID, s.WebhookID, "found-on-lookup",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
@@ -790,7 +794,7 @@ func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
|
|||||||
|
|
||||||
iCreateTarget(
|
iCreateTarget(
|
||||||
t, s.MainDB, targetID, s.WebhookID, "healthy",
|
t, s.MainDB, targetID, s.WebhookID, "healthy",
|
||||||
database.TargetTypeLog, "", 1,
|
database.TargetTypeLog, "", 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
event := iSeedEvent(
|
event := iSeedEvent(
|
||||||
|
|||||||
@@ -135,18 +135,16 @@ func openDB(
|
|||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, gdb.Exec(
|
require.NoError(t, gdb.AutoMigrate(&thing{}))
|
||||||
"CREATE TABLE things (id text PRIMARY KEY, name text)",
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
// Creating the table is not what any of these cases is about.
|
// Migration chatter is not what any of these cases is about.
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|
||||||
return gdb
|
return gdb
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertBounded holds every line the adapter wrote to the stated
|
// assertBounded holds every line the adapter wrote to the stated
|
||||||
// ceiling and proves no line carried a whole client value.
|
// ceiling and proves each was cut rather than merely short.
|
||||||
func assertBounded(t *testing.T, out string) {
|
func assertBounded(t *testing.T, out string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ func skipDir(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkNonTestGo parses every non-test .go file under root. It returns
|
// walkNonTestGo parses every non-test .go file under root. It returns
|
||||||
// the directories, relative to root, it parsed a file in or below,
|
// the directories, relative to root, it parsed a file in, along with
|
||||||
// along with every unguarded Scan it found.
|
// every unguarded Scan it found.
|
||||||
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -155,12 +155,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The directories above count too, so that one holding
|
|
||||||
// only a nested package, as internal/db does, is not
|
|
||||||
// taken for one the walk skipped.
|
|
||||||
for ; dir != "."; dir = filepath.Dir(dir) {
|
|
||||||
walked[dir] = true
|
walked[dir] = true
|
||||||
}
|
|
||||||
|
|
||||||
for _, pos := range unguardedScans(fset, file) {
|
for _, pos := range unguardedScans(fset, file) {
|
||||||
hits = append(hits, relPosition(root, pos))
|
hits = append(hits, relPosition(root, pos))
|
||||||
@@ -210,9 +205,9 @@ func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
|||||||
walked, offenders := walkNonTestGo(t, root)
|
walked, offenders := walkNonTestGo(t, root)
|
||||||
|
|
||||||
// The module's packages are static, templates, and every directory
|
// The module's packages are static, templates, and every directory
|
||||||
// directly under cmd and internal. Each holds non-test code, itself
|
// directly under cmd and internal. Each holds non-test code, so one
|
||||||
// or in a package below it, so one the walk parsed nothing in or
|
// the walk parsed nothing in was skipped, and a Scan there would
|
||||||
// below was skipped, and a Scan there would pass unseen.
|
// pass unseen.
|
||||||
packages := []string{"static", "templates"}
|
packages := []string{"static", "templates"}
|
||||||
|
|
||||||
for _, parent := range []string{"cmd", "internal"} {
|
for _, parent := range []string{"cmd", "internal"} {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) {
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
f := newRecentEventsFixture(t)
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ func createReplayDelivery(
|
|||||||
TargetName: target.Name,
|
TargetName: target.Name,
|
||||||
TargetType: target.Type,
|
TargetType: target.Type,
|
||||||
TargetConfig: target.Config,
|
TargetConfig: target.Config,
|
||||||
MaxAttempts: target.MaxAttempts,
|
MaxRetries: target.MaxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
RawQuery: event.RawQuery,
|
RawQuery: event.RawQuery,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
|
|||||||
@@ -314,15 +314,21 @@ func assertReplayTask(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertNoLeakedTarget proves the per-webhook database holds no
|
// assertNoLeakedTarget proves the per-webhook database holds no target
|
||||||
// target: an association write would have upserted the whole target,
|
// rows. AutoMigrate creates the table there because Delivery declares
|
||||||
// plaintext config and all, into the event database. See
|
// the relation, so it is a ROW that signals a leak: an association
|
||||||
|
// write would have upserted the whole target, plaintext config and
|
||||||
|
// all, into the event database. See
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/206.
|
// https://git.eeqj.de/sneak/webhooker/issues/206.
|
||||||
func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
|
func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
assert.False(
|
var leaked int64
|
||||||
t, webhookDB.Migrator().HasTable(&database.Target{}),
|
|
||||||
|
require.NoError(t, webhookDB.Unscoped().
|
||||||
|
Model(&database.Target{}).Count(&leaked).Error)
|
||||||
|
assert.Zero(
|
||||||
|
t, leaked,
|
||||||
"replay must not write the target into the event database",
|
"replay must not write the target into the event database",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
//
|
//
|
||||||
// The bound is enforced here and in SQL, because this page's
|
// The bound is enforced here and in SQL, because this page's
|
||||||
// memory profile must not depend on a constant in another
|
// memory profile must not depend on a constant in another
|
||||||
// package staying where it is, or on every stored row having
|
// package staying where it is, and because rows predating the
|
||||||
// been written by the delivery engine under its own cap.
|
// delivery engine's own cap or restored from an archive are
|
||||||
|
// not covered by it at all.
|
||||||
//
|
//
|
||||||
// It happens to equal that engine cap, so a row written by the
|
// It happens to equal that engine cap, so a row written by the
|
||||||
// engine reaches this one exactly and is never cut twice.
|
// current engine reaches this one exactly and is never cut
|
||||||
// Nothing here may assume the two differ: see view.
|
// twice. Nothing here may assume the two differ: see view.
|
||||||
const maxRenderedResponseBytes = 4096
|
const maxRenderedResponseBytes = 4096
|
||||||
|
|
||||||
// deliveryResultColumns is the delivery attempt projection.
|
// deliveryResultColumns is the delivery attempt projection.
|
||||||
@@ -39,8 +40,9 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
|||||||
//
|
//
|
||||||
// Both Error and ResponseBody have been through the target's
|
// Both Error and ResponseBody have been through the target's
|
||||||
// Redactor. The engine already masks the URL out of the
|
// Redactor. The engine already masks the URL out of the
|
||||||
// errors it stores, so for errors this is a second line; for
|
// errors it stores, so for errors this is a second line
|
||||||
// response bodies it is the only line, and its reach is what
|
// covering rows written before it did; for response bodies it
|
||||||
|
// is the only line, and its reach is what
|
||||||
// delivery.Redactor documents.
|
// delivery.Redactor documents.
|
||||||
type DeliveryResultView struct {
|
type DeliveryResultView struct {
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
|
|||||||
// TestHandleSourceLogs_EscapesResponseBody proves the
|
// TestHandleSourceLogs_EscapesResponseBody proves the
|
||||||
// response body is treated as the untrusted remote content it
|
// response body is treated as the untrusted remote content it
|
||||||
// is. The remote chooses these bytes and the page is rendered
|
// is. The remote chooses these bytes and the page is rendered
|
||||||
// inside the operator's authenticated origin.
|
// inside the operator's authenticated origin, where the
|
||||||
|
// application's own CSP allows inline script from 'self'.
|
||||||
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
|
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
|
|||||||
// response is deliberately not renderable. Content-Disposition
|
// response is deliberately not renderable. Content-Disposition
|
||||||
// makes the browser download rather than display it, and the
|
// makes the browser download rather than display it, and the
|
||||||
// octet-stream type plus nosniff stop it being interpreted as
|
// octet-stream type plus nosniff stop it being interpreted as
|
||||||
// HTML or script. Without those a stored payload would be shown
|
// HTML or script. Without those a stored payload would execute
|
||||||
// as a page of the operator's own origin. The application's CSP
|
// as the logged-in operator. The application's CSP does not
|
||||||
// is a second defence behind them: it allows no inline script,
|
// help here: script-src allows 'unsafe-inline' from 'self', so
|
||||||
// so such a page could not run script of its own.
|
// a document served from this origin could run its own inline
|
||||||
|
// script.
|
||||||
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
webhook, ok := h.ownedWebhook(w, r)
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
|||||||
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
||||||
assert.Contains(t, page, "{\n "pad": "xxx")
|
assert.Contains(t, page, "{\n "pad": "xxx")
|
||||||
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
||||||
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`)
|
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
||||||
assert.NotContains(t, page, "Showing the first")
|
assert.NotContains(t, page, "Showing the first")
|
||||||
assert.Contains(t, page, target.Name)
|
assert.Contains(t, page, target.Name)
|
||||||
assert.Contains(t, page, ">failed</span>")
|
assert.Contains(t, page, ">failed</span>")
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ const deliveryIDChunkSize = 500
|
|||||||
//
|
//
|
||||||
// Each response body is cut by SQLite rather than in Go, for
|
// Each response body is cut by SQLite rather than in Go, for
|
||||||
// the reason deliveryResultColumns gives. How many attempts a
|
// the reason deliveryResultColumns gives. How many attempts a
|
||||||
// delivery has is bounded by the target's MaxAttempts, which the
|
// delivery has is the target's MaxRetries, which the
|
||||||
// authenticated operator sets; how many of them reach the page
|
// authenticated operator sets; how many of them reach the page
|
||||||
// is bounded again by maxRenderedAttempts.
|
// is bounded again by maxRenderedAttempts.
|
||||||
func (h *Handlers) loadDeliveryResults(
|
func (h *Handlers) loadDeliveryResults(
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
|||||||
// per-delivery attempt ceiling to the handlers_test package.
|
// per-delivery attempt ceiling to the handlers_test package.
|
||||||
const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
||||||
|
|
||||||
// MaxTargetAttemptsForTest exposes the target max_attempts ceiling to
|
// MaxTargetRetriesForTest exposes the target max_retries ceiling to
|
||||||
// the handlers_test package, so the tests assert against the constant
|
// the handlers_test package, so the tests assert against the constant
|
||||||
// the handlers enforce rather than a number copied beside it.
|
// the handlers enforce rather than a number copied beside it.
|
||||||
const MaxTargetAttemptsForTest = maxTargetAttempts
|
const MaxTargetRetriesForTest = maxTargetRetries
|
||||||
|
|
||||||
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
|
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
|
||||||
// messages the webhook delete handler logs when a file of the event
|
// messages the webhook delete handler logs when a file of the event
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
// The handler-side half of the log-field audit. The slog calls in
|
// The handler-side half of the log-field audit. Two slog calls in
|
||||||
// this package that reach a value an UNAUTHENTICATED client picks
|
// this package reach a value an UNAUTHENTICATED client picks outright
|
||||||
// outright, and of a length it picks outright, are the failed-login
|
// and of a length it picks outright:
|
||||||
// DEBUG lines, whose username is a form field.
|
|
||||||
//
|
//
|
||||||
// They are at DEBUG, which is off in production by default. That is
|
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
|
||||||
|
// path segment matched no stored entrypoint and so is bounded by
|
||||||
|
// nothing;
|
||||||
|
// - the failed-login DEBUG lines, whose username is a form field.
|
||||||
|
//
|
||||||
|
// Both are at DEBUG, which is off in production by default. That is
|
||||||
// not a bound: an operator turning DEBUG on to diagnose a flood must
|
// not a bound: an operator turning DEBUG on to diagnose a flood must
|
||||||
// not thereby hand the flood an unbounded write. They spend the same
|
// not thereby hand the flood an unbounded write. Both spend the same
|
||||||
// internal/logfield budget as the access log, and are held here to
|
// internal/logfield budget as the access log, and both are held here
|
||||||
// middleware.MaxAccessLogLineBytes.
|
// to middleware.MaxAccessLogLineBytes.
|
||||||
//
|
//
|
||||||
// The two login lines past the username lookup — "invalid password"
|
// The two login lines past the username lookup — "invalid password"
|
||||||
// and "user logged in" — carry the same cap without needing it, since
|
// and "user logged in" — carry the same cap without needing it, since
|
||||||
@@ -191,12 +195,13 @@ func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
|||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReceiver sends one POST with an empty body at /h/<segment>
|
// postReceiver sends one POST at /h/<segment>.
|
||||||
// through router and returns the response status.
|
|
||||||
//
|
//
|
||||||
// RawPath is cleared after parsing so chi routes on the decoded path
|
// RawPath is cleared after parsing so chi routes on the decoded path
|
||||||
// and the handler sees the raw bytes rather than their percent-escaped
|
// and the handler sees the raw bytes rather than their percent-escaped
|
||||||
// spelling.
|
// spelling. That is the harder case for the budget: the escaped
|
||||||
|
// spelling is plain ASCII, which costs one byte per byte, while the
|
||||||
|
// decoded bytes are what the log handler has to escape.
|
||||||
func postReceiver(
|
func postReceiver(
|
||||||
t *testing.T, router *chi.Mux, segment string,
|
t *testing.T, router *chi.Mux, segment string,
|
||||||
) int {
|
) int {
|
||||||
@@ -255,6 +260,43 @@ func postLoginWithPassword(
|
|||||||
return w.Code
|
return w.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnknownEntrypoint_LogLineDoesNotTrackPathSize drives 8 KB of
|
||||||
|
// client-chosen path at the unauthenticated receiver's
|
||||||
|
// unknown-entrypoint DEBUG line and holds it to the same ceiling the
|
||||||
|
// access log states.
|
||||||
|
func TestUnknownEntrypoint_LogLineDoesNotTrackPathSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for handlerName, newHandler := range logHandlers() {
|
||||||
|
for fillName, fill := range escapeFills() {
|
||||||
|
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h, buf := capturingHandlers(t, newHandler)
|
||||||
|
router := receiverRouter(h)
|
||||||
|
|
||||||
|
for i := range floodRequests {
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
http.StatusNotFound,
|
||||||
|
postReceiver(
|
||||||
|
t, router,
|
||||||
|
oversizedFill(fill)+
|
||||||
|
strings.Repeat("y", i),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := logLines(t, buf)
|
||||||
|
require.Len(t, lines, floodRequests)
|
||||||
|
assertNoClientText(t, buf)
|
||||||
|
|
||||||
|
assertBoundedFlood(t, buf.Len())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
|
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
|
||||||
// client-chosen username at the unauthenticated login endpoint's
|
// client-chosen username at the unauthenticated login endpoint's
|
||||||
// DEBUG line and holds it to the same ceiling.
|
// DEBUG line and holds it to the same ceiling.
|
||||||
|
|||||||
@@ -16,16 +16,21 @@ import (
|
|||||||
//
|
//
|
||||||
// An empty value yields fallback, which lets the create path apply the
|
// An empty value yields fallback, which lets the create path apply the
|
||||||
// default and the edit path leave the stored value unchanged. A value
|
// default and the edit path leave the stored value unchanged. A value
|
||||||
// of 0 means retain events forever and is returned as it is. Anything
|
// of 0 is returned as 0 and is rewritten to the retain-forever
|
||||||
// unparseable or negative is refused rather than silently given a
|
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
||||||
// default.
|
// or negative is refused rather than silently given a default.
|
||||||
//
|
//
|
||||||
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
||||||
// time.Duration, an int64 nanosecond count, so a day count above
|
// time.Duration, an int64 nanosecond count, so a day count above
|
||||||
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
||||||
// future, and deletes every event the webhook has. A value above that
|
// future, and deletes every event the webhook has. A finite value
|
||||||
// ceiling is therefore refused, and the message names the ceiling
|
// above that ceiling is therefore refused, and the message names the
|
||||||
// rather than implying the input was not a number.
|
// ceiling rather than implying the input was not a number.
|
||||||
|
//
|
||||||
|
// A value at or above the retain-forever sentinel is not out of range:
|
||||||
|
// it is what the edit form pre-fills for a retain-forever webhook, so
|
||||||
|
// submitting the form back unchanged has to keep meaning "forever"
|
||||||
|
// rather than being rejected.
|
||||||
func parseRetentionDays(raw string, fallback int) (int, string) {
|
func parseRetentionDays(raw string, fallback int) (int, string) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -38,6 +43,10 @@ func parseRetentionDays(raw string, fallback int) (int, string) {
|
|||||||
"retain events forever."
|
"retain events forever."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v >= database.RetentionForeverDays {
|
||||||
|
return database.RetentionForeverDays, ""
|
||||||
|
}
|
||||||
|
|
||||||
if v > database.MaxFiniteRetentionDays {
|
if v > database.MaxFiniteRetentionDays {
|
||||||
return 0, "Retention must be at most " +
|
return 0, "Retention must be at most " +
|
||||||
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ func seedTarget(
|
|||||||
Name: "t-" + string(targetType),
|
Name: "t-" + string(targetType),
|
||||||
Type: targetType,
|
Type: targetType,
|
||||||
Active: true,
|
Active: true,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ func seedConfiguredTarget(
|
|||||||
Type: targetType,
|
Type: targetType,
|
||||||
Active: true,
|
Active: true,
|
||||||
Config: config,
|
Config: config,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
@@ -274,7 +273,7 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`<div class="max-w-[108rem] mx-auto px-6 py-8">`,
|
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
@@ -287,8 +286,8 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
// target and says what deleting it loses, that the webhook's gives its
|
// target and says what deleting it loses, that the webhook's gives its
|
||||||
// number of stored events (5 received, 2 removed by retention, so 3,
|
// number of stored events (5 received, 2 removed by retention, so 3,
|
||||||
// the statistics pane's "Within retention" figure), and that an
|
// the statistics pane's "Within retention" figure), and that an
|
||||||
// entrypoint with no description is named by its URL. Each prompt is
|
// entrypoint with no description is named by its URL. The template
|
||||||
// its form's data-confirm attribute, where is a line break.
|
// writes the slashes after http: as \/, which the browser reads as /.
|
||||||
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -326,28 +325,29 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`data-confirm="Delete webhook "delete-me"? `+
|
`Delete webhook "delete-me"?\n\n`+
|
||||||
`This deletes its stored events (3) and their deliveries. `+
|
`This deletes its stored events (3) and their deliveries. `+
|
||||||
`Any archive files it wrote are kept."`)
|
`Any archive files it wrote are kept.`)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`data-confirm="Delete entrypoint "Stripe"? `+
|
`Delete entrypoint "Stripe"?\n\n`+
|
||||||
`Senders using its URL get an error from now on, `+
|
`Senders using its URL get an error from now on, `+
|
||||||
`and the URL cannot be restored."`)
|
`and the URL cannot be restored.`)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`data-confirm="Delete entrypoint "http://example.com/h/`+
|
`Delete entrypoint "http:\/\/example.com/h/`+
|
||||||
unnamed.Path+`"? `)
|
unnamed.Path+`"?`)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`data-confirm="Delete target "t-log"? `+
|
`Delete target "t-log"?\n\n`+
|
||||||
`Nothing more is delivered to it. `+
|
`Nothing more is delivered to it. `+
|
||||||
`Its past deliveries stay in the event log."`)
|
`Its past deliveries stay in the event log.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
||||||
// webhook name with quotes, a backslash, a closing script tag and a
|
// webhook name with quotes, a backslash, a closing script tag and a
|
||||||
// newline reaches its delete prompt escaped for the attribute, which
|
// newline reaches its delete prompt escaped for the script, which the
|
||||||
// the browser reads back as the name typed: each quote and angle
|
// browser reads back as the name typed: each quote and angle bracket
|
||||||
// bracket as a character reference, the rest as typed. An unescaped
|
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
||||||
// double quote would end the attribute and cut the prompt short.
|
// doubled. An unescaped newline would break the prompt's script, and
|
||||||
|
// the form would then submit without asking.
|
||||||
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -373,6 +373,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
"Delete webhook "Bob's "best" \\ hook"+
|
"Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
||||||
"</script>\nline two"?")
|
"\\u003c\\/script\\u003e\\nline two"?")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ func onlyWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedWebhookWithRetention inserts a webhook owned by the test user
|
// seedWebhookWithRetention inserts a webhook owned by the test user
|
||||||
// with the given retention.
|
// with an exact stored retention value, bypassing Webhook.BeforeSave
|
||||||
|
// via a column-level update so that legacy rows can be planted too.
|
||||||
func seedWebhookWithRetention(
|
func seedWebhookWithRetention(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.Database,
|
db *database.Database,
|
||||||
@@ -154,6 +155,13 @@ func seedWebhookWithRetention(
|
|||||||
t,
|
t,
|
||||||
db.DB().Omit(clause.Associations).Create(wh).Error,
|
db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||||
)
|
)
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.DB().Model(wh).
|
||||||
|
Update("retention_days", retentionDays).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
wh.RetentionDays = retentionDays
|
||||||
|
|
||||||
return *wh
|
return *wh
|
||||||
}
|
}
|
||||||
@@ -217,9 +225,10 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever checks
|
// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core
|
||||||
// that the create form's 0 reaches the database as 0, which means
|
// regression test for the bug: the create form's 0 must reach the
|
||||||
// retain forever.
|
// database as the retain-forever sentinel rather than being replaced by
|
||||||
|
// the column's default of 30.
|
||||||
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -232,45 +241,22 @@ func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
wh := onlyWebhook(t, env.db)
|
wh := onlyWebhook(t, env.db)
|
||||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
|
)
|
||||||
assert.True(t, wh.RetainsForever())
|
assert.True(t, wh.RetainsForever())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleSourceCreateSubmit_DayCountIsStored(t *testing.T) {
|
func TestHandleSourceCreateSubmit_OmittedRetentionUsesDefault(
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
days := "45"
|
|
||||||
|
|
||||||
w := submitCreate(t, env.handlers, env.cookies, "finite", &days)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
wh := onlyWebhook(t, env.db)
|
|
||||||
assert.Equal(t, 45, storedRetentionDays(t, env.db, wh.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_BlankRetentionUsesDefault covers a
|
|
||||||
// retention field left empty and one missing from the form: both
|
|
||||||
// give the default, not forever.
|
|
||||||
func TestHandleSourceCreateSubmit_BlankRetentionUsesDefault(
|
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
blank := ""
|
|
||||||
|
|
||||||
for name, retention := range map[string]*string{
|
|
||||||
"blank": &blank,
|
|
||||||
"omitted": nil,
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
w := submitCreate(
|
w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil)
|
||||||
t, env.handlers, env.cookies, "defaulted", retention,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
wh := onlyWebhook(t, env.db)
|
wh := onlyWebhook(t, env.db)
|
||||||
@@ -279,13 +265,11 @@ func TestHandleSourceCreateSubmit_BlankRetentionUsesDefault(
|
|||||||
database.DefaultRetentionDays,
|
database.DefaultRetentionDays,
|
||||||
storedRetentionDays(t, env.db, wh.ID),
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceCreate_PrefillsDefaultFromConstant checks that the
|
// TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create
|
||||||
// create form pre-fills its retention from database.DefaultRetentionDays
|
// form's pre-filled retention from becoming a third hardcoded copy of
|
||||||
// rather than from a hardcoded 30.
|
// the 30-day policy.
|
||||||
func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -306,7 +290,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, body, `max="365"`,
|
t, body, `max="365"`,
|
||||||
"a max of 365 would block any longer retention",
|
"a max below the sentinel would block retain-forever",
|
||||||
)
|
)
|
||||||
assert.Contains(t, body, `min="0"`)
|
assert.Contains(t, body, `min="0"`)
|
||||||
}
|
}
|
||||||
@@ -384,6 +368,40 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever guards the
|
||||||
|
// boundary between "too large to represent" and "retain forever": the
|
||||||
|
// sentinel is above MaxFiniteRetentionDays, but it is the value the
|
||||||
|
// edit form pre-fills, so it must be accepted rather than rejected as
|
||||||
|
// out of range. A value above the sentinel is stored as the sentinel.
|
||||||
|
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, days := range []int{
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
database.RetentionForeverDays + 1,
|
||||||
|
} {
|
||||||
|
raw := strconv.Itoa(days)
|
||||||
|
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
w := submitCreate(t, env.handlers, env.cookies, "forever", &raw)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
wh := onlyWebhook(t, env.db)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
||||||
// validation failure hands the user's typing back, matching what the
|
// validation failure hands the user's typing back, matching what the
|
||||||
// edit form already does. Losing a long description to a mistyped
|
// edit form already does. Losing a long description to a mistyped
|
||||||
@@ -462,7 +480,11 @@ func TestHandleSourceEditSubmit_ZeroRetentionPersistsForever(
|
|||||||
w := submitEdit(t, env, wh, "0")
|
w := submitEdit(t, env, wh, "0")
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
|
||||||
@@ -845,15 +867,18 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
|||||||
assert.Equal(t, oldName, stored.Name)
|
assert.Equal(t, oldName, stored.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSourceEditForm_ForeverWebhookRoundTrips renders the edit form for
|
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
||||||
// a retain-forever webhook, confirms it pre-fills 0, then submits that
|
// the removed max="365" cap used to break: render the edit form for a
|
||||||
// pre-filled value straight back and confirms the retention policy
|
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
||||||
// survives untouched.
|
// by browser validation, then submit that pre-filled value straight
|
||||||
|
// back and confirm the retention policy survives untouched.
|
||||||
func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
env := setupSourceTest(t)
|
||||||
wh := seedWebhookWithRetention(t, env.db, 0)
|
wh := seedWebhookWithRetention(
|
||||||
|
t, env.db, database.RetentionForeverDays,
|
||||||
|
)
|
||||||
|
|
||||||
req := getRequest(
|
req := getRequest(
|
||||||
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||||
@@ -864,12 +889,17 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body, `name="retention_days" value="0"`,
|
t, body, `value="`+sentinel+`"`,
|
||||||
"the edit form pre-fills the stored retention",
|
"the edit form pre-fills the stored retention",
|
||||||
)
|
)
|
||||||
|
assert.NotContains(
|
||||||
|
t, body, `max="365"`,
|
||||||
|
"a max below the sentinel would block saving any edit",
|
||||||
|
)
|
||||||
// "Currently forever." is the rendered RetentionLabel, not the
|
// "Currently forever." is the rendered RetentionLabel, not the
|
||||||
// static hint below the input, which says "Enter 0 to retain events
|
// static hint below the input, which says "Enter 0 to retain events
|
||||||
// forever." A bare Contains of "forever" would pass for any
|
// forever." A bare Contains of "forever" would pass for any
|
||||||
@@ -880,20 +910,29 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Submit the pre-filled value back, exactly as a browser would.
|
// Submit the pre-filled value back, exactly as a browser would.
|
||||||
post := submitEdit(t, env, wh, "0")
|
post := submitEdit(t, env, wh, sentinel)
|
||||||
require.Equal(t, http.StatusSeeOther, post.Code)
|
require.Equal(t, http.StatusSeeOther, post.Code)
|
||||||
|
|
||||||
assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID))
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
database.RetentionForeverDays,
|
||||||
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSourceListAndDetail_ShowForeverNotZeroDays checks that a
|
// TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that
|
||||||
// retention of 0 is shown as forever, never as a day count, on either
|
// the retain-forever value is never rendered to the user as a raw day
|
||||||
// read-only view.
|
// count on either read-only view.
|
||||||
func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
|
func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
env := setupSourceTest(t)
|
||||||
wh := seedWebhookWithRetention(t, env.db, 0)
|
wh := seedWebhookWithRetention(
|
||||||
|
t, env.db, database.RetentionForeverDays,
|
||||||
|
)
|
||||||
|
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||||
|
|
||||||
listW := httptest.NewRecorder()
|
listW := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceList().ServeHTTP(
|
env.handlers.HandleSourceList().ServeHTTP(
|
||||||
@@ -902,7 +941,7 @@ func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusOK, listW.Code)
|
require.Equal(t, http.StatusOK, listW.Code)
|
||||||
assert.Contains(t, listW.Body.String(), "Retention: forever")
|
assert.Contains(t, listW.Body.String(), "Retention: forever")
|
||||||
assert.NotContains(t, listW.Body.String(), "Retention: 0 days")
|
assert.NotContains(t, listW.Body.String(), sentinel)
|
||||||
|
|
||||||
detailW := httptest.NewRecorder()
|
detailW := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||||
@@ -915,5 +954,5 @@ func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, http.StatusOK, detailW.Code)
|
require.Equal(t, http.StatusOK, detailW.Code)
|
||||||
assert.Contains(t, detailW.Body.String(), "Retention: forever")
|
assert.Contains(t, detailW.Body.String(), "Retention: forever")
|
||||||
assert.NotContains(t, detailW.Body.String(), "Retention: 0 days")
|
assert.NotContains(t, detailW.Body.String(), sentinel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxTargetAttempts bounds a target's max_attempts.
|
|
||||||
//
|
|
||||||
// Both target forms already declare max="20" on the input, so this
|
|
||||||
// enforces server-side what the UI has always advertised rather than
|
|
||||||
// introducing a new limit.
|
|
||||||
//
|
|
||||||
// The number is not cosmetic. Every attempt writes a delivery_results
|
|
||||||
// row that the event log then loads and renders, and the engine backs
|
|
||||||
// off by 2^(n-1) seconds, so attempt 20 is already about six days
|
|
||||||
// after the first. A value beyond this buys no additional durability
|
|
||||||
// and only costs rows.
|
|
||||||
const maxTargetAttempts = 20
|
|
||||||
|
|
||||||
// Errors returned when a max_attempts form value cannot be turned into
|
|
||||||
// a number of attempts.
|
|
||||||
var (
|
|
||||||
// errAttemptsInvalid signals a max_attempts form value that is not
|
|
||||||
// a whole number of at least 1.
|
|
||||||
errAttemptsInvalid = errors.New(
|
|
||||||
"must be a whole number, at least 1",
|
|
||||||
)
|
|
||||||
|
|
||||||
// errAttemptsTooLarge signals a max_attempts form value that is a
|
|
||||||
// whole number but above maxTargetAttempts. It is distinguished
|
|
||||||
// from errAttemptsInvalid so the message can name the ceiling
|
|
||||||
// instead of implying the input was not a number.
|
|
||||||
errAttemptsTooLarge = errors.New("out of range")
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseMaxAttempts interprets a max_attempts form value.
|
|
||||||
//
|
|
||||||
// An ABSENT value — the field empty or not submitted — yields
|
|
||||||
// fallback, which lets the create path apply its default and the edit
|
|
||||||
// path leave the stored value alone. A value that is SET BUT INVALID
|
|
||||||
// is an error: unparseable, below 1, or above maxTargetAttempts.
|
|
||||||
//
|
|
||||||
// The distinction is the whole point of this function. Returning a
|
|
||||||
// default for input the operator typed but that did not parse would
|
|
||||||
// silently turn retries off on a store-and-forward proxy — and on the
|
|
||||||
// edit path destroy a working configuration over a typo. A default
|
|
||||||
// answers a question that was not asked; it never answers one that
|
|
||||||
// was asked badly.
|
|
||||||
func parseMaxAttempts(raw string, fallback int) (int, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
v, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || v < 1 {
|
|
||||||
return 0, errAttemptsInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
if v > maxTargetAttempts {
|
|
||||||
return 0, errAttemptsTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// attemptsErrorMessage returns the message the create and edit forms
|
|
||||||
// show for a rejected max_attempts value. Any error other than
|
|
||||||
// errAttemptsTooLarge falls back to the generic wording, so an
|
|
||||||
// unrecognised parse failure still produces a sensible 400.
|
|
||||||
func attemptsErrorMessage(err error) string {
|
|
||||||
if errors.Is(err, errAttemptsTooLarge) {
|
|
||||||
return errAttemptsTooLarge.Error() +
|
|
||||||
": at most " + strconv.Itoa(maxTargetAttempts) +
|
|
||||||
" attempts"
|
|
||||||
}
|
|
||||||
|
|
||||||
return errAttemptsInvalid.Error()
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// attemptsTargetURL is the destination the attempts-validation targets
|
|
||||||
// point at. It is a literal public address rather than a hostname so
|
|
||||||
// the SSRF check resolves nothing and a sandbox without DNS cannot
|
|
||||||
// make these cases pass or fail for the wrong reason.
|
|
||||||
const attemptsTargetURL = "https://93.184.216.34/hooks/attempts"
|
|
||||||
|
|
||||||
const (
|
|
||||||
// wayAboveCeiling is the typo'd-extra-zero case from the report.
|
|
||||||
wayAboveCeiling = "999999999"
|
|
||||||
|
|
||||||
// notANumber is the plainest garbage an operator can type, and
|
|
||||||
// the value the report submitted on the edit form.
|
|
||||||
notANumber = "abc"
|
|
||||||
|
|
||||||
// workingAttempts is the number of attempts a seeded target is
|
|
||||||
// already delivering with, which a rejected submission must not
|
|
||||||
// disturb.
|
|
||||||
workingAttempts = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// aboveCeiling is the smallest rejected whole number.
|
|
||||||
func aboveCeiling() string {
|
|
||||||
return strconv.Itoa(handlers.MaxTargetAttemptsForTest + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// overCeilingAttempts is whole-number input past the limit, which is
|
|
||||||
// rejected with the limit named.
|
|
||||||
func overCeilingAttempts() []string {
|
|
||||||
return []string{aboveCeiling(), wayAboveCeiling}
|
|
||||||
}
|
|
||||||
|
|
||||||
// belowOneAttempts is whole-number input under the floor of one
|
|
||||||
// attempt, which is rejected with the floor named.
|
|
||||||
func belowOneAttempts() []string {
|
|
||||||
return []string{"0", "-5"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unparseableAttempts is input an operator can type into the field
|
|
||||||
// that is not a number of attempts. Each must be REJECTED: silently
|
|
||||||
// reading any of them as a default turns retries off on a
|
|
||||||
// store-and-forward proxy without saying so.
|
|
||||||
//
|
|
||||||
// The twenty-digit case is here because it parses as digits but
|
|
||||||
// overflows int, which is the one failure the field's own min/max
|
|
||||||
// attributes cannot describe.
|
|
||||||
func unparseableAttempts() []string {
|
|
||||||
return []string{
|
|
||||||
notANumber,
|
|
||||||
"2.7",
|
|
||||||
"12345678901234567890",
|
|
||||||
"1e3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAttemptsForm is a complete, otherwise-valid HTTP target
|
|
||||||
// creation, so the only thing any case below varies is max_attempts.
|
|
||||||
func createAttemptsForm(attempts string) url.Values {
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "attempts-target")
|
|
||||||
form.Set("type", string(database.TargetTypeHTTP))
|
|
||||||
form.Set("url", attemptsTargetURL)
|
|
||||||
|
|
||||||
if attempts != absentField {
|
|
||||||
form.Set("max_attempts", attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
// absentField marks a field the form does not submit at all, which is
|
|
||||||
// the case that legitimately takes a default and must stay distinct
|
|
||||||
// from a field submitted with garbage in it.
|
|
||||||
const absentField = "\x00absent"
|
|
||||||
|
|
||||||
// absentAttempts is every way of saying "the operator did not set
|
|
||||||
// this", each of which takes the default rather than a 400. Blank and
|
|
||||||
// whitespace-only count as absent here because they do in the timeout
|
|
||||||
// and retention controls on the same forms; a rule the fields do not
|
|
||||||
// share would be its own surprise.
|
|
||||||
func absentAttempts() []string {
|
|
||||||
return []string{absentField, "", " "}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createWithAttempts posts the target create form for a fresh webhook
|
|
||||||
// and returns the webhook and the response.
|
|
||||||
func createWithAttempts(
|
|
||||||
t *testing.T,
|
|
||||||
env *sourceTestEnv,
|
|
||||||
attempts string,
|
|
||||||
) (database.Webhook, int, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodPost,
|
|
||||||
"/hook/"+webhook.ID+"/targets",
|
|
||||||
createAttemptsForm(attempts),
|
|
||||||
)
|
|
||||||
|
|
||||||
return webhook, w.Code, w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_AttemptsAboveCeilingRejected proves the create form
|
|
||||||
// enforces a ceiling at all, and that the 400 names it — a rejection
|
|
||||||
// that does not say what the limit is leaves the operator guessing.
|
|
||||||
func TestTargetCreate_AttemptsAboveCeilingRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
|
|
||||||
|
|
||||||
for _, attempts := range overCeilingAttempts() {
|
|
||||||
webhook, code, body := createWithAttempts(t, env, attempts)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code,
|
|
||||||
"max_attempts=%s should be rejected", attempts)
|
|
||||||
assert.Contains(t, body, ceiling,
|
|
||||||
"the rejection for %s should name the ceiling",
|
|
||||||
attempts)
|
|
||||||
assert.Empty(t,
|
|
||||||
targetsForWebhook(t, env.db, webhook.ID),
|
|
||||||
"no target should be created for %s", attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_AttemptsBelowOneRejected proves the create form
|
|
||||||
// refuses 0 and less, naming the floor: every delivery is attempted
|
|
||||||
// at least once.
|
|
||||||
func TestTargetCreate_AttemptsBelowOneRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range belowOneAttempts() {
|
|
||||||
webhook, code, body := createWithAttempts(t, env, attempts)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code,
|
|
||||||
"max_attempts=%s should be rejected", attempts)
|
|
||||||
assert.Contains(t, body, "at least 1",
|
|
||||||
"the rejection for %s should name the floor",
|
|
||||||
attempts)
|
|
||||||
assert.Empty(t,
|
|
||||||
targetsForWebhook(t, env.db, webhook.ID),
|
|
||||||
"no target should be created for %s", attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_UnparseableAttemptsRejected is the core of the
|
|
||||||
// defect: each of these was accepted with HTTP 200 and stored as a
|
|
||||||
// default.
|
|
||||||
func TestTargetCreate_UnparseableAttemptsRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range unparseableAttempts() {
|
|
||||||
webhook, code, body := createWithAttempts(t, env, attempts)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code,
|
|
||||||
"max_attempts=%q should be rejected, not coerced",
|
|
||||||
attempts)
|
|
||||||
assert.Contains(t, body, "whole number",
|
|
||||||
"the rejection for %q should say why", attempts)
|
|
||||||
assert.Empty(t,
|
|
||||||
targetsForWebhook(t, env.db, webhook.ID),
|
|
||||||
"no target should be created for %q", attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_ValidAttemptsStored covers the accepting half,
|
|
||||||
// including both bounds: a bound that rejects its own limit would
|
|
||||||
// make the advertised range unreachable.
|
|
||||||
func TestTargetCreate_ValidAttemptsStored(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, want := range []int{1, 3, handlers.MaxTargetAttemptsForTest} {
|
|
||||||
webhook, code, body := createWithAttempts(
|
|
||||||
t, env, strconv.Itoa(want),
|
|
||||||
)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, code, body)
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
assert.Equal(t, want, targets[0].MaxAttempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetCreate_AbsentAttemptsTakesDefault keeps the two cases
|
|
||||||
// distinct. An omitted field is not an operator asking for something
|
|
||||||
// invalid, so it still gets the default of one attempt rather than a
|
|
||||||
// 400 — otherwise the fix above would make the form unusable.
|
|
||||||
func TestTargetCreate_AbsentAttemptsTakesDefault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range absentAttempts() {
|
|
||||||
webhook, code, body := createWithAttempts(t, env, attempts)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, code, body)
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
assert.Equal(t, 1, targets[0].MaxAttempts,
|
|
||||||
"an absent max_attempts should take the default")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedAttemptsTarget creates an HTTP target already delivering with
|
|
||||||
// workingAttempts attempts, through the real create handler.
|
|
||||||
func seedAttemptsTarget(
|
|
||||||
t *testing.T,
|
|
||||||
env *sourceTestEnv,
|
|
||||||
) (database.Webhook, database.Target) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhook, code, body := createWithAttempts(
|
|
||||||
t, env, strconv.Itoa(workingAttempts),
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, code, body)
|
|
||||||
|
|
||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
||||||
require.Len(t, targets, 1)
|
|
||||||
require.Equal(t, workingAttempts, targets[0].MaxAttempts)
|
|
||||||
|
|
||||||
return webhook, targets[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// editAttemptsForm is a complete edit submission that changes the
|
|
||||||
// target's name as well, so a rejected submission can be shown to
|
|
||||||
// have written nothing at all rather than merely to have left
|
|
||||||
// max_attempts alone.
|
|
||||||
func editAttemptsForm(attempts string) url.Values {
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "renamed-by-edit")
|
|
||||||
form.Set("url", attemptsTargetURL)
|
|
||||||
|
|
||||||
if attempts != absentField {
|
|
||||||
form.Set("max_attempts", attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertEditRejectedAndUnchanged submits an edit expected to fail and
|
|
||||||
// checks both halves of the requirement: the 400 explains itself, and
|
|
||||||
// the target it was submitted against is untouched.
|
|
||||||
func assertEditRejectedAndUnchanged(
|
|
||||||
t *testing.T,
|
|
||||||
env *sourceTestEnv,
|
|
||||||
attempts, wantReason string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhook, target := seedAttemptsTarget(t, env)
|
|
||||||
|
|
||||||
w := submitTargetEdit(
|
|
||||||
env, webhook.ID, target.ID, editAttemptsForm(attempts),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code,
|
|
||||||
"max_attempts=%q should be rejected on edit", attempts)
|
|
||||||
assert.Contains(t, w.Body.String(), wantReason,
|
|
||||||
"the rejection for %q should say why", attempts)
|
|
||||||
|
|
||||||
stored := storedTarget(t, env, target.ID)
|
|
||||||
assert.Equal(t, workingAttempts, stored.MaxAttempts,
|
|
||||||
"a rejected edit must not destroy the working number "+
|
|
||||||
"of attempts with %q", attempts)
|
|
||||||
assert.Equal(t, "attempts-target", stored.Name,
|
|
||||||
"a rejected edit must write nothing at all")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetEdit_UnparseableAttemptsRejected is the damaging half of
|
|
||||||
// the defect. A target delivering with retries, re-saved with a typo
|
|
||||||
// in the field, returned 200 and was left with retries disabled and
|
|
||||||
// nothing said.
|
|
||||||
func TestTargetEdit_UnparseableAttemptsRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range unparseableAttempts() {
|
|
||||||
assertEditRejectedAndUnchanged(
|
|
||||||
t, env, attempts, "whole number",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetEdit_AttemptsBelowOneRejected proves the floor applies
|
|
||||||
// to the edit path too.
|
|
||||||
func TestTargetEdit_AttemptsBelowOneRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range belowOneAttempts() {
|
|
||||||
assertEditRejectedAndUnchanged(t, env, attempts, "at least 1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetEdit_AttemptsAboveCeilingRejected proves the ceiling
|
|
||||||
// applies to the edit path too, naming itself, so the two paths
|
|
||||||
// cannot disagree about what is storable.
|
|
||||||
func TestTargetEdit_AttemptsAboveCeilingRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest)
|
|
||||||
|
|
||||||
for _, attempts := range overCeilingAttempts() {
|
|
||||||
assertEditRejectedAndUnchanged(t, env, attempts, ceiling)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetEdit_ValidAttemptsStored covers the accepting half of the
|
|
||||||
// edit path, so the bounds cannot be enforced by simply refusing
|
|
||||||
// every submission.
|
|
||||||
func TestTargetEdit_ValidAttemptsStored(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, want := range []int{1, 9, handlers.MaxTargetAttemptsForTest} {
|
|
||||||
webhook, target := seedAttemptsTarget(t, env)
|
|
||||||
|
|
||||||
w := submitTargetEdit(
|
|
||||||
env, webhook.ID, target.ID,
|
|
||||||
editAttemptsForm(strconv.Itoa(want)),
|
|
||||||
)
|
|
||||||
require.Equal(t,
|
|
||||||
http.StatusSeeOther, w.Code, w.Body.String(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, want,
|
|
||||||
storedTarget(t, env, target.ID).MaxAttempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetEdit_AbsentAttemptsLeavesStoredValue is the edit path's
|
|
||||||
// absent-versus-invalid case. The field is only offered by the forms
|
|
||||||
// for types that retry, so a submission without it must leave the
|
|
||||||
// stored count alone rather than be rejected or reset.
|
|
||||||
func TestTargetEdit_AbsentAttemptsLeavesStoredValue(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
for _, attempts := range absentAttempts() {
|
|
||||||
webhook, target := seedAttemptsTarget(t, env)
|
|
||||||
|
|
||||||
w := submitTargetEdit(
|
|
||||||
env, webhook.ID, target.ID,
|
|
||||||
editAttemptsForm(attempts),
|
|
||||||
)
|
|
||||||
require.Equal(t,
|
|
||||||
http.StatusSeeOther, w.Code, w.Body.String(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, workingAttempts,
|
|
||||||
storedTarget(t, env, target.ID).MaxAttempts,
|
|
||||||
"an absent max_attempts must leave the stored "+
|
|
||||||
"count alone (%q)", attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTargetAttempts_CreateAndEditAgreeOnEveryCase proves the two
|
|
||||||
// paths cannot disagree, which is what let the create form and the
|
|
||||||
// edit form drift apart in the first place. Every input is submitted
|
|
||||||
// to both and the accept/reject verdicts are compared.
|
|
||||||
func TestTargetAttempts_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
accepted := []string{
|
|
||||||
"1", "2",
|
|
||||||
strconv.Itoa(handlers.MaxTargetAttemptsForTest),
|
|
||||||
}
|
|
||||||
overCeiling := overCeilingAttempts()
|
|
||||||
belowOne := belowOneAttempts()
|
|
||||||
unparseable := unparseableAttempts()
|
|
||||||
|
|
||||||
cases := make(
|
|
||||||
[]string, 0,
|
|
||||||
len(accepted)+len(overCeiling)+len(belowOne)+
|
|
||||||
len(unparseable),
|
|
||||||
)
|
|
||||||
cases = append(cases, accepted...)
|
|
||||||
cases = append(cases, overCeiling...)
|
|
||||||
cases = append(cases, belowOne...)
|
|
||||||
cases = append(cases, unparseable...)
|
|
||||||
|
|
||||||
for _, attempts := range cases {
|
|
||||||
_, createCode, _ := createWithAttempts(t, env, attempts)
|
|
||||||
|
|
||||||
webhook, target := seedAttemptsTarget(t, env)
|
|
||||||
editCode := submitTargetEdit(
|
|
||||||
env, webhook.ID, target.ID,
|
|
||||||
editAttemptsForm(attempts),
|
|
||||||
).Code
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
createCode == http.StatusBadRequest,
|
|
||||||
editCode == http.StatusBadRequest,
|
|
||||||
"create and edit must agree on max_attempts=%q "+
|
|
||||||
"(create %d, edit %d)",
|
|
||||||
attempts, createCode, editCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -105,7 +105,6 @@ func (h *Handlers) newTarget(
|
|||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
Type: in.Type,
|
Type: in.Type,
|
||||||
Active: true,
|
Active: true,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errMsg, err := h.setTargetFromForm(ctx, target, in)
|
errMsg, err := h.setTargetFromForm(ctx, target, in)
|
||||||
@@ -118,10 +117,9 @@ func (h *Handlers) newTarget(
|
|||||||
|
|
||||||
// setTargetFromForm validates a target form against the target's type
|
// setTargetFromForm validates a target form against the target's type
|
||||||
// and, when it accepts it, sets the target's name, configuration and
|
// and, when it accepts it, sets the target's name, configuration and
|
||||||
// number of delivery attempts from it. It returns the message the form
|
// retry count from it. It returns the message the form shows for
|
||||||
// shows for anything it refuses, an unknown type among them, and then
|
// anything it refuses, an unknown type among them, and then leaves the
|
||||||
// leaves the target unchanged; an error is the server's fault, as for
|
// target unchanged; an error is the server's fault, as for newTarget.
|
||||||
// newTarget.
|
|
||||||
// The add target form and the target edit form both go through here,
|
// The add target form and the target edit form both go through here,
|
||||||
// so the two cannot come to disagree about what a target may be.
|
// so the two cannot come to disagree about what a target may be.
|
||||||
func (h *Handlers) setTargetFromForm(
|
func (h *Handlers) setTargetFromForm(
|
||||||
@@ -138,20 +136,20 @@ func (h *Handlers) setTargetFromForm(
|
|||||||
return errMsg, err
|
return errMsg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty max_attempts keeps the target's count: the default of
|
// An empty max_retries keeps the target's count: the
|
||||||
// 1 for a new target, and the stored count for an edited one,
|
// fire-and-forget default of 0 for a new target, and the stored
|
||||||
// since the forms for target types that do not retry have no such
|
// count for an edited one, since the forms for target types that
|
||||||
// field. A value that is filled in but invalid is refused rather
|
// do not retry have no such field. A value that is filled in but
|
||||||
// than becoming that count, so a typo cannot destroy the count a
|
// invalid is refused rather than becoming that count, so a typo
|
||||||
// target is delivering with.
|
// cannot destroy the count a target is delivering with.
|
||||||
maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts)
|
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil
|
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
target.Name = in.Name
|
target.Name = in.Name
|
||||||
target.Config = configJSON
|
target.Config = configJSON
|
||||||
target.MaxAttempts = maxAttempts
|
target.MaxRetries = maxRetries
|
||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -178,8 +176,8 @@ type targetFormInput struct {
|
|||||||
// ForwardQuery is an HTTP target's checkbox that passes each
|
// ForwardQuery is an HTTP target's checkbox that passes each
|
||||||
// event's query string on to it.
|
// event's query string on to it.
|
||||||
ForwardQuery bool
|
ForwardQuery bool
|
||||||
// MaxAttempts is an HTTP or Slack target's max_attempts.
|
// MaxRetries is an HTTP or Slack target's max_retries.
|
||||||
MaxAttempts string
|
MaxRetries string
|
||||||
// Expiry is a database (archive) target's row expiry.
|
// Expiry is a database (archive) target's row expiry.
|
||||||
Expiry string
|
Expiry string
|
||||||
// Rotation is a database (archive) target's rotation.
|
// Rotation is a database (archive) target's rotation.
|
||||||
@@ -206,7 +204,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
|
|||||||
Headers: r.PostFormValue("headers"),
|
Headers: r.PostFormValue("headers"),
|
||||||
Timeout: r.PostFormValue("timeout"),
|
Timeout: r.PostFormValue("timeout"),
|
||||||
ForwardQuery: r.PostFormValue("forward_query") != "",
|
ForwardQuery: r.PostFormValue("forward_query") != "",
|
||||||
MaxAttempts: r.PostFormValue("max_attempts"),
|
MaxRetries: r.PostFormValue("max_retries"),
|
||||||
Expiry: r.PostFormValue("expiry"),
|
Expiry: r.PostFormValue("expiry"),
|
||||||
Rotation: r.PostFormValue("rotation"),
|
Rotation: r.PostFormValue("rotation"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func TestHandleTargetCreate_QueryStringCannotSupplyNameOrType(
|
|||||||
|
|
||||||
w, _ := postTargetCreate(
|
w, _ := postTargetCreate(
|
||||||
t, env, webhook.ID,
|
t, env, webhook.ID,
|
||||||
"name=leaky&type=slack&max_attempts=9&expiry=30d&url="+
|
"name=leaky&type=slack&max_retries=9&expiry=30d&url="+
|
||||||
url.QueryEscape(targetSecretURL),
|
url.QueryEscape(targetSecretURL),
|
||||||
url.Values{},
|
url.Values{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,21 +24,21 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
|
|||||||
targetType database.TargetType
|
targetType database.TargetType
|
||||||
fields string
|
fields string
|
||||||
wantConfig string
|
wantConfig string
|
||||||
wantAttempts int
|
wantRetries int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
database.TargetTypeHTTP, "timeout=12&max_attempts=3",
|
database.TargetTypeHTTP, "timeout=12&max_retries=3",
|
||||||
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
|
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeSlack, "max_attempts=4",
|
database.TargetTypeSlack, "max_retries=4",
|
||||||
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
|
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeDatabase, "expiry=720h",
|
database.TargetTypeDatabase, "expiry=720h",
|
||||||
`{"expiry":"720h"}`, 1,
|
`{"expiry":"720h"}`, 0,
|
||||||
},
|
},
|
||||||
{database.TargetTypeLog, "", "", 1},
|
{database.TargetTypeLog, "", "", 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -62,7 +62,7 @@ func TestHandleTargetCreate_EveryType(t *testing.T) {
|
|||||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
require.Len(t, targets, 1)
|
require.Len(t, targets, 1)
|
||||||
assert.Equal(t, tc.targetType, targets[0].Type)
|
assert.Equal(t, tc.targetType, targets[0].Type)
|
||||||
assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts)
|
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
|
||||||
|
|
||||||
if tc.wantConfig == "" {
|
if tc.wantConfig == "" {
|
||||||
assert.Empty(t, targets[0].Config)
|
assert.Empty(t, targets[0].Config)
|
||||||
@@ -90,11 +90,11 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
|
|||||||
{
|
{
|
||||||
database.TargetTypeHTTP,
|
database.TargetTypeHTTP,
|
||||||
"name=private&url=" + editBlockedURL +
|
"name=private&url=" + editBlockedURL +
|
||||||
"&timeout=12&max_attempts=3",
|
"&timeout=12&max_retries=3",
|
||||||
"Invalid target URL",
|
"Invalid target URL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeSlack, "name=no-url&max_attempts=4",
|
database.TargetTypeSlack, "name=no-url&max_retries=4",
|
||||||
"Webhook URL is required for Slack targets",
|
"Webhook URL is required for Slack targets",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,8 +133,8 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
|
|||||||
assert.Contains(t, page, html.EscapeString(tc.reason))
|
assert.Contains(t, page, html.EscapeString(tc.reason))
|
||||||
|
|
||||||
// Each value comes back in a data attribute of the targets
|
// Each value comes back in a data attribute of the targets
|
||||||
// section named after its field (max_attempts as
|
// section named after its field (max_retries as
|
||||||
// data-max-attempts), except url, which comes back in
|
// data-max-retries), except url, which comes back in
|
||||||
// data-destination; templates/source_detail.html says why.
|
// data-destination; templates/source_detail.html says why.
|
||||||
for field := range typed {
|
for field := range typed {
|
||||||
attr := "data-" + strings.ReplaceAll(field, "_", "-")
|
attr := "data-" + strings.ReplaceAll(field, "_", "-")
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
|||||||
Headers: cfg.Headers,
|
Headers: cfg.Headers,
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
ForwardQuery: cfg.ForwardQuery,
|
ForwardQuery: cfg.ForwardQuery,
|
||||||
MaxAttempts: strconv.Itoa(target.MaxAttempts),
|
MaxRetries: strconv.Itoa(target.MaxRetries),
|
||||||
Expiry: cfg.Expiry,
|
Expiry: cfg.Expiry,
|
||||||
Rotation: cfg.Rotation,
|
Rotation: cfg.Rotation,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func seedHTTPTarget(
|
|||||||
form.Set("url", editOriginalURL)
|
form.Set("url", editOriginalURL)
|
||||||
form.Set("headers", headers)
|
form.Set("headers", headers)
|
||||||
form.Set("timeout", timeout)
|
form.Set("timeout", timeout)
|
||||||
form.Set("max_attempts", "3")
|
form.Set("max_retries", "3")
|
||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
@@ -179,7 +179,7 @@ func editForm(targetURL, headers, timeout string) url.Values {
|
|||||||
form.Set("url", targetURL)
|
form.Set("url", targetURL)
|
||||||
form.Set("headers", headers)
|
form.Set("headers", headers)
|
||||||
form.Set("timeout", timeout)
|
form.Set("timeout", timeout)
|
||||||
form.Set("max_attempts", "5")
|
form.Set("max_retries", "5")
|
||||||
|
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
@@ -280,9 +280,9 @@ func TestHandleTargetEditSubmit_Saves(t *testing.T) {
|
|||||||
assertEditClearingRemovesThem(t, env)
|
assertEditClearingRemovesThem(t, env)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("absent max_attempts is kept", func(t *testing.T) {
|
t.Run("absent max_retries is not zeroed", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
assertEditKeepsAbsentMaxAttempts(t, env)
|
assertEditKeepsAbsentMaxRetries(t, env)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ func assertEditChangesDestination(
|
|||||||
|
|
||||||
reloaded := storedTarget(t, env, target.ID)
|
reloaded := storedTarget(t, env, target.ID)
|
||||||
assert.Equal(t, "edited-name", reloaded.Name)
|
assert.Equal(t, "edited-name", reloaded.Name)
|
||||||
assert.Equal(t, 5, reloaded.MaxAttempts)
|
assert.Equal(t, 5, reloaded.MaxRetries)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, database.TargetTypeHTTP, reloaded.Type,
|
t, database.TargetTypeHTTP, reloaded.Type,
|
||||||
"the edit form must not change a target's type",
|
"the edit form must not change a target's type",
|
||||||
@@ -368,25 +368,25 @@ func assertEditClearingRemovesThem(
|
|||||||
assert.Zero(t, cfg.Timeout)
|
assert.Zero(t, cfg.Timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery attempts are offered only by the forms for target types
|
// Retries are offered only by the forms for target types that retry.
|
||||||
// that retry. An absent field means the form does not edit them, not
|
// An absent field means the form does not edit retries, not that
|
||||||
// that retries should be turned off.
|
// they should be turned off.
|
||||||
func assertEditKeepsAbsentMaxAttempts(
|
func assertEditKeepsAbsentMaxRetries(
|
||||||
t *testing.T, env *sourceTestEnv,
|
t *testing.T, env *sourceTestEnv,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||||
require.Equal(t, 3, target.MaxAttempts)
|
require.Equal(t, 3, target.MaxRetries)
|
||||||
|
|
||||||
form := editForm(editOriginalURL, "", "")
|
form := editForm(editOriginalURL, "", "")
|
||||||
form.Del("max_attempts")
|
form.Del("max_retries")
|
||||||
|
|
||||||
w := submitTargetEdit(env, webhook.ID, target.ID, form)
|
w := submitTargetEdit(env, webhook.ID, target.ID, form)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, 3, storedTarget(t, env, target.ID).MaxAttempts,
|
t, 3, storedTarget(t, env, target.ID).MaxRetries,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,12 +659,12 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|||||||
{
|
{
|
||||||
database.TargetTypeHTTP,
|
database.TargetTypeHTTP,
|
||||||
"name=edited&url=" + editBlockedURL +
|
"name=edited&url=" + editBlockedURL +
|
||||||
"&headers=X-Edited:+kept&timeout=12&max_attempts=3",
|
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
|
||||||
"Invalid target URL",
|
"Invalid target URL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeSlack,
|
database.TargetTypeSlack,
|
||||||
"name=edited&url=" + editOriginalURL + "&max_attempts=25",
|
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
||||||
"Invalid delivery attempts",
|
"Invalid delivery attempts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxTargetRetries bounds a target's max_retries.
|
||||||
|
//
|
||||||
|
// Both target forms already declare max="20" on the input, so this
|
||||||
|
// enforces server-side what the UI has always advertised rather than
|
||||||
|
// introducing a new limit.
|
||||||
|
//
|
||||||
|
// The number is not cosmetic. Every attempt writes a delivery_results
|
||||||
|
// row that the event log then loads and renders, and the engine backs
|
||||||
|
// off by 2^(n-1) seconds, so attempt 20 is already about six days
|
||||||
|
// after the first. A value beyond this buys no additional durability
|
||||||
|
// and only costs rows.
|
||||||
|
const maxTargetRetries = 20
|
||||||
|
|
||||||
|
// Errors returned when a max_retries form value cannot be turned into
|
||||||
|
// a retry count.
|
||||||
|
var (
|
||||||
|
// errRetriesInvalid signals a max_retries form value that is not
|
||||||
|
// a non-negative whole number.
|
||||||
|
errRetriesInvalid = errors.New(
|
||||||
|
"must be a whole number",
|
||||||
|
)
|
||||||
|
|
||||||
|
// errRetriesTooLarge signals a max_retries form value that is a
|
||||||
|
// whole number but above maxTargetRetries. It is distinguished
|
||||||
|
// from errRetriesInvalid so the message can name the ceiling
|
||||||
|
// instead of implying the input was not a number.
|
||||||
|
errRetriesTooLarge = errors.New("out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseMaxRetries interprets a max_retries form value.
|
||||||
|
//
|
||||||
|
// An ABSENT value — the field empty or not submitted — yields
|
||||||
|
// fallback, which lets the create path apply its default and the edit
|
||||||
|
// path leave the stored value alone. A value that is SET BUT INVALID
|
||||||
|
// is an error: unparseable, negative, or above maxTargetRetries.
|
||||||
|
//
|
||||||
|
// The distinction is the whole point of this function. max_retries=0
|
||||||
|
// means fire-and-forget, so returning 0 for input the operator typed
|
||||||
|
// but that did not parse silently disables retries on a
|
||||||
|
// store-and-forward proxy — and on the edit path it destroys a
|
||||||
|
// working retry configuration over a typo. A default answers a
|
||||||
|
// question that was not asked; it never answers one that was asked
|
||||||
|
// badly.
|
||||||
|
//
|
||||||
|
// A target stored with a count above the ceiling before this
|
||||||
|
// validation existed keeps rendering and keeps delivering — nothing
|
||||||
|
// clamps the row. Re-saving it from the edit form does have to bring
|
||||||
|
// it into range, because the form submits the pre-filled value back
|
||||||
|
// and accepting it would be the ceiling not applying to the edit
|
||||||
|
// path. The 400 names the ceiling, so the fix is one field.
|
||||||
|
func parseMaxRetries(raw string, fallback int) (int, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
return 0, errRetriesInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if v > maxTargetRetries {
|
||||||
|
return 0, errRetriesTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retriesErrorMessage returns the message the create and edit forms
|
||||||
|
// show for a rejected max_retries value. Any error other than
|
||||||
|
// errRetriesTooLarge falls back to the generic wording, so an
|
||||||
|
// unrecognised parse failure still produces a sensible 400.
|
||||||
|
func retriesErrorMessage(err error) string {
|
||||||
|
if errors.Is(err, errRetriesTooLarge) {
|
||||||
|
return errRetriesTooLarge.Error() +
|
||||||
|
": at most " + strconv.Itoa(maxTargetRetries) +
|
||||||
|
" attempts"
|
||||||
|
}
|
||||||
|
|
||||||
|
return errRetriesInvalid.Error() +
|
||||||
|
", or 0 for fire-and-forget"
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retriesTargetURL is the destination the retry-validation targets
|
||||||
|
// point at. It is a literal public address rather than a hostname so
|
||||||
|
// the SSRF check resolves nothing and a sandbox without DNS cannot
|
||||||
|
// make these cases pass or fail for the wrong reason.
|
||||||
|
const retriesTargetURL = "https://93.184.216.34/hooks/retries"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// wayAboveCeiling is the typo'd-extra-zero case from the report.
|
||||||
|
wayAboveCeiling = "999999999"
|
||||||
|
|
||||||
|
// notANumber is the plainest garbage an operator can type, and
|
||||||
|
// the value the report submitted on the edit form.
|
||||||
|
notANumber = "abc"
|
||||||
|
|
||||||
|
// workingRetries is the retry count a seeded target is already
|
||||||
|
// delivering with, which a rejected submission must not disturb.
|
||||||
|
workingRetries = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// aboveCeiling is the smallest rejected whole number.
|
||||||
|
func aboveCeiling() string {
|
||||||
|
return strconv.Itoa(handlers.MaxTargetRetriesForTest + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// overCeilingRetries is whole-number input past the limit, which is
|
||||||
|
// rejected with the limit named.
|
||||||
|
func overCeilingRetries() []string {
|
||||||
|
return []string{aboveCeiling(), wayAboveCeiling}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unparseableRetries is input an operator can type into the field
|
||||||
|
// that is not a retry count. Each must be REJECTED: silently reading
|
||||||
|
// any of them as 0 turns a store-and-forward proxy into
|
||||||
|
// fire-and-forget without saying so.
|
||||||
|
//
|
||||||
|
// The twenty-digit case is here because it parses as digits but
|
||||||
|
// overflows int, which is the one failure the field's own min/max
|
||||||
|
// attributes cannot describe.
|
||||||
|
func unparseableRetries() []string {
|
||||||
|
return []string{
|
||||||
|
notANumber,
|
||||||
|
"2.7",
|
||||||
|
"-5",
|
||||||
|
"12345678901234567890",
|
||||||
|
"1e3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRetriesForm is a complete, otherwise-valid HTTP target
|
||||||
|
// creation, so the only thing any case below varies is max_retries.
|
||||||
|
func createRetriesForm(retries string) url.Values {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "retries-target")
|
||||||
|
form.Set("type", string(database.TargetTypeHTTP))
|
||||||
|
form.Set("url", retriesTargetURL)
|
||||||
|
|
||||||
|
if retries != absentField {
|
||||||
|
form.Set("max_retries", retries)
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// absentField marks a field the form does not submit at all, which is
|
||||||
|
// the case that legitimately takes a default and must stay distinct
|
||||||
|
// from a field submitted with garbage in it.
|
||||||
|
const absentField = "\x00absent"
|
||||||
|
|
||||||
|
// absentRetries is every way of saying "the operator did not set
|
||||||
|
// this", each of which takes the default rather than a 400. Blank and
|
||||||
|
// whitespace-only count as absent here because they do in the timeout
|
||||||
|
// and retention controls on the same forms; a rule the fields do not
|
||||||
|
// share would be its own surprise.
|
||||||
|
func absentRetries() []string {
|
||||||
|
return []string{absentField, "", " "}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWithRetries posts the target create form for a fresh webhook
|
||||||
|
// and returns the webhook and the response.
|
||||||
|
func createWithRetries(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
retries string,
|
||||||
|
) (database.Webhook, int, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets",
|
||||||
|
createRetriesForm(retries),
|
||||||
|
)
|
||||||
|
|
||||||
|
return webhook, w.Code, w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_RetriesAboveCeilingRejected proves the create form
|
||||||
|
// enforces a ceiling at all, and that the 400 names it — a rejection
|
||||||
|
// that does not say what the limit is leaves the operator guessing.
|
||||||
|
func TestTargetCreate_RetriesAboveCeilingRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
||||||
|
|
||||||
|
for _, retries := range overCeilingRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code,
|
||||||
|
"max_retries=%s should be rejected", retries)
|
||||||
|
assert.Contains(t, body, ceiling,
|
||||||
|
"the rejection for %s should name the ceiling",
|
||||||
|
retries)
|
||||||
|
assert.Empty(t,
|
||||||
|
targetsForWebhook(t, env.db, webhook.ID),
|
||||||
|
"no target should be created for %s", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_UnparseableRetriesRejected is the core of the
|
||||||
|
// defect: each of these was accepted with HTTP 200 and stored as 0.
|
||||||
|
func TestTargetCreate_UnparseableRetriesRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range unparseableRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code,
|
||||||
|
"max_retries=%q should be rejected, not coerced",
|
||||||
|
retries)
|
||||||
|
assert.Contains(t, body, "whole number",
|
||||||
|
"the rejection for %q should say why", retries)
|
||||||
|
assert.Empty(t,
|
||||||
|
targetsForWebhook(t, env.db, webhook.ID),
|
||||||
|
"no target should be created for %q", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_ValidRetriesStored covers the accepting half,
|
||||||
|
// including the ceiling itself: a bound that rejects its own limit
|
||||||
|
// would make the advertised maximum unreachable.
|
||||||
|
func TestTargetCreate_ValidRetriesStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, want := range []int{0, 3, handlers.MaxTargetRetriesForTest} {
|
||||||
|
webhook, code, body := createWithRetries(
|
||||||
|
t, env, strconv.Itoa(want),
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, want, targets[0].MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetCreate_AbsentRetriesTakesDefault keeps the two cases
|
||||||
|
// distinct. An omitted field is not an operator asking for something
|
||||||
|
// invalid, so it still gets the fire-and-forget default rather than a
|
||||||
|
// 400 — otherwise the fix above would make the form unusable.
|
||||||
|
func TestTargetCreate_AbsentRetriesTakesDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range absentRetries() {
|
||||||
|
webhook, code, body := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, 0, targets[0].MaxRetries,
|
||||||
|
"an absent max_retries should take the default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRetriesTarget creates an HTTP target already delivering with
|
||||||
|
// workingRetries retries, through the real create handler.
|
||||||
|
func seedRetriesTarget(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
) (database.Webhook, database.Target) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook, code, body := createWithRetries(
|
||||||
|
t, env, strconv.Itoa(workingRetries),
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, code, body)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
require.Equal(t, workingRetries, targets[0].MaxRetries)
|
||||||
|
|
||||||
|
return webhook, targets[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// editRetriesForm is a complete edit submission that changes the
|
||||||
|
// target's name as well, so a rejected submission can be shown to
|
||||||
|
// have written nothing at all rather than merely to have left
|
||||||
|
// max_retries alone.
|
||||||
|
func editRetriesForm(retries string) url.Values {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "renamed-by-edit")
|
||||||
|
form.Set("url", retriesTargetURL)
|
||||||
|
|
||||||
|
if retries != absentField {
|
||||||
|
form.Set("max_retries", retries)
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEditRejectedAndUnchanged submits an edit expected to fail and
|
||||||
|
// checks both halves of the requirement: the 400 explains itself, and
|
||||||
|
// the target it was submitted against is untouched.
|
||||||
|
func assertEditRejectedAndUnchanged(
|
||||||
|
t *testing.T,
|
||||||
|
env *sourceTestEnv,
|
||||||
|
retries, wantReason string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID, editRetriesForm(retries),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code,
|
||||||
|
"max_retries=%q should be rejected on edit", retries)
|
||||||
|
assert.Contains(t, w.Body.String(), wantReason,
|
||||||
|
"the rejection for %q should say why", retries)
|
||||||
|
|
||||||
|
stored := storedTarget(t, env, target.ID)
|
||||||
|
assert.Equal(t, workingRetries, stored.MaxRetries,
|
||||||
|
"a rejected edit must not destroy the working retry "+
|
||||||
|
"count with %q", retries)
|
||||||
|
assert.Equal(t, "retries-target", stored.Name,
|
||||||
|
"a rejected edit must write nothing at all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_UnparseableRetriesRejected is the damaging half of
|
||||||
|
// the defect. A target delivering with two retries, re-saved with a
|
||||||
|
// typo in the field, returned 200 and was left with retries disabled
|
||||||
|
// and nothing said.
|
||||||
|
func TestTargetEdit_UnparseableRetriesRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range unparseableRetries() {
|
||||||
|
assertEditRejectedAndUnchanged(
|
||||||
|
t, env, retries, "whole number",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_RetriesAboveCeilingRejected proves the ceiling
|
||||||
|
// applies to the edit path too, naming itself, so the two paths
|
||||||
|
// cannot disagree about what is storable.
|
||||||
|
func TestTargetEdit_RetriesAboveCeilingRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
||||||
|
|
||||||
|
for _, retries := range overCeilingRetries() {
|
||||||
|
assertEditRejectedAndUnchanged(t, env, retries, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_ValidRetriesStored covers the accepting half of the
|
||||||
|
// edit path, so the ceiling cannot be enforced by simply refusing
|
||||||
|
// every submission.
|
||||||
|
func TestTargetEdit_ValidRetriesStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, want := range []int{0, 9, handlers.MaxTargetRetriesForTest} {
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(strconv.Itoa(want)),
|
||||||
|
)
|
||||||
|
require.Equal(t,
|
||||||
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, want,
|
||||||
|
storedTarget(t, env, target.ID).MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetEdit_AbsentRetriesLeavesStoredValue is the edit path's
|
||||||
|
// absent-versus-invalid case. Retries are only offered by the forms
|
||||||
|
// for types that retry, so a submission without the field must leave
|
||||||
|
// the stored count alone rather than be rejected or zeroed.
|
||||||
|
func TestTargetEdit_AbsentRetriesLeavesStoredValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, retries := range absentRetries() {
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
|
||||||
|
w := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(retries),
|
||||||
|
)
|
||||||
|
require.Equal(t,
|
||||||
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, workingRetries,
|
||||||
|
storedTarget(t, env, target.ID).MaxRetries,
|
||||||
|
"an absent max_retries must leave the stored "+
|
||||||
|
"count alone (%q)", retries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRetries_CreateAndEditAgreeOnEveryCase proves the two
|
||||||
|
// paths cannot disagree, which is what let the create form and the
|
||||||
|
// edit form drift apart in the first place. Every input is submitted
|
||||||
|
// to both and the accept/reject verdicts are compared.
|
||||||
|
func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
accepted := []string{
|
||||||
|
"0", "1",
|
||||||
|
strconv.Itoa(handlers.MaxTargetRetriesForTest),
|
||||||
|
}
|
||||||
|
overCeiling := overCeilingRetries()
|
||||||
|
unparseable := unparseableRetries()
|
||||||
|
|
||||||
|
cases := make(
|
||||||
|
[]string, 0,
|
||||||
|
len(accepted)+len(overCeiling)+len(unparseable),
|
||||||
|
)
|
||||||
|
cases = append(cases, accepted...)
|
||||||
|
cases = append(cases, overCeiling...)
|
||||||
|
cases = append(cases, unparseable...)
|
||||||
|
|
||||||
|
for _, retries := range cases {
|
||||||
|
_, createCode, _ := createWithRetries(t, env, retries)
|
||||||
|
|
||||||
|
webhook, target := seedRetriesTarget(t, env)
|
||||||
|
editCode := submitTargetEdit(
|
||||||
|
env, webhook.ID, target.ID,
|
||||||
|
editRetriesForm(retries),
|
||||||
|
).Code
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
createCode == http.StatusBadRequest,
|
||||||
|
editCode == http.StatusBadRequest,
|
||||||
|
"create and edit must agree on max_retries=%q "+
|
||||||
|
"(create %d, edit %d)",
|
||||||
|
retries, createCode, editCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
|
|||||||
stored := storedTarget(t, env, tgt.ID)
|
stored := storedTarget(t, env, tgt.ID)
|
||||||
assert.False(t, stored.Active)
|
assert.False(t, stored.Active)
|
||||||
assert.Equal(t, "edited-name", stored.Name)
|
assert.Equal(t, "edited-name", stored.Name)
|
||||||
assert.Equal(t, 5, stored.MaxAttempts)
|
assert.Equal(t, 5, stored.MaxRetries)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
|
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||||
// retention copy to what the code does: the reaper permanently deletes
|
// retention copy to what the code does: the reaper permanently deletes
|
||||||
// events past the cutoff, an empty field falls back to
|
// events past the cutoff, an empty field falls back to
|
||||||
// DefaultRetentionDays, and 0 means retain forever.
|
// DefaultRetentionDays, and 0 is rewritten to the retain-forever
|
||||||
|
// sentinel by Webhook.BeforeSave.
|
||||||
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -252,7 +253,10 @@ func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|||||||
"blank means unchanged, not forever",
|
"blank means unchanged, not forever",
|
||||||
)
|
)
|
||||||
|
|
||||||
forever := &database.Webhook{Name: "wh", RetentionDays: 0}
|
forever := &database.Webhook{
|
||||||
|
Name: "wh",
|
||||||
|
RetentionDays: database.RetentionForeverDays,
|
||||||
|
}
|
||||||
forever.ID = "wh-2"
|
forever.ID = "wh-2"
|
||||||
|
|
||||||
foreverBody := renderPage(
|
foreverBody := renderPage(
|
||||||
@@ -344,19 +348,21 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxAttemptsHelp is the wording both target forms must carry. The
|
// maxRetriesHelp is the wording both target forms must carry. The
|
||||||
// delivery core makes max_attempts attempts in total, not that many
|
// delivery core makes max_retries attempts in total, not that many
|
||||||
// retries on top of a first try (a fresh delivery starts at attempt 1
|
// retries on top of a first try (a fresh delivery starts at attempt 1
|
||||||
// and target_http gives up once the attempt number reaches
|
// and target_http gives up once the attempt number reaches
|
||||||
// max_attempts).
|
// max_retries), and 0 is special-cased to a single fire-and-forget
|
||||||
const maxAttemptsHelp = "How many times each delivery is attempted in " +
|
// attempt with no circuit breaker.
|
||||||
"all, the first attempt included."
|
const maxRetriesHelp = "How many times each delivery is attempted in " +
|
||||||
|
"all, the first attempt included. 0 means a single attempt with no " +
|
||||||
|
"retries and no circuit breaker."
|
||||||
|
|
||||||
// TestTargetFormMaxAttemptsCopyMatchesBehaviour pins the max_attempts
|
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
||||||
// label, "Delivery attempts", and help text on both the create form
|
// label, "Delivery attempts", and help text on both the create form
|
||||||
// (the add-target form on the webhook detail page) and the edit form,
|
// (the add-target form on the webhook detail page) and the edit form,
|
||||||
// so the copy cannot drift back to calling the number a retry count.
|
// so the copy cannot drift back to calling the number a retry count.
|
||||||
func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
|
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
var h *handlers.Handlers
|
||||||
@@ -388,11 +394,11 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, createBody, "Delivery attempts:</label>")
|
assert.Contains(t, createBody, "Delivery attempts:</label>")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, createBody, maxAttemptsHelp,
|
t, createBody, maxRetriesHelp,
|
||||||
"the add-target form must explain max_attempts as total attempts",
|
"the add-target form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
|
|
||||||
// A slack target exercises the same max_attempts field while needing
|
// A slack target exercises the same max_retries field while needing
|
||||||
// only a URL from the edit template, so the test data stays
|
// only a URL from the edit template, so the test data stays
|
||||||
// minimal. The Target and TargetForm keys mirror the field names
|
// minimal. The Target and TargetForm keys mirror the field names
|
||||||
// the template reads off the handler's values.
|
// the template reads off the handler's values.
|
||||||
@@ -407,7 +413,7 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"TargetForm": map[string]any{
|
"TargetForm": map[string]any{
|
||||||
"URL": "https://hooks.slack.com/services/x",
|
"URL": "https://hooks.slack.com/services/x",
|
||||||
"MaxAttempts": "3",
|
"MaxRetries": "3",
|
||||||
},
|
},
|
||||||
dataKeyError: "",
|
dataKeyError: "",
|
||||||
},
|
},
|
||||||
@@ -415,8 +421,8 @@ func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
|
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, editBody, maxAttemptsHelp,
|
t, editBody, maxRetriesHelp,
|
||||||
"the target edit form must explain max_attempts as total attempts",
|
"the target edit form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,13 +49,14 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged only once the lookup has found the entrypoint, so
|
// Logged only once the UUID is known to name a real
|
||||||
// an invented path writes no INFO line; a miss is logged at
|
// entrypoint. The UUID comes straight out of the path on
|
||||||
// DEBUG. The entrypoint is named by its ID: the UUID is its
|
// the one unauthenticated endpoint, so logging it before
|
||||||
// credential and is never logged.
|
// the lookup let a client write an INFO line per invented
|
||||||
|
// path; the request itself is already in the access log
|
||||||
|
// and a miss is already logged at DEBUG.
|
||||||
h.log.Info("webhook request received",
|
h.log.Info("webhook request received",
|
||||||
"entrypoint_id", entrypoint.ID,
|
"entrypoint_uuid", entrypointUUID,
|
||||||
"webhook_id", entrypoint.WebhookID,
|
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"remoteIP", middleware.RemoteIP(r),
|
"remoteIP", middleware.RemoteIP(r),
|
||||||
"clientIP", middleware.ClientIP(r),
|
"clientIP", middleware.ClientIP(r),
|
||||||
@@ -131,10 +133,17 @@ func (h *Handlers) lookupEntrypoint(
|
|||||||
"path = ?", entrypointUUID,
|
"path = ?", entrypointUUID,
|
||||||
).First(&entrypoint)
|
).First(&entrypoint)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
// Not even an unknown UUID is logged. The receiver's log
|
// The receiver is unauthenticated and /h/{uuid}
|
||||||
// lines never carry the path segment, which is a credential
|
// matches any single segment, so this value is entirely
|
||||||
// whenever it names an entrypoint.
|
// client-chosen on exactly the branch where the lookup
|
||||||
h.log.Debug("entrypoint not found")
|
// failed. DEBUG is off by default; the cap is what keeps
|
||||||
|
// turning it on from restoring an unbounded write.
|
||||||
|
h.log.Debug(
|
||||||
|
"entrypoint not found",
|
||||||
|
"path", logfield.Truncate(
|
||||||
|
entrypointUUID, logfield.MaxBytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return entrypoint, false
|
return entrypoint, false
|
||||||
@@ -433,7 +442,7 @@ func buildDeliveryTasks(
|
|||||||
TargetName: targets[i].Name,
|
TargetName: targets[i].Name,
|
||||||
TargetType: targets[i].Type,
|
TargetType: targets[i].Type,
|
||||||
TargetConfig: targets[i].Config,
|
TargetConfig: targets[i].Config,
|
||||||
MaxAttempts: targets[i].MaxAttempts,
|
MaxRetries: targets[i].MaxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
RawQuery: event.RawQuery,
|
RawQuery: event.RawQuery,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{30, "30 days"},
|
{30, "30 days"},
|
||||||
{0, "forever"},
|
{database.RetentionForeverDays, "forever"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
||||||
@@ -102,87 +99,6 @@ func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleWebhook_LogsNoEntrypointUUID sends an accepted event, a
|
|
||||||
// request over the receiver rate limit and a request for an unknown
|
|
||||||
// UUID through the receiver as routes.go mounts it, and checks that
|
|
||||||
// nothing logged, at any level, carries either UUID.
|
|
||||||
func TestHandleWebhook_LogsNoEntrypointUUID(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg := &config.Config{DataDir: t.TempDir(), ReceiverRateLimit: 1}
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, cfg, &h, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
log := slog.New(slog.NewJSONHandler(
|
|
||||||
buf, &slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
))
|
|
||||||
h.SetLogForTest(log)
|
|
||||||
mw := middlewaretest.New(t, log, cfg, nil)
|
|
||||||
|
|
||||||
webhook := seedWebhook(t, db)
|
|
||||||
entrypoint := &database.Entrypoint{
|
|
||||||
WebhookID: webhook.ID,
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).
|
|
||||||
Create(entrypoint).Error)
|
|
||||||
|
|
||||||
unknownUUID := uuid.New().String()
|
|
||||||
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Use(mw.Logging())
|
|
||||||
router.With(mw.ReceiverRateLimit()).HandleFunc(
|
|
||||||
middleware.ReceiverRoute, h.HandleWebhook(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// In order: the limit is one request per minute per path, so the
|
|
||||||
// second request for the known UUID is the rejected one.
|
|
||||||
for _, step := range []struct {
|
|
||||||
entrypointUUID string
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{entrypoint.Path, http.StatusOK},
|
|
||||||
{entrypoint.Path, http.StatusTooManyRequests},
|
|
||||||
{unknownUUID, http.StatusNotFound},
|
|
||||||
} {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost,
|
|
||||||
"/h/"+step.entrypointUUID, strings.NewReader("{}"),
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, step.wantStatus, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The lines this test is about were all written, so the checks
|
|
||||||
// below are not passing on an empty log.
|
|
||||||
for _, msg := range []string{
|
|
||||||
"webhook request received",
|
|
||||||
"webhook receiver rate limit exceeded",
|
|
||||||
"entrypoint not found",
|
|
||||||
} {
|
|
||||||
assert.Contains(t, buf.String(), `"msg":"`+msg+`"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NotContains(t, buf.String(), entrypoint.Path)
|
|
||||||
assert.NotContains(t, buf.String(), unknownUUID)
|
|
||||||
|
|
||||||
line := receivedLine(t, buf)
|
|
||||||
assert.Equal(t, entrypoint.ID, line["entrypoint_id"])
|
|
||||||
assert.Equal(t, webhook.ID, line["webhook_id"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// receivedLine returns the one "webhook request received" line in the
|
// receivedLine returns the one "webhook request received" line in the
|
||||||
// captured JSON log.
|
// captured JSON log.
|
||||||
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -28,13 +27,6 @@ const floodRequests = 64
|
|||||||
// line for a redirected or rejected request may contain it.
|
// line for a redirected or rejected request may contain it.
|
||||||
const attackerMarker = "QQATTACKERTEXTQQ"
|
const attackerMarker = "QQATTACKERTEXTQQ"
|
||||||
|
|
||||||
// knownEntrypointUUID and failingEntrypointUUID are the UUIDs the
|
|
||||||
// stand-in receiver in accessLogRouter answers 200 and 500 to.
|
|
||||||
const (
|
|
||||||
knownEntrypointUUID = "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60"
|
|
||||||
failingEntrypointUUID = "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxLineBytes bounds a single access log line whose client-supplied
|
// maxLineBytes bounds a single access log line whose client-supplied
|
||||||
// fields are of ordinary size. Well above what the fixed fields need,
|
// fields are of ordinary size. Well above what the fixed fields need,
|
||||||
// well below the length of the oversized input the amplification tests
|
// well below the length of the oversized input the amplification tests
|
||||||
@@ -140,21 +132,17 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|||||||
)
|
)
|
||||||
|
|
||||||
router.HandleFunc(
|
router.HandleFunc(
|
||||||
middleware.ReceiverRoute,
|
"/h/{uuid}",
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Stands in for the real handler: a known entrypoint
|
// Stands in for the real handler: an unknown entrypoint
|
||||||
// UUID succeeds, a failing one is a server error, and
|
// UUID 404s, a known one succeeds.
|
||||||
// any other 404s.
|
if chi.URLParam(r, "uuid") != "known" {
|
||||||
switch chi.URLParam(r, "uuid") {
|
|
||||||
case knownEntrypointUUID:
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
case failingEntrypointUUID:
|
|
||||||
http.Error(
|
|
||||||
w, "boom", http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,13 +335,11 @@ func oversizedValue(ch string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// oversizedHeaders fills every client-supplied header the access log
|
// oversizedHeaders fills every client-supplied header the access log
|
||||||
// reads with the same value. The Referer carries it as the host of an
|
// reads with the same value.
|
||||||
// absolute URL, because the access log keeps only a Referer's scheme
|
|
||||||
// and host.
|
|
||||||
func oversizedHeaders(value string) map[string]string {
|
func oversizedHeaders(value string) map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"User-Agent": value,
|
"User-Agent": value,
|
||||||
"Referer": "http://" + value,
|
"Referer": value,
|
||||||
"X-Request-Id": value,
|
"X-Request-Id": value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,111 +649,17 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|||||||
router := accessLogRouter(m)
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK,
|
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
||||||
get(t, router, "/.well-known/healthcheck?src=ci"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The path resolved against a stored entrypoint, so it stays. The
|
||||||
|
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(
|
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
||||||
t, "/.well-known/healthcheck?(redacted)", entries[0]["url"],
|
|
||||||
)
|
|
||||||
assert.NotContains(t, buf.String(), "src=ci")
|
assert.NotContains(t, buf.String(), "src=ci")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAccessLog_ReceiverNeverLogsTheUUID covers the two statuses that
|
|
||||||
// keep the concrete path on every other route. On the receiver that
|
|
||||||
// path carries the entrypoint UUID, so the line names the route
|
|
||||||
// pattern instead.
|
|
||||||
func TestAccessLog_ReceiverNeverLogsTheUUID(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for entrypointUUID, wantStatus := range map[string]int{
|
|
||||||
knownEntrypointUUID: http.StatusOK,
|
|
||||||
failingEntrypointUUID: http.StatusInternalServerError,
|
|
||||||
} {
|
|
||||||
t.Run(strconv.Itoa(wantStatus), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, buf := capturingMiddleware(t)
|
|
||||||
router := accessLogRouter(m)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, wantStatus,
|
|
||||||
get(t, router, "/h/"+entrypointUUID+"?src=ci"),
|
|
||||||
)
|
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.Equal(t, middleware.ReceiverRoute, entries[0]["url"])
|
|
||||||
assert.NotContains(t, buf.String(), entrypointUUID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAccessLog_ReceiverPreflightNeverLogsTheUUID sends a CORS
|
|
||||||
// preflight to the receiver in dev, in the production middleware
|
|
||||||
// order. CORS answers it with 200 before chi routes it, so the line
|
|
||||||
// names no route and carries no UUID.
|
|
||||||
func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, buf := capturingMiddleware(t)
|
|
||||||
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Use(m.Logging())
|
|
||||||
router.Use(m.CORS())
|
|
||||||
router.HandleFunc(
|
|
||||||
middleware.ReceiverRoute,
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodOptions,
|
|
||||||
"/h/"+knownEntrypointUUID, nil,
|
|
||||||
)
|
|
||||||
req.Header.Set("Origin", "https://sender.example")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.Equal(t, unmatchedRouteLiteral, entries[0]["url"])
|
|
||||||
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose
|
|
||||||
// Referer is the entrypoint URL, as a sender's is after it follows a
|
|
||||||
// redirect from http:// to https://. The referer field keeps only the
|
|
||||||
// scheme and host, and a Referer without them is logged as a marker.
|
|
||||||
func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, buf := capturingMiddleware(t)
|
|
||||||
router := accessLogRouter(m)
|
|
||||||
|
|
||||||
for _, referer := range []string{
|
|
||||||
"http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci",
|
|
||||||
"/h/" + knownEntrypointUUID,
|
|
||||||
} {
|
|
||||||
assert.Equal(t, http.StatusOK, getWithHeaders(
|
|
||||||
t, router, "/h/"+knownEntrypointUUID,
|
|
||||||
map[string]string{"Referer": referer},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
|
||||||
require.Len(t, entries, 2)
|
|
||||||
assert.Equal(t, "http://webhooker.example", entries[0]["referer"])
|
|
||||||
assert.Equal(t, "(redacted)", entries[1]["referer"])
|
|
||||||
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ package middleware_test
|
|||||||
// the client's choosing.
|
// the client's choosing.
|
||||||
// - CSRF's 403 rejection, at WARN, also registered ahead of
|
// - CSRF's 403 rejection, at WARN, also registered ahead of
|
||||||
// RequireAuth.
|
// RequireAuth.
|
||||||
// - The rate limiters' 429 rejection, at WARN. On the receiver
|
// - The rate limiters' 429 rejection, at WARN, on the
|
||||||
// route it names the route pattern rather than the path, and
|
// unauthenticated receiver among others.
|
||||||
// the limiters that log a path sit behind RequireAuth, so its cap
|
|
||||||
// is defensive.
|
|
||||||
// - RequireAuth's own unauthenticated-request line, at DEBUG.
|
// - RequireAuth's own unauthenticated-request line, at DEBUG.
|
||||||
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is
|
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is
|
||||||
// defensive rather than load-bearing today: chi pins the one
|
// defensive rather than load-bearing today: chi pins the one
|
||||||
@@ -270,14 +268,10 @@ func logSites() map[string]logSite {
|
|||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
},
|
},
|
||||||
// The 429 line every limiter but the aggregate one shares.
|
// The per-entrypoint receiver limiter, unauthenticated. Its
|
||||||
// Mounted outside any route, as here, it logs the path, as
|
// bucket is keyed on the path, so the first request through a
|
||||||
// it does for the limiters behind RequireAuth; on
|
// fresh path is served and only the ones after it are
|
||||||
// ReceiverRoute it names the pattern instead. The
|
// rejected; sendUntilLimited absorbs that.
|
||||||
// per-entrypoint receiver limiter's bucket is keyed on the
|
|
||||||
// path, so the first request through a fresh path is served
|
|
||||||
// and only the ones after it are rejected; sendUntilLimited
|
|
||||||
// absorbs that.
|
|
||||||
"receiver rate limit 429": {
|
"receiver rate limit 429": {
|
||||||
build: func(
|
build: func(
|
||||||
t *testing.T, m *middleware.Middleware,
|
t *testing.T, m *middleware.Middleware,
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ const unmatchedMethod = unmatchedRoute
|
|||||||
// after /h/ is client-chosen, so labelling by path lets any
|
// after /h/ is client-chosen, so labelling by path lets any
|
||||||
// unauthenticated client mint permanent series at will, and publishes
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
// while doing it. The access log and the 429 line read it for the
|
// while doing it.
|
||||||
// same reason: see ReceiverRoute.
|
|
||||||
//
|
//
|
||||||
// chi populates the route context during routeHTTP, so this is only
|
// chi populates the route context during routeHTTP, so this is only
|
||||||
// valid once routing has run. Every caller below is on the recording
|
// valid once routing has run. Every caller below is on the recording
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
@@ -22,7 +23,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
"sneak.berlin/go/webhooker/internal/referer"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ const (
|
|||||||
// It is also the ceiling on every OTHER line this service writes
|
// It is also the ceiling on every OTHER line this service writes
|
||||||
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
||||||
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
||||||
// rejection, the unauthenticated-request DEBUG line, the
|
// rejection, the rate-limit rejection, the unauthenticated-request
|
||||||
// failed-login DEBUG lines, and the two login-throttle WARN
|
// and unknown-entrypoint DEBUG lines, the failed-login DEBUG
|
||||||
// lines ("login failure limit exceeded" in loginguard.go and
|
// lines, and the two login-throttle WARN lines ("login failure
|
||||||
// "password verification capacity exhausted" in
|
// limit exceeded" in loginguard.go and "password verification
|
||||||
// internal/handlers/auth.go) — spend the same per-field budgets,
|
// capacity exhausted" in internal/handlers/auth.go) — spend the
|
||||||
// and each carries strictly fewer client-supplied fields than the
|
// same per-field budgets, and each carries strictly fewer
|
||||||
// access log does, so none of them can reach a width the access
|
// client-supplied fields than the access log does,
|
||||||
// log cannot. That is
|
// so none of them can reach a width the access log cannot. That is
|
||||||
// asserted directly, per line and under both handlers, rather than
|
// asserted directly, per line and under both handlers, rather than
|
||||||
// left to the reasoning: see logbound_test.go in this package and
|
// left to the reasoning: see logbound_test.go in this package and
|
||||||
// in internal/handlers.
|
// in internal/handlers.
|
||||||
@@ -115,15 +115,16 @@ const (
|
|||||||
// a caller on a parameterised route would supply, which is the
|
// a caller on a parameterised route would supply, which is the
|
||||||
// only way those caps can be pinned at all.
|
// only way those caps can be pinned at all.
|
||||||
//
|
//
|
||||||
// The GORM adapter in internal/gormlog writes through slog as well.
|
// One writer in that set is not a handler's own slog call. The
|
||||||
// It logs each statement with its placeholders rather than the
|
// GORM adapter in internal/gormlog logs the statement with the
|
||||||
// values bound to them (see its ParamsFilter), so the key a client
|
// client-chosen parameter already interpolated into it, which on
|
||||||
// sends to the receiver or login lookup does not reach its lines.
|
// the receiver and login lookups is exactly the value the budgets
|
||||||
// Its widest line spends two logfield.MaxBytes budgets — the
|
// above exist for. Its widest line spends two logfield.MaxBytes
|
||||||
// statement and the driver error — against a fixed portion smaller
|
// budgets — the statement and the driver error — against a fixed
|
||||||
// than this one's, and internal/gormlog/gormlog_test.go asserts
|
// portion smaller than this one's, and
|
||||||
// every line it emits against this constant directly rather than
|
// internal/gormlog/gormlog_test.go asserts every line it emits
|
||||||
// leaving it as arithmetic.
|
// against this constant directly rather than leaving it as
|
||||||
|
// arithmetic.
|
||||||
//
|
//
|
||||||
// What it does NOT cover, so that the figure above is not read as
|
// What it does NOT cover, so that the figure above is not read as
|
||||||
// more than it is:
|
// more than it is:
|
||||||
@@ -150,12 +151,6 @@ const (
|
|||||||
MaxAccessLogLineBytes = 2560
|
MaxAccessLogLineBytes = 2560
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReceiverRoute is the route pattern the webhook receiver is served
|
|
||||||
// at. Its path carries the entrypoint UUID, which is the receiver's
|
|
||||||
// only credential, so the log lines this package writes name a
|
|
||||||
// request on this route by the pattern, never by its path.
|
|
||||||
const ReceiverRoute = "/h/{uuid}"
|
|
||||||
|
|
||||||
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
||||||
type MiddlewareParams struct {
|
type MiddlewareParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -273,14 +268,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// marker.
|
// marker.
|
||||||
//
|
//
|
||||||
// The path on those branches is bounded by the service's routes or by
|
// The path on those branches is bounded by the service's routes or by
|
||||||
// the operator's data — a 2xx under /s means the file is in the
|
// the operator's data — a 2xx on the receiver means the UUID named a
|
||||||
// embedded tree. The query is not bounded by anything:
|
// stored entrypoint, a 2xx under /s means the file is in the embedded
|
||||||
// /.well-known/healthcheck and /s/* take no authentication and sit
|
// tree. The query is not bounded by anything: /.well-known/healthcheck
|
||||||
// behind no rate limiter, and /pages/login behind only the login
|
// and /s/* take no authentication and sit behind no rate limiter, and
|
||||||
// limiter, so any of them will answer 200 to a URL carrying an
|
// /pages/login behind only the login limiter, so any of them will
|
||||||
// arbitrary number of arbitrary bytes after the '?'. Keeping the path
|
// answer 200 to a URL carrying an arbitrary number of arbitrary bytes
|
||||||
// and dropping the query is what makes this branch as bounded as the
|
// after the '?'. Keeping the path and dropping the query is what makes
|
||||||
// pattern branches below.
|
// this branch as bounded as the pattern branches below.
|
||||||
//
|
//
|
||||||
// Nothing debuggable is lost. The only query parameters the service
|
// Nothing debuggable is lost. The only query parameters the service
|
||||||
// reads are the login page's `next`, the page to return to,
|
// reads are the login page's `next`, the page to return to,
|
||||||
@@ -302,41 +297,37 @@ func concreteLogURL(r *http.Request) string {
|
|||||||
|
|
||||||
// accessLogURL returns the value for the access log's url field.
|
// accessLogURL returns the value for the access log's url field.
|
||||||
//
|
//
|
||||||
// On ReceiverRoute it is the route pattern whatever the status, so
|
// 2xx and 5xx responses get the concrete path (see concreteLogURL). A
|
||||||
// the entrypoint UUID in the path is never logged. A request chi never
|
// success resolved against a static route or against the operator's
|
||||||
// routed gets unmatchedRoute whatever the status, for the same reason:
|
// own data — on the receiver, a 2xx means the UUID named a stored
|
||||||
// in dev, CORS answers a preflight to the receiver before routing.
|
// entrypoint — and a server error is our own bug, where the exact URL
|
||||||
//
|
// is the primary evidence and which no client can provoke at will.
|
||||||
// On every other route, 2xx and 5xx responses get the concrete path
|
|
||||||
// (see concreteLogURL). A success resolved against a static route or
|
|
||||||
// against the operator's own data, and a server error is our own bug,
|
|
||||||
// where the exact URL is the primary evidence and which no client can
|
|
||||||
// provoke at will.
|
|
||||||
//
|
//
|
||||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||||
// the outcomes an unauthenticated client drives for free, such as a
|
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||||
// 303 to the login page on any invented /user/ path. Logging the
|
// on any invented /h/ path, 303 to the login page on any
|
||||||
// concrete URL there lets a flood write attacker-chosen text, of
|
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||||
// attacker-chosen length, into the operator's log at one line per
|
// write attacker-chosen text, of attacker-chosen length, into the
|
||||||
// request. The pattern comes from the router's own table, so it is
|
// operator's log at one line per request. The pattern comes from the
|
||||||
// bounded by the service's routes while still naming which class of
|
// router's own table, so it is bounded by the service's routes while
|
||||||
// request was rejected.
|
// still naming which class of request was rejected.
|
||||||
//
|
//
|
||||||
// The pattern is only populated once routing has run, so this must be
|
// The pattern is only populated once routing has run, so this must be
|
||||||
// called after the handler returns, not before.
|
// called after the handler returns, not before.
|
||||||
func accessLogURL(r *http.Request, status int) string {
|
func accessLogURL(r *http.Request, status int) string {
|
||||||
pattern := routePatternID(r.Context())
|
|
||||||
if pattern == ReceiverRoute || pattern == unmatchedRoute {
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
if status < http.StatusMultipleChoices ||
|
if status < http.StatusMultipleChoices ||
|
||||||
status >= http.StatusInternalServerError {
|
status >= http.StatusInternalServerError {
|
||||||
return concreteLogURL(r)
|
return concreteLogURL(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if rc := chi.RouteContext(r.Context()); rc != nil {
|
||||||
|
if pattern := rc.RoutePattern(); pattern != "" {
|
||||||
return pattern
|
return pattern
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unmatchedRoute
|
||||||
|
}
|
||||||
|
|
||||||
// Logging returns middleware that logs each HTTP request with
|
// Logging returns middleware that logs each HTTP request with
|
||||||
// timing and metadata.
|
// timing and metadata.
|
||||||
@@ -388,8 +379,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
requestID, maxLogRequestIDBytes,
|
requestID, maxLogRequestIDBytes,
|
||||||
),
|
),
|
||||||
"referer", logfield.Truncate(
|
"referer", logfield.Truncate(
|
||||||
referer.SchemeAndHost(r.Referer()),
|
r.Referer(), logfield.MaxBytes,
|
||||||
logfield.MaxBytes,
|
|
||||||
),
|
),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP", RemoteIP(r),
|
"remoteIP", RemoteIP(r),
|
||||||
@@ -548,9 +538,7 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
// SecurityHeaders returns middleware that sets production security
|
// SecurityHeaders returns middleware that sets production security
|
||||||
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
||||||
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
||||||
// Permissions-Policy. The CSP takes scripts and styles only from
|
// Permissions-Policy.
|
||||||
// files on this origin, so a template may hold no inline script,
|
|
||||||
// event handler or style attribute.
|
|
||||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
maxAge := s.params.Config.HSTSMaxAge
|
maxAge := s.params.Config.HSTSMaxAge
|
||||||
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
||||||
@@ -572,8 +560,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|||||||
w.Header().Set(
|
w.Header().Set(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self'; "+
|
"script-src 'self' 'unsafe-inline'; "+
|
||||||
"style-src 'self'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
)
|
)
|
||||||
w.Header().Set(
|
w.Header().Set(
|
||||||
"Referrer-Policy",
|
"Referrer-Policy",
|
||||||
|
|||||||
@@ -747,10 +747,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
// --- SecurityHeaders Middleware Tests ---
|
// --- SecurityHeaders Middleware Tests ---
|
||||||
|
|
||||||
// securityHeaders serves one request through SecurityHeaders built
|
// hstsHeaders serves one request through SecurityHeaders built over
|
||||||
// over a Config with the given HSTSMaxAge, and returns the response's
|
// a Config with the given HSTSMaxAge, and returns every
|
||||||
// headers.
|
// Strict-Transport-Security value on the response.
|
||||||
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
|
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg := &config.Config{HSTSMaxAge: maxAge}
|
cfg := &config.Config{HSTSMaxAge: maxAge}
|
||||||
@@ -772,7 +772,7 @@ func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
|
|||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
return w.Header()
|
return w.Header().Values("Strict-Transport-Security")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecurityHeaders_HSTS(t *testing.T) {
|
func TestSecurityHeaders_HSTS(t *testing.T) {
|
||||||
@@ -804,28 +804,11 @@ func TestSecurityHeaders_HSTS(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
|
||||||
t, tt.expected,
|
|
||||||
securityHeaders(t, tt.maxAge).Values(
|
|
||||||
"Strict-Transport-Security",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSecurityHeaders_CSP pins the Content-Security-Policy: a page
|
|
||||||
// takes its scripts and styles only from files on its own origin,
|
|
||||||
// never from an inline script, event handler or style.
|
|
||||||
func TestSecurityHeaders_CSP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, "default-src 'self'; script-src 'self'; style-src 'self'",
|
|
||||||
securityHeaders(t, 0).Get("Content-Security-Policy"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MaxBodySize Middleware Tests ---
|
// --- MaxBodySize Middleware Tests ---
|
||||||
|
|
||||||
const testBodyLimit int64 = 64
|
const testBodyLimit int64 = 64
|
||||||
|
|||||||
@@ -259,28 +259,26 @@ func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
|||||||
return peer, true
|
return peer, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// tooManyRequests returns the 429 handler used by the postRateLimit
|
// tooManyRequests returns the 429 handler used by the
|
||||||
// limiters and the per-entrypoint receiver limiter: it logs the
|
// password-change and per-entrypoint receiver limiters: it logs the
|
||||||
// rejection with logMessage and answers with responseMessage.
|
// rejection with logMessage and answers with responseMessage.
|
||||||
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
||||||
// receiver limiter uses floodTooManyRequests instead.
|
// receiver limiter uses floodTooManyRequests instead.
|
||||||
//
|
//
|
||||||
// On ReceiverRoute the line names the route pattern, so the
|
// The path is capped against the same budget as the access log's url
|
||||||
// entrypoint UUID in the path is never logged. Elsewhere it names the
|
// field. The per-entrypoint receiver limiter is unauthenticated and
|
||||||
// path, capped against the same budget as the access log's url field;
|
// its path is a client-chosen segment of client-chosen length, so at
|
||||||
// the limiters there sit behind RequireAuth, so the cap is defensive.
|
// WARN an uncapped path would let a sender pick the size of the line
|
||||||
|
// it writes — the same defect the access log capping closed.
|
||||||
func (m *Middleware) tooManyRequests(
|
func (m *Middleware) tooManyRequests(
|
||||||
logMessage, responseMessage string,
|
logMessage, responseMessage string,
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
|
||||||
if routePatternID(r.Context()) == ReceiverRoute {
|
|
||||||
path = ReceiverRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
m.log.Warn(
|
m.log.Warn(
|
||||||
logMessage,
|
logMessage,
|
||||||
"path", logfield.Truncate(path, logfield.MaxBytes),
|
"path", logfield.Truncate(
|
||||||
|
r.URL.Path, logfield.MaxBytes,
|
||||||
|
),
|
||||||
"remoteIP", RemoteIP(r),
|
"remoteIP", RemoteIP(r),
|
||||||
"clientIP", ClientIP(r),
|
"clientIP", ClientIP(r),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// Package referer reduces a Referer header to its scheme and host, so
|
|
||||||
// that a URL path, which on the receiver route carries the entrypoint
|
|
||||||
// UUID, never reaches a log line or an error report.
|
|
||||||
package referer
|
|
||||||
|
|
||||||
import "net/url"
|
|
||||||
|
|
||||||
// redacted stands in for a Referer that cannot be reduced to its
|
|
||||||
// scheme and host.
|
|
||||||
const redacted = "(redacted)"
|
|
||||||
|
|
||||||
// SchemeAndHost returns a Referer's scheme and host, never its path or
|
|
||||||
// query. The path can be an entrypoint URL, which carries the UUID: a
|
|
||||||
// sender that follows a redirect from http:// to https:// sends the
|
|
||||||
// URL it was redirected from, and a browser sends the 404 page's URL
|
|
||||||
// under /h/ when it fetches that page's stylesheet. A Referer without
|
|
||||||
// both a scheme and a host comes back as "(redacted)".
|
|
||||||
//
|
|
||||||
// It is the access log's referer field, and the Referer header the
|
|
||||||
// Sentry hook sends.
|
|
||||||
func SchemeAndHost(referer string) string {
|
|
||||||
if referer == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(referer)
|
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
||||||
return redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.Scheme + "://" + parsed.Host
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package referer_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/referer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// entrypointUUID is the secret part of an entrypoint URL; no case may
|
|
||||||
// return it.
|
|
||||||
const entrypointUUID = "0b6f7d3e-6a39-4e3c-9f0a-2c4d8e1b5a77"
|
|
||||||
|
|
||||||
// redacted is what SchemeAndHost returns for a Referer it cannot
|
|
||||||
// reduce to a scheme and host.
|
|
||||||
const redacted = "(redacted)"
|
|
||||||
|
|
||||||
func TestSchemeAndHost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
referer string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no Referer",
|
|
||||||
referer: "",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "entrypoint URL with a query",
|
|
||||||
referer: "http://webhooker.example/h/" + entrypointUUID +
|
|
||||||
"?src=ci",
|
|
||||||
want: "http://webhooker.example",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host with a port",
|
|
||||||
referer: "https://webhooker.example:8443/h/" + entrypointUUID,
|
|
||||||
want: "https://webhooker.example:8443",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path without a scheme or host",
|
|
||||||
referer: "/h/" + entrypointUUID,
|
|
||||||
want: redacted,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host without a scheme",
|
|
||||||
referer: "webhooker.example/h/" + entrypointUUID,
|
|
||||||
want: redacted,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not a URL",
|
|
||||||
referer: "http://[::1/h/" + entrypointUUID,
|
|
||||||
want: redacted,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got := referer.SchemeAndHost(tc.referer)
|
|
||||||
|
|
||||||
assert.Equal(t, tc.want, got)
|
|
||||||
assert.NotContains(t, got, entrypointUUID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,8 +60,8 @@ const (
|
|||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives, the copy control and the delete prompts work,
|
// Alpine.js directives and the copy control work, and that a target's
|
||||||
// and that a target's row shows its delivery counts.
|
// row shows its delivery counts.
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -74,10 +74,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||||
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
|
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
|
||||||
|
|
||||||
// The sign-in page is loaded before the browser signs in, since a
|
|
||||||
// signed-in visit to it is sent on to another page.
|
|
||||||
checkOtherPages(ctx, t, srv.URL+"/pages/login")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||||
))
|
))
|
||||||
@@ -103,11 +99,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
|
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
|
||||||
checkOtherPages(ctx, t,
|
|
||||||
srv.URL+"/hooks", srv.URL+"/settings", srv.URL+"/user/browser",
|
|
||||||
// A path that does not exist, which gets the 404 error page.
|
|
||||||
srv.URL+"/no-such-page")
|
|
||||||
checkDeletePrompt(ctx, t, page, target)
|
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
}
|
}
|
||||||
@@ -363,11 +354,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"http", "http",
|
"http", "http",
|
||||||
"csrf_token name type url headers timeout max_attempts",
|
"csrf_token name type url headers timeout max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slack", "slack", "csrf_token name type url max_attempts",
|
"slack", "slack", "csrf_token name type url max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -643,7 +634,7 @@ func checkRefusedEdits(
|
|||||||
"#url": "http://127.0.0.1/hook",
|
"#url": "http://127.0.0.1/hook",
|
||||||
"#headers": "X-Edited: kept",
|
"#headers": "X-Edited: kept",
|
||||||
"#timeout": "12",
|
"#timeout": "12",
|
||||||
"#max_attempts": "3",
|
"#max_retries": "3",
|
||||||
}},
|
}},
|
||||||
{page + "/edit", map[string]string{
|
{page + "/edit", map[string]string{
|
||||||
"#name": "edited-webhook",
|
"#name": "edited-webhook",
|
||||||
@@ -1397,75 +1388,3 @@ func checkPhoneWidth(
|
|||||||
assert.False(t, sideways, "the event log scrolls sideways on a phone")
|
assert.False(t, sideways, "the event log scrolls sideways on a phone")
|
||||||
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
|
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOtherPages loads the pages at urls, which no other check opens,
|
|
||||||
// so that anything the browser reports going wrong on them fails the
|
|
||||||
// test too.
|
|
||||||
func checkOtherPages(ctx context.Context, t *testing.T, urls ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, url := range urls {
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// answerPrompt returns a script that stands in for the user at the
|
|
||||||
// browser's prompt: it makes window.confirm record the question in
|
|
||||||
// window.asked and give answer. It also records in window.sent whether
|
|
||||||
// a form is sent once its submit event is handled, from a listener on
|
|
||||||
// window, which the event reaches after the listener static/js/app.js
|
|
||||||
// adds to document.
|
|
||||||
func answerPrompt(answer bool) string {
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
window.asked = "";
|
|
||||||
window.sent = false;
|
|
||||||
window.confirm = (question) => {
|
|
||||||
window.asked = question;
|
|
||||||
return %t;
|
|
||||||
};
|
|
||||||
window.addEventListener("submit", (event) => {
|
|
||||||
window.sent = !event.defaultPrevented;
|
|
||||||
});`, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDeletePrompt loads a webhook page and clicks Delete beside
|
|
||||||
// target. It checks that the browser asks first, naming the target,
|
|
||||||
// that answering no leaves the form unsent, and that answering yes
|
|
||||||
// sends it and the target is deleted.
|
|
||||||
func checkDeletePrompt(
|
|
||||||
ctx context.Context, t *testing.T, url string, target *database.Target,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
form := `form[action$="/targets/` + target.ID + `/delete"]`
|
|
||||||
question := `Delete target "` + target.Name + `"?` + "\n\n" +
|
|
||||||
"Nothing more is delivered to it. " +
|
|
||||||
"Its past deliveries stay in the event log."
|
|
||||||
|
|
||||||
var (
|
|
||||||
asked string
|
|
||||||
sent bool
|
|
||||||
listed bool
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, loadPage(url), chromedp.Evaluate(answerPrompt(false), nil),
|
|
||||||
))
|
|
||||||
click(ctx, t, form+" button")
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.Evaluate(`window.asked`, &asked),
|
|
||||||
chromedp.Evaluate(`window.sent`, &sent),
|
|
||||||
))
|
|
||||||
assert.Equal(t, question, asked, "Delete does not ask about the target")
|
|
||||||
assert.False(t, sent, "answering no still sends the delete form")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Evaluate(answerPrompt(true), nil),
|
|
||||||
))
|
|
||||||
clickAndLoad(ctx, t, form+" button")
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
|
||||||
`document.querySelector('`+form+`') !== null`, &listed,
|
|
||||||
)))
|
|
||||||
assert.False(t, listed, "answering yes does not delete the target")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func TestHook_DownloadArchive(t *testing.T) {
|
|||||||
Name: "kept",
|
Name: "kept",
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Active: true,
|
Active: true,
|
||||||
MaxAttempts: 1,
|
|
||||||
}
|
}
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
env.db.DB().Omit(clause.Associations).Create(archive).Error,
|
env.db.DB().Omit(clause.Associations).Create(archive).Error,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,11 +25,6 @@ const (
|
|||||||
// its network namespace even with a published port.
|
// its network namespace even with a published port.
|
||||||
wildcardV4 = "0.0.0.0"
|
wildcardV4 = "0.0.0.0"
|
||||||
|
|
||||||
// loopbackV6 and wildcardV6 are the IPv6 counterparts of the two
|
|
||||||
// above.
|
|
||||||
loopbackV6 = "::1"
|
|
||||||
wildcardV6 = "::"
|
|
||||||
|
|
||||||
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
||||||
// well-formed literal that no host is assigned, so binding it
|
// well-formed literal that no host is assigned, so binding it
|
||||||
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
||||||
@@ -122,24 +116,6 @@ func otherLocalAddr(t *testing.T) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// skipWithoutIPv6 skips the test when this host cannot bind
|
|
||||||
// loopbackV6. Without IPv6 there is no second family for a bind to
|
|
||||||
// claim or leave alone.
|
|
||||||
func skipWithoutIPv6(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var listenCfg net.ListenConfig
|
|
||||||
|
|
||||||
l, err := listenCfg.Listen(
|
|
||||||
t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("host has no IPv6 loopback to bind")
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, l.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
// startBoundServer starts the wired app with the given bind address
|
// startBoundServer starts the wired app with the given bind address
|
||||||
// on a free port and returns that port. The app is stopped on
|
// on a free port and returns that port. The app is stopped on
|
||||||
// cleanup.
|
// cleanup.
|
||||||
@@ -316,59 +292,6 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) {
|
|||||||
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on
|
|
||||||
// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or
|
|
||||||
// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a
|
|
||||||
// socket that answers IPv6 too unless told otherwise, which put the
|
|
||||||
// admin UI and the receiver on every IPv6 address of a host whose
|
|
||||||
// operator had chosen an IPv4 bind.
|
|
||||||
func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
skipWithoutIPv6(t)
|
|
||||||
|
|
||||||
for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} {
|
|
||||||
t.Run(bindAddress, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
port := startBoundServer(t, bindAddress)
|
|
||||||
|
|
||||||
// Positive control: the service really is up, on IPv4.
|
|
||||||
requireDialable(
|
|
||||||
t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)),
|
|
||||||
)
|
|
||||||
|
|
||||||
dialer := net.Dialer{Timeout: dialTimeout}
|
|
||||||
|
|
||||||
conn, err := dialer.DialContext(
|
|
||||||
t.Context(), "tcp",
|
|
||||||
net.JoinHostPort(loopbackV6, strconv.Itoa(port)),
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(
|
|
||||||
t, err, syscall.ECONNREFUSED,
|
|
||||||
"a %s bind answered on %s", bindAddress, loopbackV6,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart:
|
|
||||||
// :: is the value that binds IPv6, and it answers IPv4 as well.
|
|
||||||
func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
skipWithoutIPv6(t)
|
|
||||||
|
|
||||||
port := startBoundServer(t, wildcardV6)
|
|
||||||
|
|
||||||
requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port)))
|
|
||||||
requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
||||||
// listener serves the application rather than merely accepting TCP,
|
// listener serves the application rather than merely accepting TCP,
|
||||||
// so a bind address that is honoured cannot be mistaken for one that
|
// so a bind address that is honoured cannot be mistaken for one that
|
||||||
|
|||||||
+1
-30
@@ -1,11 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -41,23 +39,6 @@ func (s *Server) listenAddr() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenNetwork returns the network the HTTP listener binds on, "tcp4"
|
|
||||||
// for an IPv4 BindAddress and "tcp" for any other.
|
|
||||||
//
|
|
||||||
// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well,
|
|
||||||
// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what
|
|
||||||
// the README says it means. :: stays on "tcp" and answers both. An
|
|
||||||
// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it
|
|
||||||
// carries to Go's listener, so it gets "tcp4" too.
|
|
||||||
func (s *Server) listenNetwork() string {
|
|
||||||
addr, err := netip.ParseAddr(s.params.Config.BindAddress)
|
|
||||||
if err == nil && addr.Unmap().Is4() {
|
|
||||||
return "tcp4"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "tcp"
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHTTPServer builds the HTTP server for this Server's
|
// newHTTPServer builds the HTTP server for this Server's
|
||||||
// configuration.
|
// configuration.
|
||||||
//
|
//
|
||||||
@@ -86,17 +67,7 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||||
|
|
||||||
// ListenAndServe always listens on "tcp", which would undo
|
err := s.httpServer.ListenAndServe()
|
||||||
// listenNetwork.
|
|
||||||
var listenCfg net.ListenConfig
|
|
||||||
|
|
||||||
listener, err := listenCfg.Listen(
|
|
||||||
context.Background(), s.listenNetwork(), s.httpServer.Addr,
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
err = s.httpServer.Serve(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
s.shutdownWithFailure()
|
s.shutdownWithFailure()
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import (
|
|||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
chimw "github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
"sneak.berlin/go/webhooker/static"
|
"sneak.berlin/go/webhooker/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupGlobalMiddleware() {
|
func (s *Server) setupGlobalMiddleware() {
|
||||||
s.router.Use(chimw.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
@@ -346,7 +345,7 @@ func (s *Server) setupWebhookRoutes() {
|
|||||||
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
||||||
// pins it.
|
// pins it.
|
||||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||||
middleware.ReceiverRoute,
|
"/h/{uuid}",
|
||||||
s.h.HandleWebhook(),
|
s.h.HandleWebhook(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user