Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33ea72448 |
+4
-10
@@ -1,20 +1,14 @@
|
||||
# .git is sent so the build can derive the version it stamps into the binary
|
||||
# (script/version). Its config, which can hold a remote URL carrying a
|
||||
# credential and which `git describe` does not need, is left out of a
|
||||
# directory context. A context sent as a tar is not filtered by this file, so
|
||||
# it carries .git/config unless its sender leaves it out.
|
||||
.git/config
|
||||
|
||||
# No tracked file may be listed here: git in the build would see it as
|
||||
# deleted and mark the version -dirty.
|
||||
#
|
||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||
# that keeps the check stages from replaying a cached pass. See the lint
|
||||
# stage of the Dockerfile.
|
||||
.git/
|
||||
bin/
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
static/js/alpine.min.js
|
||||
*.md
|
||||
LICENSE
|
||||
.editorconfig
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
|
||||
@@ -12,8 +12,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||
with:
|
||||
# The superseded-status step needs history to walk ancestors (it
|
||||
# aborts on a shallow clone).
|
||||
# The fingerprint step below needs history to find the last commit
|
||||
# that touched the Docker build context, and the superseded-status
|
||||
# step needs it to walk ancestors (it aborts on a shallow clone).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mark superseded run statuses
|
||||
@@ -27,11 +28,16 @@ jobs:
|
||||
run: script/ci-mark-superseded
|
||||
|
||||
- name: Fingerprint the build context
|
||||
# Writes the hash of the commit being checked into the context, which
|
||||
# invalidates the `COPY . .` layer of both check stages: a commit
|
||||
# that was never linted, format-checked, tested and built cannot
|
||||
# report success from cache.
|
||||
run: git rev-parse HEAD > .ci-fingerprint
|
||||
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||
# commit legitimately replays the whole image from cache and stays
|
||||
# cheap. Every other commit writes a new fingerprint into the context,
|
||||
# which invalidates the `COPY . .` layer of both check stages: a
|
||||
# commit that was never linted, formatted-checked, tested and built
|
||||
# cannot report success from cache.
|
||||
run: |
|
||||
set -eu
|
||||
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
||||
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
||||
|
||||
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
||||
- name: Build Docker image (runs make check)
|
||||
run: script/cibuild
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+10
-23
@@ -12,8 +12,8 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
||||
# holds the hash of the commit being checked (see
|
||||
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
||||
# value changes with every commit that touches the build context (see
|
||||
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
||||
# below cannot report success by replaying a cached pass. Do not add it to
|
||||
# .dockerignore.
|
||||
@@ -26,7 +26,7 @@ COPY . .
|
||||
# Dockerfile.lint, including --network=none (see its header for why).
|
||||
RUN make fmt-check
|
||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
@@ -38,13 +38,8 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||
# suite executes. git is what script/version derives the version with.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# A build context sent as a tar archive keeps its files' owners, and git
|
||||
# refuses to read a checkout owned by another user. Trust this one
|
||||
# whoever owns it.
|
||||
RUN git config --system --add safe.directory /build
|
||||
# suite executes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -60,22 +55,14 @@ COPY . .
|
||||
# from its tarball in 3p/.
|
||||
RUN make test
|
||||
|
||||
# Version stamped into the binary: the VERSION build arg when one is
|
||||
# given, otherwise what script/version derives from the .git the build
|
||||
# context carries, so any `docker build .` of a clone stamps its commit.
|
||||
# With neither, as from a source tarball, it is "unknown".
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
# nothing in this stage can derive it: script/docker resolves it on the
|
||||
# host and passes it in. The default is what a bare `docker build .`
|
||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||
#
|
||||
# Declared here, below the test step, so a changed version does not
|
||||
# invalidate its cached layer.
|
||||
ARG VERSION
|
||||
|
||||
# A context that carries .git must not stamp "unknown": that means git is
|
||||
# missing here or could not read the checkout, and the image could not be
|
||||
# traced back to its commit.
|
||||
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
||||
echo "version is unknown although the build context carries .git" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Browser test image, built by script/test-browser (make test-browser). It
|
||||
# runs the test in internal/server that loads the pages in a headless
|
||||
# browser under the real Content-Security-Policy. That test is built only
|
||||
# with the browser build tag, so make test leaves it out. Here the browser
|
||||
# comes from a digest-pinned image, and if it is missing the test fails.
|
||||
|
||||
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# The test binary embeds the templates and static files, so the browser
|
||||
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
||||
# script/test.
|
||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
||||
|
||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
||||
# browser is on PATH as headless-shell, where the test's browser library
|
||||
# looks for it.
|
||||
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
||||
|
||||
COPY --from=build /browser.test /browser.test
|
||||
|
||||
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
||||
+1
-3
@@ -34,6 +34,4 @@ COPY . .
|
||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||
# disable a setting without a word. `config verify` is what catches that.
|
||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||
# --build-tags browser also lints the browser test, which is built only with
|
||||
# that tag (make test-browser).
|
||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Version stamped into the binary. Derived from git by script/version;
|
||||
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
||||
# how the Dockerfile passes its build arg in.
|
||||
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||
VERSION ?= $(shell script/version)
|
||||
|
||||
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
||||
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||
# is required: a plain assignment loses to the command-line definition it
|
||||
@@ -33,9 +33,6 @@ assets:
|
||||
test:
|
||||
@script/test
|
||||
|
||||
test-browser:
|
||||
@script/test-browser
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ before deploying one.
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.26.1+ (the version in `go.mod`)
|
||||
- Docker (for `make lint` and so for `make check`, for the browser test in
|
||||
`make test-browser`, for the CI gate, and for containerized deployment)
|
||||
- Docker (for linting, for the test stage of the CI gate, and for
|
||||
containerized deployment)
|
||||
|
||||
golangci-lint is not a prerequisite and must not be installed on the
|
||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||
@@ -58,7 +58,6 @@ make fmt # Format code (gofmt + goimports)
|
||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
make test # Run tests with race detection
|
||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
||||
make check # test + lint + fmt-check (CI gate)
|
||||
make build # Build binary to bin/webhooker (version-stamped)
|
||||
make version # Print the version this checkout would stamp
|
||||
@@ -136,6 +135,7 @@ TTY detection, and security headers are always applied.
|
||||
| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
|
||||
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||
| `DEBUG` | Enable debug logging | `false` |
|
||||
| `MAINTENANCE_MODE` | Report `maintenanceMode: true` in the healthcheck JSON. It does not change how any request is served — no maintenance page exists | `false` |
|
||||
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
|
||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
|
||||
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
|
||||
@@ -145,11 +145,6 @@ TTY detection, and security headers are always applied.
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||
|
||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
||||
every one of these with the value the running server loaded. It is
|
||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
||||
set or not set, never their values.
|
||||
|
||||
#### Allowing egress to your own network
|
||||
|
||||
By default every delivery target must resolve to a public address. The
|
||||
@@ -163,20 +158,19 @@ WireServer, which serves an Azure VM its credentials. Because it is a
|
||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||
|
||||
That is all the default blocklist covers: the IPv4 private and reserved
|
||||
ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`),
|
||||
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
|
||||
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and
|
||||
certain public addresses. A public address belongs on the default
|
||||
blocklist only if it hands credentials, user data or bootstrap material
|
||||
to whatever can reach it, without the caller presenting anything. A
|
||||
provider's other public addresses are not refused. IBM Cloud, for
|
||||
example, serves its package mirrors, time servers and object storage on
|
||||
`161.26.0.0/16`, and the private endpoints of its own cloud services on
|
||||
`166.8.0.0/14`. Neither range hands out credentials that way: the token
|
||||
service among those endpoints issues a token only in exchange for
|
||||
something the caller presents, such as an API key. Reaching these
|
||||
services can be a legitimate delivery, and every cloud has some, so a
|
||||
partial list would promise coverage it does not give.
|
||||
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
||||
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
||||
addresses. A public address belongs on the default blocklist only if it
|
||||
hands credentials, user data or bootstrap material to whatever can reach
|
||||
it, without the caller presenting anything. A provider's other public
|
||||
addresses are not refused. IBM Cloud, for example, serves its package
|
||||
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
||||
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
||||
range hands out credentials that way: the token service among those
|
||||
endpoints issues a token only in exchange for something the caller
|
||||
presents, such as an API key. Reaching these services can be a
|
||||
legitimate delivery, and every cloud has some, so a partial list would
|
||||
promise coverage it does not give.
|
||||
|
||||
That default is also inconvenient for the thing webhooker is mostly
|
||||
for: taking a public webhook and forwarding it to something on your own
|
||||
@@ -216,16 +210,16 @@ Two things this setting cannot do:
|
||||
the list is always an allowlist; an empty list (the default) means
|
||||
every private and reserved range stays refused. Note that
|
||||
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
||||
- **It cannot open link-local, the unspecified addresses, or a cloud
|
||||
metadata endpoint at a non-public address that discloses credentials
|
||||
or user data.** A metadata address is on the list below when it is not
|
||||
a public address and both of these hold: the provider fixes it, so it
|
||||
cannot collide with anything you run; and reaching it hands out
|
||||
credentials, user data or bootstrap material. Those stay blocked no
|
||||
matter what you list, including when you list them outright or list a
|
||||
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
|
||||
Treat this as best effort rather than a guarantee — it is a
|
||||
hand-maintained list and the caveat below the table applies:
|
||||
- **It cannot open link-local, or a cloud metadata endpoint at a
|
||||
non-public address that discloses credentials or user data.** An
|
||||
address is on the list below when it is not a public address and both
|
||||
of these hold: the provider fixes it, so it cannot collide with
|
||||
anything you run; and reaching it hands out credentials, user data or
|
||||
bootstrap material. Those stay blocked no matter what you list,
|
||||
including when you list them outright or list a supernet such as
|
||||
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
|
||||
effort rather than a guarantee — it is a hand-maintained list and the
|
||||
caveat below the table applies:
|
||||
|
||||
| Blocked unconditionally | What it is |
|
||||
| ----------------------- | ---------- |
|
||||
@@ -239,25 +233,14 @@ Two things this setting cannot do:
|
||||
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
||||
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
||||
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
||||
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
|
||||
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
|
||||
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
||||
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
||||
|
||||
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
||||
`169.254.0.0/16` entry. Reaching any of these but the two unspecified
|
||||
addresses is credential or user-data theft rather than delivery to an
|
||||
internal service. Every entry outside the two link-local blocks is a
|
||||
single address, so blocking it costs you nothing else on the network
|
||||
around it.
|
||||
|
||||
The unspecified addresses `0.0.0.0` and `::` hand out nothing
|
||||
themselves, but no host can have either, and on Linux a connection to
|
||||
one reaches this host's own loopback. They are listed so that an
|
||||
allowlist reaches loopback only through an entry that covers a loopback
|
||||
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
|
||||
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
|
||||
open loopback.
|
||||
`169.254.0.0/16` entry. Reaching any of these is credential or
|
||||
user-data theft rather than delivery to an internal service. Every
|
||||
entry outside the two link-local blocks is a single address, so
|
||||
blocking it costs you nothing else on the network around it.
|
||||
|
||||
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
||||
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
||||
@@ -457,19 +440,6 @@ Your proxy must therefore **append** the peer address to
|
||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
||||
bare address with no port.
|
||||
|
||||
Every log line that names a client carries two addresses: `remoteIP`,
|
||||
the connecting peer, which behind a proxy is the proxy; and `clientIP`,
|
||||
the client the rate limiters identify by the rules above, which is the
|
||||
field to read when tracing who sent what. Those lines are the
|
||||
`http request` access log line, the rate-limit rejection lines
|
||||
(`login failure limit exceeded` among them), the
|
||||
`csrf: token validation failed` warning and the receiver's
|
||||
`webhook request received` line. `clientIP` is only as trustworthy as
|
||||
`TRUSTED_PROXIES`: for a request from a peer inside the list, it is
|
||||
read out of the `X-Forwarded-For` that peer sent, so a peer that does
|
||||
not belong in the list can make it name any address it likes. For a
|
||||
request from any other peer, both fields name the peer.
|
||||
|
||||
#### Sessions
|
||||
|
||||
Sessions are bounded by two independent clocks, and end at whichever
|
||||
@@ -528,8 +498,8 @@ no report is being sent — which is why it aborts rather than starting
|
||||
with reporting off. Leaving it unset is not a mistake and not affected:
|
||||
error reporting is simply off and startup is normal.
|
||||
|
||||
The boolean variable `DEBUG` accepts exactly the spellings Go's
|
||||
`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
|
||||
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
|
||||
else. `yes`, `on`, and `off` are rejected rather than quietly treated
|
||||
as false.
|
||||
@@ -728,8 +698,7 @@ The app runs as a non-root user (`webhooker`, UID 1000), exposes port
|
||||
The `/var/lib/webhooker` volume holds all SQLite databases: the main
|
||||
application database (`webhooker.db`), the per-webhook event databases
|
||||
(`events-{uuid}.db`), and any archive databases written by `database`
|
||||
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount
|
||||
this as a persistent volume to
|
||||
targets (`archive-{uuid}.db`). Mount this as a persistent volume to
|
||||
preserve data across container restarts.
|
||||
|
||||
**The container sets its data directory's owner and mode itself
|
||||
@@ -854,9 +823,9 @@ reports.
|
||||
was given, so on any port other than 443 `$host` makes every form
|
||||
POST — including login — fail with `403 origin invalid`, with
|
||||
nothing in the error naming the cause.
|
||||
5. **Keep the proxy's access log.** webhooker's own access log names
|
||||
the client in its `clientIP` field only while `TRUSTED_PROXIES`
|
||||
covers the proxy; the proxy's log names it regardless. nginx's
|
||||
5. **Keep the proxy's access log.** webhooker's own access log records
|
||||
the peer address, which behind a proxy is always the proxy. The
|
||||
proxy's log is the only record of which client sent what. nginx's
|
||||
default `combined` format already 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 about traffic.
|
||||
@@ -885,8 +854,9 @@ server {
|
||||
# webhooker's message.
|
||||
client_max_body_size 1m;
|
||||
|
||||
# $remote_addr is the client. webhooker's own log names it, as
|
||||
# clientIP, only while TRUSTED_PROXIES covers this proxy.
|
||||
# $remote_addr is the client. webhooker's own log records this
|
||||
# proxy and nothing else, so this file is the only place the
|
||||
# client's address is written down.
|
||||
access_log /var/log/nginx/webhooker.access.log combined;
|
||||
|
||||
location / {
|
||||
@@ -967,13 +937,13 @@ is both the simplest and the only complete rule:
|
||||
encryption key), users, API keys, webhooks, entrypoints, targets.
|
||||
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
||||
delivery results.
|
||||
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
|
||||
`database` target**. Archived events. The two names are made safe for
|
||||
a file name, and the file is renamed when the webhook or the target is
|
||||
(see [Database Architecture](#database-architecture)).
|
||||
- `archive-{webhook_uuid}.db` — **one per webhook that has a `database`
|
||||
target**. Archived events. Keyed on the webhook UUID, not the target
|
||||
UUID: a webhook with several `database` targets still has exactly one
|
||||
archive file.
|
||||
|
||||
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
|
||||
canonical 36-character hyphenated form, so a real filename looks like
|
||||
`{webhook_uuid}` is the webhook's UUID primary key in its canonical
|
||||
36-character hyphenated form, so a real filename looks like
|
||||
`events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the
|
||||
always-empty [single-instance lock](#single-instance-lock); it holds no
|
||||
state and is not part of the backup set — a copied one is stale and
|
||||
@@ -1047,8 +1017,8 @@ stopped copy.
|
||||
|
||||
Archive databases are the one exception the service is built for: the
|
||||
archive writer closes and reopens its handle around writes (debounced
|
||||
to at most one reopen per second), so an operator can move an
|
||||
`archive-….db` away for offline retention while the service runs,
|
||||
to at most one reopen per second), so an operator can move
|
||||
`archive-{uuid}.db` away for offline retention while the service runs,
|
||||
and it is recreated on the next write. See
|
||||
[Database Architecture](#database-architecture). That is a
|
||||
move-the-file-away workflow, not a substitute for the backup procedures
|
||||
@@ -1066,7 +1036,7 @@ happens on the next write past the debounce window, when the connection
|
||||
pool retires the idle connection (about a minute after the last write),
|
||||
or at the idle archive sweep — measured, the same file was a complete
|
||||
20 KB `.db` with no sidecars about a minute after its last write. A
|
||||
clean stop closes it too. So either move the `archive-….db` together
|
||||
clean stop closes it too. So either move `archive-{uuid}.db` together
|
||||
with any `-wal`/`-shm` beside it, or wait until there are none.
|
||||
|
||||
### Restore
|
||||
@@ -1163,29 +1133,13 @@ build itself.
|
||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||
| No git metadata | `unknown` |
|
||||
|
||||
The image derives it the same way, from the `.git` that the build
|
||||
context carries, so any `docker build .` of a clone, with no build
|
||||
arguments, stamps the commit it was built from; a shallow clone of one
|
||||
branch has no tags and stamps the short SHA. `.dockerignore` must
|
||||
therefore leave out neither `.git` nor any tracked file, which git in
|
||||
the build would see as deleted, marking the version `-dirty`. It does
|
||||
leave `.git/config`, which can hold a remote URL carrying a credential
|
||||
and which `git describe` does not need, out of a directory context. A
|
||||
context sent as a tar is not filtered by `.dockerignore`, so it carries
|
||||
`.git/config` unless its sender leaves it out; for upaas, that is
|
||||
https://git.eeqj.de/sneak/upaas/issues/274. git in the build
|
||||
reads the checkout whoever owns its files, since a context sent as a tar
|
||||
archive keeps the sender's owners and git otherwise refuses a checkout
|
||||
owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`)
|
||||
takes precedence; `script/docker` (and so `make docker`) passes the one
|
||||
`script/version` resolves on the host. The image build fails if its
|
||||
context carries `.git` and the version still comes out `unknown`, which
|
||||
means git is missing from the build or could not read the checkout.
|
||||
|
||||
`unknown` is what a source tarball, or a `docker build` with no `.git`
|
||||
in its context and no `VERSION` build arg, reports. A build that reports
|
||||
`unknown` is a build nobody told what it was; it is not a failure, but
|
||||
it cannot be traced back to a commit.
|
||||
`unknown` is what a source tarball or a `docker build .` with no
|
||||
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
||||
the build context carries no git metadata and the image cannot derive
|
||||
the version itself: `script/docker` (and so `make docker`) resolves it
|
||||
on the host and passes it in as the `VERSION` build arg. A build that
|
||||
reports `unknown` is a build nobody told what it was; it is not a
|
||||
failure, but it cannot be traced back to a commit.
|
||||
|
||||
`make version` prints what the current checkout would stamp, and
|
||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||
@@ -1201,7 +1155,7 @@ commit still produce a byte-identical binary.
|
||||
Treat a backup with the same care as the credentials inside it. Encrypt
|
||||
backups at rest and restrict who can read them.
|
||||
|
||||
- `events-{uuid}.db` and `archive-….db` hold the **full payload
|
||||
- `events-{uuid}.db` and `archive-{uuid}.db` hold the **full payload
|
||||
body and headers** of every event as received, including whatever the
|
||||
sending service put in them — tokens, signatures, personal data.
|
||||
- Event databases written before
|
||||
@@ -1270,7 +1224,7 @@ What that means for an operator:
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow. Eleven of the Makefile's eighteen targets are thin
|
||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||
`version` are inline commands with no script behind them, though `build`,
|
||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||
@@ -1291,8 +1245,6 @@ We provide:
|
||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/test` — run the test suite
|
||||
- `script/test-browser` — run the browser test in Docker (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
@@ -1313,32 +1265,11 @@ We provide:
|
||||
|
||||
## Third-party browser assets
|
||||
|
||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
||||
the standard `alpinejs` build needs to run the expressions written in the
|
||||
markup. The CSP build runs no expressions, so every Alpine directive in
|
||||
`templates/` only names a property or method of a component registered in
|
||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||
|
||||
A browser test in `internal/server` loads the webhook page and the event log
|
||||
under the real policy and checks that: both add forms stay hidden until Add is
|
||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
||||
what it submits, also after leaving the page and going back to it, when the
|
||||
browser restores the choice; an event expands and collapses, and so do a
|
||||
delivery's attempts inside it; and at phone width the menu button opens and
|
||||
closes the mobile menu. It also fails if the browser reports a console warning
|
||||
or error, an uncaught exception, or anything the policy refused. `make check`
|
||||
and the image build lint it but do not run it, and `make test` leaves it out
|
||||
(its file is built only with the `browser` build tag). Run it with
|
||||
`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
|
||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||
output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not
|
||||
apply. The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||
publishes it. It is a dependency, not this repo's build output, so
|
||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
`vendor/` directory as its module vendor directory.
|
||||
|
||||
`script/assets` (`make assets`) extracts the browser build,
|
||||
@@ -1349,11 +1280,10 @@ nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
|
||||
the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||
`script/assets`, and run `make check` and `make test-browser`.
|
||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -1643,9 +1573,8 @@ events should be forwarded.
|
||||
is built on the same HTTP core as `http` and honours `max_retries`
|
||||
identically, circuit breaker included. See the Slack target section
|
||||
under "Per-Webhook Event Databases" for the message format.
|
||||
- **`database`** — Archive the full event as a row into the target's
|
||||
own archive database
|
||||
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
|
||||
- **`database`** — Archive the full event as a row into a separate
|
||||
per-webhook archive database (`archive-{webhookID}.db`) for long-term
|
||||
retention, with an optional creation-validated expiry (default: keep
|
||||
forever). No external delivery and no retries; an archive write
|
||||
failure fails the delivery. See the database target section under
|
||||
@@ -1823,7 +1752,7 @@ retries) is individually logged for full observability.
|
||||
#### EventTotals and TargetTotals
|
||||
|
||||
Running counts in each event database, read by the statistics pane at the
|
||||
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
||||
top of the webhook page. `EventTotals` is one row:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ----------- |
|
||||
@@ -1855,14 +1784,6 @@ target. Its failure percentage for a window is the deliveries that became
|
||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||
a dash when none did.
|
||||
|
||||
The webhook list at `/hooks` shows three of the pane's figures for each
|
||||
webhook: its events within retention and its last event, both from
|
||||
`EventTotals`, and its deliveries that failed in the last 24 hours,
|
||||
counted with the pane's query. It opens each webhook's event database once
|
||||
(the handle stays open) and runs those two reads there, so its cost grows
|
||||
with the number of webhooks and, for each, with the deliveries that
|
||||
finished in the last 24 hours, never with the events stored.
|
||||
|
||||
#### Event-tier indexes
|
||||
|
||||
These indexes on the per-webhook event databases are declared in the model
|
||||
@@ -1870,7 +1791,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
||||
|
||||
| Table | Columns | Serves |
|
||||
| ------------------ | --------------------------- | ------ |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||
@@ -1967,41 +1888,9 @@ The **database target type** builds on this architecture to provide
|
||||
long-term archiving, separate from the per-webhook event database (which
|
||||
may prune events under its own retention). Delivering to a database
|
||||
target writes the full event — body, headers, method, content type, and
|
||||
webhook/entrypoint/event identifiers — as a row into the target's own
|
||||
archive database, `archive-{webhook_name}-{target_name}-{target_uuid}.db`,
|
||||
stored under the data directory beside the event database. Each
|
||||
`database` target has its own archive file, even when one webhook has
|
||||
several.
|
||||
|
||||
Both names are made safe for a file name the same way: lowercased, ASCII
|
||||
letters and digits kept, every other run of characters turned into a
|
||||
single `-`, no `-` at either end, cut to 40 characters, and `unnamed`
|
||||
when nothing is left. The target UUID keeps the file name unique. A
|
||||
webhook named `Orders (EU)` with a target named `Long-term archive`
|
||||
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
||||
Renaming the webhook or the target renames the file, under the same
|
||||
lock the archive writes and the archive sweeper take. Webhook edits,
|
||||
target edits and target creation run one at a time, so no edit can
|
||||
rename the file between another's rename and save, and the name on disk
|
||||
matches the UI. A rename never replaces a file: if one already has
|
||||
the new name, the edit is refused with an error naming that file, and
|
||||
the stored name stays. If the archive is not there (the operator moved
|
||||
it away), the rename is not an error, and the next write creates the
|
||||
file under the new name.
|
||||
|
||||
The file is moved just before the new name is saved. If the process
|
||||
stops between the two, the archive is left under the new name while the
|
||||
UI still shows the old one, and the next delivery starts a second
|
||||
archive under the name shown. To bring them back together, stop the
|
||||
service before moving anything, and move each archive as its `.db`
|
||||
together with any `-wal` and `-shm` beside it, since the `-wal` can hold
|
||||
rows that are not yet in the `.db`. If no file has the name shown, move
|
||||
the archive under the new name back to it. If a second archive already
|
||||
has the name shown, move the archive under the new name out of the data
|
||||
directory instead and keep it as you would any archive moved away. Then
|
||||
start the service again.
|
||||
|
||||
After each write the archive handle is closed
|
||||
webhook/entrypoint/event identifiers — as a row into a dedicated archive
|
||||
database, `archive-{webhookID}.db`, stored under the data directory
|
||||
beside the event database. 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 archiving without stopping the
|
||||
service; a moved or removed archive file is recreated automatically on
|
||||
@@ -2012,33 +1901,35 @@ older than the expiry are pruned each time the archive is (re)opened. An
|
||||
archive write failure is never silent success: the delivery records a
|
||||
failed attempt with the error and is marked failed.
|
||||
|
||||
Because reopens only happen on writes, an archive whose target has
|
||||
stopped receiving events would never be pruned. A background **archive
|
||||
sweeper** closes that gap: on the same interval as the event retention
|
||||
reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose
|
||||
database target declares a positive expiry, whether or not the target
|
||||
is still receiving traffic. The sweep never creates an archive — a
|
||||
target whose archive file does not yet exist is skipped, not initialised
|
||||
— it takes the same per-target lock the write path uses, so it can never
|
||||
interleave with a write, and it leaves the archive closed afterwards so
|
||||
the move-the-file-away workflow keeps working. Archives with no expiry,
|
||||
or the expiry `never`, are not touched by the sweep at all.
|
||||
Because reopens only happen on writes, an archive belonging to a webhook
|
||||
that has stopped receiving events would never be pruned. A background
|
||||
**archive sweeper** closes that gap: on the same interval as the event
|
||||
retention reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive
|
||||
whose database target declares a positive expiry, whether or not the
|
||||
webhook is still receiving traffic. The sweep never creates an archive —
|
||||
a webhook whose archive file does not yet exist is skipped, not
|
||||
initialised — it takes the same per-webhook lock the write path uses, so
|
||||
it can never interleave with a write, and it leaves the archive closed
|
||||
afterwards so the move-the-file-away workflow keeps working. Archives
|
||||
with no expiry, or the expiry `never`, are not touched by the sweep at
|
||||
all.
|
||||
|
||||
Because each `database` target has its own archive file, a target's
|
||||
`expiry` governs only its own archive. Two `database` targets on one
|
||||
webhook with different expiries keep two archives, each pruned on its
|
||||
own schedule.
|
||||
Note that a webhook has one archive file but may carry more than one
|
||||
`database` target, each with its own `expiry`. The shortest expiry
|
||||
configured on any of them therefore governs the whole archive, and the
|
||||
sweep applies it whether or not the webhook is still receiving events.
|
||||
Configure a single `database` target per webhook unless you intend that.
|
||||
|
||||
Deleting a webhook releases its archives: the delivery engine's cached
|
||||
archive writers are dropped and their file handles closed, so nothing
|
||||
lingers after the webhook is gone. The archive **files themselves are
|
||||
deliberately left on disk**. Unlike the event database — per-webhook
|
||||
working storage that is hard-deleted with the webhook — an archive is
|
||||
long-term storage an operator may still want to keep or move away for
|
||||
offline retention, and destroying it as a side effect of deleting a
|
||||
webhook would be unrecoverable. Removing an `archive-….db` is the
|
||||
operator's call. Deleting a `database` target releases its writer the
|
||||
same way, and for the same reason leaves its file alone.
|
||||
Deleting a webhook releases its archive: the delivery engine's cached
|
||||
archive writer is dropped and its file handle closed, so nothing lingers
|
||||
after the webhook is gone. The archive **file itself is deliberately
|
||||
left on disk**. Unlike the event database — per-webhook working storage
|
||||
that is hard-deleted with the webhook — an archive is long-term storage
|
||||
an operator may still want to keep or move away for offline retention,
|
||||
and destroying it as a side effect of deleting a webhook would be
|
||||
unrecoverable. Removing `archive-{webhookID}.db` is the operator's call.
|
||||
Deleting a webhook's last `database` target releases the writer the same
|
||||
way, and for the same reason leaves the file alone.
|
||||
|
||||
The **Slack target type** sends webhook events as formatted messages to
|
||||
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
|
||||
@@ -2485,21 +2376,20 @@ trade.
|
||||
Net: **one `INFO` line per request, of at most 2,560 bytes.** That
|
||||
ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`,
|
||||
`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11
|
||||
for `method`, plus a 405-byte fixed portion (the field names, the
|
||||
punctuation, both timestamps at their longest, `remoteIP` and
|
||||
`clientIP` each charged as an IPv6 address with a zone, the status and
|
||||
the latency) — 2,156 bytes, stated at 2,560 so the figure has headroom.
|
||||
`internal/middleware/accesslog_test.go` asserts it against 8 KB of
|
||||
client-chosen text in the path, in the query, and in each of
|
||||
`User-Agent`, `Referer`, `X-Request-Id` and `X-Forwarded-For`,
|
||||
for `method`, plus a 336-byte fixed portion (the field names, the
|
||||
punctuation, both timestamps at their longest, an IPv6 `remoteIP` with
|
||||
a zone, the status and the latency) — 2,087 bytes, stated at 2,560 so
|
||||
the figure has headroom. `internal/middleware/accesslog_test.go`
|
||||
asserts it against 8 KB of client-chosen text in the path, in the
|
||||
query, and in each of `User-Agent`, `Referer` and `X-Request-Id`,
|
||||
including cases built from the characters the handlers escape, and
|
||||
against a 5xx that keeps its concrete path while all three header fields
|
||||
are also at their budget and an `X-Forwarded-For` sent from a trusted
|
||||
proxy ends in an IPv6 client address at its longest followed by an 8 KB
|
||||
zone, where `clientIP` must name the address without the zone. Every
|
||||
case runs through both handlers `internal/logger` can select — the JSON
|
||||
one and the text one it installs on a tty — since the two do not escape
|
||||
alike and the ceiling is quoted unqualified.
|
||||
against the widest access log line the service can be made to write: a
|
||||
5xx that keeps its concrete path while all three header fields are also
|
||||
at their budget. Every case runs through both handlers
|
||||
`internal/logger` can select — the JSON one and the text one it installs
|
||||
on a tty — since the two do not escape alike and the ceiling is quoted
|
||||
unqualified. Measured over a real connection, the widest access log line
|
||||
is 1,972 bytes.
|
||||
|
||||
Multiply that ceiling by the request rate to size log storage. Note
|
||||
that the rate is not bounded by the limits above on every route:
|
||||
@@ -2837,9 +2727,9 @@ remedies are to block the source at the reverse proxy, or to
|
||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||
applied without reintroducing the lockout, because the proxy sees the
|
||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
||||
The flood's source is in the `clientIP` field of webhooker's access
|
||||
log while `TRUSTED_PROXIES` covers the proxy, and in the proxy's own
|
||||
access log either way (see [Trusted proxies](#trusted-proxies)).
|
||||
The flood's source is in the proxy's access log: webhooker's own logs
|
||||
record the proxy's address, not the client's (see
|
||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
||||
|
||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||
enforced in the webhook handler) can layer on top of this env-level
|
||||
@@ -2852,7 +2742,7 @@ abuse limit later; they are tracked as future work.
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------- | ----------- |
|
||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||
|
||||
@@ -2874,7 +2764,6 @@ returns to the page that was asked for.
|
||||
| ------ | ------------------------ | ----------- |
|
||||
| `GET` | `/user/{username}` | User profile page |
|
||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
||||
| `GET` | `/hooks` | List user's webhooks |
|
||||
| `GET` | `/hooks/new` | Create webhook form |
|
||||
| `POST` | `/hooks/new` | Create webhook submission |
|
||||
@@ -2926,7 +2815,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
||||
```
|
||||
webhooker/
|
||||
├── 3p/
|
||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||
├── cmd/webhooker/
|
||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||
├── internal/
|
||||
@@ -2988,7 +2877,6 @@ webhooker/
|
||||
│ │ ├── healthcheck.go # Health check handler
|
||||
│ │ ├── index.go # Index page handler
|
||||
│ │ ├── profile.go # User profile handler
|
||||
│ │ ├── settings.go # Read-only Settings page handler
|
||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||
│ │ └── webhook.go # Webhook receiver handler
|
||||
│ ├── healthcheck/
|
||||
@@ -3021,14 +2909,13 @@ webhooker/
|
||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||
│ ├── 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/app.js # Progressive-enhancement copy-to-clipboard
|
||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
||||
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||
├── go.mod / go.sum
|
||||
└── .golangci.yml # Linter configuration
|
||||
```
|
||||
@@ -3048,15 +2935,13 @@ Components are wired via Uber fx in this order:
|
||||
7. `healthcheck.New` — Health check service
|
||||
8. `session.New` — Cookie-based session manager (key from database)
|
||||
9. `handlers.New` — HTTP handlers
|
||||
10. `metrics.NewRegistry` — The registry `/metrics` serves
|
||||
11. `metrics.New` — The delivery collectors, registered on that registry
|
||||
12. `middleware.New` — HTTP middleware
|
||||
13. `delivery.New` — Event-driven delivery engine
|
||||
14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
||||
15. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
||||
16. `delivery.Engine` → `delivery.Archives` — interface bridge so
|
||||
deleting or renaming a webhook or target reaches its archive files
|
||||
17. `server.New` — HTTP server and router
|
||||
10. `middleware.New` — HTTP middleware
|
||||
11. `delivery.New` — Event-driven delivery engine
|
||||
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
|
||||
13. `delivery.Engine` → `delivery.Notifier` — interface bridge
|
||||
14. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
|
||||
deleting a webhook releases its archive writer
|
||||
15. `server.New` — HTTP server and router
|
||||
|
||||
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
|
||||
*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which
|
||||
@@ -3077,7 +2962,7 @@ Applied to all routes in this order:
|
||||
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
||||
Permissions-Policy)
|
||||
3. **Logging** — Structured request logging (method, URL, status,
|
||||
latency, remote IP, client IP, user agent, request ID)
|
||||
latency, remote IP, user agent, request ID)
|
||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||
`METRICS_PASSWORD` are both set)
|
||||
5. **CORS** — Cross-origin resource sharing headers
|
||||
@@ -3099,14 +2984,8 @@ local record instead of nothing. What that placement gives up is
|
||||
recovery of a panic in the six entries above it, none of which does
|
||||
more than set a header or start a timer.
|
||||
|
||||
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
||||
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
|
||||
error page in the normal layout; the global one keeps the plain-text `500` for
|
||||
every other route.
|
||||
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
||||
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||
CSRF middleware in every one of those route groups, because
|
||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||
@@ -3125,7 +3004,7 @@ declared length. A chunked request, or
|
||||
one that lies about its length, is hard-capped by
|
||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||
|
||||
Those same five route groups then apply **CSRF** and **NoCache**
|
||||
Those same four route groups then apply **CSRF** and **NoCache**
|
||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||
rather than global: **PasswordChangeRateLimit** on
|
||||
@@ -3171,12 +3050,12 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
by middleware that runs before CSRF parses the form
|
||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||
on all state-changing forms (cookie-based double-submit tokens with
|
||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
|
||||
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
||||
POSTs) and `/api` (stateless API). The middleware detects TLS
|
||||
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
||||
session cookie uses — to set appropriate cookie security flags and
|
||||
Origin/Referer validation mode
|
||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||
`/api` (stateless API). The middleware detects TLS per-request through
|
||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||
to set appropriate cookie security flags and Origin/Referer validation
|
||||
mode
|
||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||
about an inbound request is verified; possession of the UUID
|
||||
authorises submission, and no shared secret or signature check will
|
||||
@@ -3190,8 +3069,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
route through a single decision function, so they cannot disagree
|
||||
about a destination. An operator can permit specific blocks with
|
||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
||||
guard cannot be switched off, and link-local, the unspecified
|
||||
addresses `0.0.0.0` and `::`, and a
|
||||
guard cannot be switched off, and link-local plus a
|
||||
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
||||
metadata endpoints — several of which are ULAs outside link-local —
|
||||
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
||||
@@ -3349,9 +3227,8 @@ version is fixed independently of the compiler's:
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||
stamps the version. The version is the `VERSION` build arg if one is
|
||||
given, otherwise derived from the `.git` in the context, and the
|
||||
stage fails if a context with `.git` would stamp `unknown` (see
|
||||
stamps the version. The version arrives as the `VERSION` build arg,
|
||||
since the context has no `.git` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||
@@ -3374,9 +3251,8 @@ linked, which is what lets it run on the Alpine runtime image.
|
||||
inside the image, so a build that succeeds is a repo that is formatted,
|
||||
linted, tested and compiled. `script/lint` also uses Docker
|
||||
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
||||
run the same pinned linter version the gate does; of the steps
|
||||
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
||||
host.
|
||||
run the same pinned linter version the gate does; only `script/test`
|
||||
and `script/fmt-check` run on the host.
|
||||
|
||||
#### CI gate honesty
|
||||
|
||||
@@ -3384,13 +3260,19 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
|
||||
test stages replayed rather than executed, which would make a green
|
||||
check meaningless. The `check` workflow therefore writes
|
||||
`.ci-fingerprint` into the build context before building. Its value is
|
||||
the hash of the commit being checked, so every commit, docs-only ones
|
||||
and a squash merge whose tree matches an already-built branch included,
|
||||
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
||||
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
||||
and `make build`. A run that reports success ran them.
|
||||
the hash of the last commit that touched the build context, so:
|
||||
|
||||
The module download layer sits above `COPY . .` and stays cached.
|
||||
- Any commit that changes code (including a squash merge whose tree
|
||||
matches an already-built branch) gets a new fingerprint, invalidates
|
||||
the `COPY . .` layer of both check stages, and really runs
|
||||
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
||||
run that reports success ran them.
|
||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||
anyway — so the image replays from cache and costs seconds.
|
||||
|
||||
The module download layer sits above `COPY . .` and stays cached either
|
||||
way.
|
||||
|
||||
A separate workflow step, run before the fingerprint is written, covers
|
||||
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
||||
|
||||
@@ -40,6 +40,12 @@ duplicate. That is deliberate — the alternative is a silent lost
|
||||
delivery — and the README says so under Rationale. It is not a defect
|
||||
to re-file.
|
||||
|
||||
One caveat on reading a green check: a docs-only commit deliberately
|
||||
replays from the layer cache
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||
such a commit evidences a replay rather than an executed run. A code
|
||||
commit invalidates the `COPY` layer and genuinely executes.
|
||||
|
||||
# Next Step
|
||||
|
||||
Clear the rest of the open 1.0.0 milestone
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
@@ -178,10 +177,6 @@ func newApp() *fx.App {
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
handlers.New,
|
||||
// The registry /metrics serves, and the delivery
|
||||
// collectors registered on it.
|
||||
metrics.NewRegistry,
|
||||
metrics.New,
|
||||
middleware.New,
|
||||
// The one SSRF guard both target-creation validation
|
||||
// and the delivery dialer consult, so they cannot
|
||||
@@ -192,10 +187,11 @@ func newApp() *fx.App {
|
||||
// Wire *delivery.Engine as delivery.Notifier so the
|
||||
// webhook handler can notify the engine of new deliveries.
|
||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||
// Wire *delivery.Engine as delivery.Archives so deleting
|
||||
// or renaming a webhook or target reaches its archive
|
||||
// files.
|
||||
func(e *delivery.Engine) delivery.Archives { return e },
|
||||
// Wire *delivery.Engine as delivery.WebhookEvictor so
|
||||
// deleting a webhook releases its archive writer.
|
||||
func(e *delivery.Engine) delivery.WebhookEvictor {
|
||||
return e
|
||||
},
|
||||
server.New,
|
||||
),
|
||||
fx.Invoke(
|
||||
|
||||
@@ -4,8 +4,6 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/getsentry/sentry-go v0.25.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
@@ -31,12 +29,7 @@ require (
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -57,7 +50,7 @@ require (
|
||||
go.uber.org/zap v1.23.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
|
||||
@@ -6,12 +6,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -29,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
@@ -69,16 +55,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -129,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
|
||||
@@ -149,6 +149,7 @@ type ConfigParams struct {
|
||||
type Config struct {
|
||||
DataDir string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
Environment string
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
@@ -195,13 +196,12 @@ type Config struct {
|
||||
// otherwise refuse. The guard itself is always on: there is no
|
||||
// setting that disables SSRF protection, and delivery's
|
||||
// alwaysBlockedNetworks stays blocked no matter what is listed
|
||||
// here. That set is link-local, the unspecified addresses
|
||||
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
||||
// link-local that disclose credentials or user data at a
|
||||
// provider-fixed, non-public address; it is not exhaustive of
|
||||
// every cloud's metadata address. See
|
||||
// alwaysBlockedNetworks for the authoritative list and why
|
||||
// each entry is on it.
|
||||
// here. That set is link-local plus the cloud metadata
|
||||
// endpoints outside it that disclose credentials or user data
|
||||
// at a provider-fixed, non-public address; it is not
|
||||
// exhaustive of every cloud's metadata address. See
|
||||
// alwaysBlockedNetworks for the authoritative list and the
|
||||
// criterion it is built from.
|
||||
AllowedEgressCIDRs []netip.Prefix
|
||||
|
||||
params *ConfigParams
|
||||
@@ -657,6 +657,11 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retentionSweepInterval, err := envPositiveDuration(
|
||||
"RETENTION_SWEEP_INTERVAL",
|
||||
defaultRetentionSweepInterval,
|
||||
@@ -706,6 +711,7 @@ func loadFromEnv() (*Config, error) {
|
||||
return &Config{
|
||||
DataDir: DataDir(),
|
||||
Debug: debug,
|
||||
MaintenanceMode: maintenanceMode,
|
||||
Environment: environment,
|
||||
MetricsUsername: metricsUsername,
|
||||
MetricsPassword: metricsPassword,
|
||||
@@ -792,6 +798,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
// host can reach the admin UI.
|
||||
"bindAddress", s.BindAddress,
|
||||
"debug", s.Debug,
|
||||
"maintenanceMode", s.MaintenanceMode,
|
||||
"dataDir", s.DataDir,
|
||||
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||
// Logged because a perfectly valid non-positive value here
|
||||
|
||||
@@ -124,11 +124,6 @@ func testEnvironmentConfigSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||
// running after a start or stop timeout would write there after
|
||||
// the test has returned. The same holds for every fxtest.New
|
||||
// below.
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -277,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -370,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -411,7 +404,6 @@ func TestDefaultDataDir(t *testing.T) {
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -542,7 +534,6 @@ func testReceiverRateLimitSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -659,7 +650,6 @@ func testTrustedProxiesSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -773,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -1017,7 +1006,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.NopLogger,
|
||||
fx.Provide(globals.New, logger.New, config.New),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
@@ -18,9 +18,10 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
|
||||
|
||||
// Real configuration variables exercised by the config.New tests.
|
||||
const (
|
||||
envKeyPort = "PORT"
|
||||
envKeyDebug = "DEBUG"
|
||||
envKeyBindAddress = "BIND_ADDRESS"
|
||||
envKeyPort = "PORT"
|
||||
envKeyDebug = "DEBUG"
|
||||
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||
envKeyBindAddress = "BIND_ADDRESS"
|
||||
)
|
||||
|
||||
// Sample BIND_ADDRESS values used by the tables below.
|
||||
@@ -603,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
|
||||
value: "ture",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||
key: envKeyMaintenanceMode,
|
||||
value: "sometimes",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +656,8 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
for _, key := range []string{
|
||||
envKeyPort, envKeyDebug, envKeyBindAddress, envKeySentryDSN,
|
||||
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||
envKeyBindAddress, envKeySentryDSN,
|
||||
} {
|
||||
require.NoError(t, os.Unsetenv(key))
|
||||
}
|
||||
@@ -660,6 +668,7 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.False(t, cfg.Debug)
|
||||
assert.False(t, cfg.MaintenanceMode)
|
||||
|
||||
// Loopback, not the wildcard: the default must not publish the
|
||||
// cleartext admin UI and the unauthenticated receiver on every
|
||||
|
||||
@@ -15,7 +15,6 @@ type APIKey struct {
|
||||
Description string `json:"description"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// User.APIKeys leads back here, and the JSON could loop.
|
||||
User User `json:"-"`
|
||||
// Relations
|
||||
User User `json:"user,omitzero"`
|
||||
}
|
||||
|
||||
@@ -56,10 +56,8 @@ type Delivery struct {
|
||||
// the index.
|
||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||
// JSON could loop.
|
||||
Event Event `json:"-"`
|
||||
Target Target `json:"-"`
|
||||
// Relations
|
||||
Event Event `json:"event,omitzero"`
|
||||
Target Target `json:"target,omitzero"`
|
||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ type DeliveryResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// Delivery.DeliveryResults leads back here, and the JSON could loop.
|
||||
Delivery Delivery `json:"-"`
|
||||
// Relations
|
||||
Delivery Delivery `json:"delivery,omitzero"`
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ type Entrypoint struct {
|
||||
Description string `json:"description"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// Webhook.Entrypoints leads back here, and the JSON could loop.
|
||||
Webhook Webhook `json:"-"`
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
}
|
||||
|
||||
@@ -44,9 +44,8 @@ type Event struct {
|
||||
// kept as the record of where the copy came from either way.
|
||||
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to, so
|
||||
// Webhook and Entrypoint are left out of the JSON.
|
||||
Webhook Webhook `json:"-"`
|
||||
Entrypoint Entrypoint `json:"-"`
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
|
||||
// reference to the record it belongs to is left out of the JSON, so a
|
||||
// webhook and its targets cannot marshal each other in a loop, and that
|
||||
// GORM still preloads that reference, since it ignores json tags.
|
||||
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
stored := database.Webhook{
|
||||
UserID: uuid.New().String(),
|
||||
Name: testWebhookName,
|
||||
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
||||
Targets: []database.Target{{
|
||||
Name: "log",
|
||||
Type: database.TargetTypeLog,
|
||||
}},
|
||||
}
|
||||
require.NoError(t, db.Create(&stored).Error)
|
||||
|
||||
entrypointID := stored.Entrypoints[0].ID
|
||||
targetID := stored.Targets[0].ID
|
||||
|
||||
var webhook database.Webhook
|
||||
|
||||
require.NoError(t, db.
|
||||
Preload("Entrypoints.Webhook").
|
||||
Preload("Targets.Webhook").
|
||||
First(&webhook, "id = ?", stored.ID).Error)
|
||||
|
||||
require.Len(t, webhook.Entrypoints, 1)
|
||||
require.Len(t, webhook.Targets, 1)
|
||||
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
|
||||
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
|
||||
|
||||
encoded := marshalModel(t, webhook)
|
||||
|
||||
assert.Contains(t, encoded, entrypointID)
|
||||
assert.Contains(t, encoded, targetID)
|
||||
|
||||
// Each child holds the parent's id as its webhookId, so the parent
|
||||
// is looked for by its own id field.
|
||||
parentIDField := `"id":"` + stored.ID + `"`
|
||||
|
||||
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
|
||||
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
|
||||
|
||||
var target database.Target
|
||||
|
||||
require.NoError(t, db.
|
||||
Preload("Webhook").
|
||||
First(&target, "id = ?", targetID).Error)
|
||||
|
||||
assert.Equal(t, stored.ID, target.Webhook.ID)
|
||||
|
||||
encoded = marshalModel(t, target)
|
||||
|
||||
assert.Contains(t, encoded, stored.ID)
|
||||
assert.NotContains(t, encoded, parentIDField)
|
||||
}
|
||||
|
||||
// TestModelsMarshalWithoutTheirParent covers the other references to a
|
||||
// parent: each model is built with its parent set, and the parent's id
|
||||
// must not appear in the JSON.
|
||||
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := database.BaseModel{ID: uuid.New().String()}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
model any
|
||||
}{
|
||||
{
|
||||
name: "Webhook.User",
|
||||
model: database.Webhook{User: database.User{BaseModel: parent}},
|
||||
},
|
||||
{
|
||||
name: "APIKey.User",
|
||||
model: database.APIKey{User: database.User{BaseModel: parent}},
|
||||
},
|
||||
{
|
||||
name: "Delivery.Event",
|
||||
model: database.Delivery{Event: database.Event{BaseModel: parent}},
|
||||
},
|
||||
{
|
||||
name: "Delivery.Target",
|
||||
model: database.Delivery{Target: database.Target{BaseModel: parent}},
|
||||
},
|
||||
{
|
||||
name: "DeliveryResult.Delivery",
|
||||
model: database.DeliveryResult{
|
||||
Delivery: database.Delivery{BaseModel: parent},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Event.Webhook",
|
||||
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
|
||||
},
|
||||
{
|
||||
name: "Event.Entrypoint",
|
||||
model: database.Event{
|
||||
Entrypoint: database.Entrypoint{BaseModel: parent},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,7 @@ type Target struct {
|
||||
MaxRetries int `json:"maxRetries,omitempty"`
|
||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// Webhook.Targets leads back here, and the JSON could loop.
|
||||
Webhook Webhook `json:"-"`
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -66,9 +66,8 @@ type Webhook struct {
|
||||
// must equal DefaultRetentionDays.
|
||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// User.Webhooks leads back here, and the JSON could loop.
|
||||
User User `json:"-"`
|
||||
// Relations
|
||||
User User `json:"user,omitzero"`
|
||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
|
||||
@@ -184,6 +184,16 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
|
||||
wh := webhooks[i]
|
||||
|
||||
// Skip retain-forever webhooks before building any query.
|
||||
// RetainsForever covers both the RetentionForeverDays
|
||||
// sentinel and the non-positive values that predate it: the
|
||||
// sentinel is a positive number, so without this the reaper
|
||||
// would compute a cutoff a thousand years in the past and
|
||||
// issue a DELETE matching nothing on every single sweep.
|
||||
if wh.RetainsForever() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing to reap if the per-webhook database has never
|
||||
// been created.
|
||||
if !r.dbManager.DBExists(wh.ID) {
|
||||
@@ -202,13 +212,6 @@ func (r *RetentionReaper) reapWebhook(
|
||||
webhookID string,
|
||||
retentionDays int,
|
||||
) {
|
||||
// A retain-forever webhook has no cutoff, so its database is not
|
||||
// even opened.
|
||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := r.dbManager.GetDB(webhookID)
|
||||
if err != nil {
|
||||
r.log.Error(
|
||||
@@ -220,6 +223,11 @@ func (r *RetentionReaper) reapWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := reapExpired(ctx, db, cutoff)
|
||||
if err != nil {
|
||||
r.log.Error(
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
t,
|
||||
overflowingRetentionDays,
|
||||
database.RetentionForeverDays,
|
||||
"the test value must not be treated as retain-forever",
|
||||
"the test value must not be rescued by the forever skip",
|
||||
)
|
||||
|
||||
webhookID := createWebhook(
|
||||
|
||||
@@ -102,13 +102,6 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
||||
// seedExpiredEvents stores count events created at the given time,
|
||||
// each with a delivered delivery to one target and a failed delivery
|
||||
// to the other, and one attempt for each delivery.
|
||||
//
|
||||
// It and seedBareEvents insert 50 rows per statement, not more. The
|
||||
// SQLite driver looks up each parameter's value by scanning the
|
||||
// statement's arguments from the first until it reaches that
|
||||
// parameter's, so the time to bind a statement grows with the square of
|
||||
// its parameter count: at 500 rows, several thousand parameters, the
|
||||
// seeding took most of these tests' time under -race.
|
||||
func seedExpiredEvents(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
@@ -145,8 +138,8 @@ func seedExpiredEvents(
|
||||
)
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
||||
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
|
||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||
|
||||
results := make([]database.DeliveryResult, len(deliveries))
|
||||
for i := range deliveries {
|
||||
@@ -155,7 +148,7 @@ func seedExpiredEvents(
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(results, 50).Error)
|
||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||
}
|
||||
|
||||
// seedBareEvents stores count events created at the given time, with
|
||||
@@ -179,7 +172,7 @@ func seedBareEvents(
|
||||
events[i].CreatedAt = createdAt
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||
}
|
||||
|
||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// ArchiveSweeper periodically prunes expired rows from the
|
||||
// archive databases of database targets that carry a positive
|
||||
// expiry.
|
||||
// ArchiveSweeper periodically prunes expired rows from
|
||||
// per-webhook archive databases whose database target carries a
|
||||
// positive expiry.
|
||||
//
|
||||
// Without it, pruning happens only when an archive is
|
||||
// (re)opened, and archives are only ever reopened by writes: an
|
||||
// archive whose target has stopped receiving events would keep
|
||||
// its expired rows forever. The sweep closes
|
||||
// archive belonging to a webhook that has stopped receiving
|
||||
// events would keep its expired rows forever. The sweep closes
|
||||
// that gap without changing anything for archives whose expiry
|
||||
// is unset or "never".
|
||||
//
|
||||
@@ -45,13 +44,8 @@ type ArchiveSweeper struct {
|
||||
eng *Engine
|
||||
log *slog.Logger
|
||||
interval time.Duration
|
||||
|
||||
// cancel needs no lock: fx calls the stop hook only after the
|
||||
// start hook has returned, so stop never reads it while start
|
||||
// is still setting it.
|
||||
cancel context.CancelFunc
|
||||
|
||||
wg sync.WaitGroup
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewArchiveSweeper creates the archive sweeper and registers
|
||||
@@ -161,25 +155,17 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
|
||||
// soft-deleted along with it, so GORM's default scope already
|
||||
// excludes them.
|
||||
//
|
||||
// A failure for one target is logged and the sweep continues,
|
||||
// A failure for one webhook is logged and the sweep continues,
|
||||
// matching how the write path already treats a prune error as
|
||||
// non-fatal.
|
||||
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||
var targets []database.Target
|
||||
|
||||
err := s.db.DB().
|
||||
WithContext(ctx).
|
||||
Model(&database.Target{}).
|
||||
Where("type = ?", database.TargetTypeDatabase).
|
||||
Find(&targets).Error
|
||||
if err != nil {
|
||||
// The app stopping as a sweep starts cancels the listing.
|
||||
// Stopping is not a failure, so it must not produce an
|
||||
// error line.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Error(
|
||||
"archive sweep: failed to list database targets",
|
||||
"error", err,
|
||||
@@ -224,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
|
||||
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A writer evicted, or a target row gone, underneath the sweep
|
||||
// means the operator deleted the target or its webhook while
|
||||
// the sweep was walking the target list. That is an ordinary
|
||||
// A writer evicted underneath the sweep means the operator
|
||||
// deleted the webhook (or its last database target) while the
|
||||
// sweep was walking the target list. That is an ordinary
|
||||
// interleaving, not a failure, so it must not produce an
|
||||
// error line.
|
||||
if errors.Is(err, errArchiveWriterEvicted) ||
|
||||
errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if errors.Is(err, errArchiveWriterEvicted) {
|
||||
s.log.Debug(
|
||||
"archive sweep: target deleted mid-sweep",
|
||||
"archive sweep: writer evicted mid-sweep",
|
||||
"webhook_id", target.WebhookID,
|
||||
"target_id", target.ID,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -36,23 +34,18 @@ const (
|
||||
sweepConcurrentWrites = 20
|
||||
)
|
||||
|
||||
// archiveTestWebhookName is the name of every webhook
|
||||
// seedDatabaseTarget creates. It is not safe in a file name as it
|
||||
// stands, so every archive test goes through archiveNamePart.
|
||||
const archiveTestWebhookName = "Sweep Test!"
|
||||
|
||||
// archiveEnv bundles the pieces an archive test drives: a main
|
||||
// configuration database holding webhooks and targets, a delivery
|
||||
// engine owning the archive writer registry, the archive sweeper,
|
||||
// and the data directory the archive files live in.
|
||||
type archiveEnv struct {
|
||||
// sweeperEnv bundles the pieces an archive sweep test drives:
|
||||
// a main configuration database holding webhooks and targets, a
|
||||
// delivery engine owning the archive writer registry, and the
|
||||
// data directory the archive files live in.
|
||||
type sweeperEnv struct {
|
||||
sweeper *delivery.ArchiveSweeper
|
||||
eng *delivery.Engine
|
||||
mainDB *database.Database
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
func setupSweeperTest(t *testing.T) *sweeperEnv {
|
||||
t.Helper()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
@@ -85,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
1,
|
||||
)
|
||||
|
||||
return &archiveEnv{
|
||||
return &sweeperEnv{
|
||||
sweeper: delivery.NewTestArchiveSweeper(
|
||||
mainDB, eng, log,
|
||||
),
|
||||
@@ -95,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
}
|
||||
}
|
||||
|
||||
// archivePath returns where the engine keeps a database target's
|
||||
// archive file, for the names seedDatabaseTarget gave it.
|
||||
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
||||
// archivePath returns where the engine keeps a webhook's
|
||||
// archive file.
|
||||
func (env *sweeperEnv) archivePath(webhookID string) string {
|
||||
return filepath.Join(
|
||||
env.dataDir,
|
||||
delivery.ArchiveFileName(
|
||||
archiveTestWebhookName, tgt.Name, tgt.ID,
|
||||
),
|
||||
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||
)
|
||||
}
|
||||
|
||||
// seedDatabaseTarget creates a webhook with one database target
|
||||
// carrying the given target config JSON, and returns the target.
|
||||
func (env *archiveEnv) seedDatabaseTarget(
|
||||
// carrying the given target config JSON, and returns the
|
||||
// webhook id.
|
||||
func (env *sweeperEnv) seedDatabaseTarget(
|
||||
t *testing.T, configJSON string,
|
||||
) *database.Target {
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: uuid.New().String(),
|
||||
Name: archiveTestWebhookName,
|
||||
Name: "sweep-test",
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
@@ -124,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
|
||||
Create(wh).Error,
|
||||
)
|
||||
|
||||
return env.addDatabaseTarget(t, wh.ID, configJSON)
|
||||
}
|
||||
|
||||
// addDatabaseTarget creates one more database target on an
|
||||
// existing webhook and returns it.
|
||||
func (env *archiveEnv) addDatabaseTarget(
|
||||
t *testing.T, webhookID, configJSON string,
|
||||
) *database.Target {
|
||||
t.Helper()
|
||||
|
||||
tgt := &database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: "Archive",
|
||||
WebhookID: wh.ID,
|
||||
Name: "archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
Config: configJSON,
|
||||
@@ -148,19 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
|
||||
Create(tgt).Error,
|
||||
)
|
||||
|
||||
return tgt
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
// seedArchiveRows creates the archive file for a target and
|
||||
// seedArchiveRows creates the archive file for a webhook and
|
||||
// inserts one row per supplied archived-at timestamp, returning
|
||||
// the archive path. The handle is closed before returning, so
|
||||
// the archive is idle exactly as it would be with no traffic.
|
||||
func (env *archiveEnv) seedArchiveRows(
|
||||
t *testing.T, tgt *database.Target, archivedAt ...time.Time,
|
||||
func (env *sweeperEnv) seedArchiveRows(
|
||||
t *testing.T, webhookID string, archivedAt ...time.Time,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
path := env.archivePath(tgt)
|
||||
path := env.archivePath(webhookID)
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||
@@ -179,7 +160,7 @@ func (env *archiveEnv) seedArchiveRows(
|
||||
for i, at := range archivedAt {
|
||||
row := delivery.ExportArchivedEvent{
|
||||
EventID: fmt.Sprintf("ev-%d", i),
|
||||
WebhookID: tgt.WebhookID,
|
||||
WebhookID: webhookID,
|
||||
Method: http.MethodPost,
|
||||
Body: `{"seeded":true}`,
|
||||
ArchivedAt: at,
|
||||
@@ -262,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
|
||||
now := time.Now()
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
t, webhookID,
|
||||
now.Add(-48*time.Hour),
|
||||
now.Add(-time.Minute),
|
||||
)
|
||||
@@ -306,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
||||
}
|
||||
|
||||
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
|
||||
// interleaving where a sweep tick has already listed a target
|
||||
// when the target is deleted and its writer evicted. The sweep
|
||||
// must not put a writer back into the registry: nothing would
|
||||
// ever evict it again, which is precisely the leak this change
|
||||
// exists to close.
|
||||
// interleaving where a sweep tick has already listed a webhook's
|
||||
// target when the webhook is deleted and its writer evicted. The
|
||||
// sweep must not put a writer back into the registry: nothing
|
||||
// would ever evict it again, which is precisely the leak this
|
||||
// change exists to close.
|
||||
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
// Prime the registry the way a delivery would, then evict as
|
||||
// the deletion path does. The target row is deliberately left
|
||||
// in place: this is the tick that listed the target before
|
||||
// in place: this is the tick that listed the webhook before
|
||||
// the deletion committed.
|
||||
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||
_, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.eng.EvictTarget(tgt.ID)
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
env.eng.EvictWebhook(webhookID)
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
"a sweep must never re-register a writer for a target "+
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"a sweep must never re-register a writer for a webhook "+
|
||||
"whose registry entry has already been released",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_LeavesNoRegistryEntry states the same
|
||||
// invariant in its general form: sweeping an archive whose
|
||||
// target has no cached writer must not leave one behind, so the
|
||||
// webhook has no cached writer must not leave one behind, so the
|
||||
// registry keeps holding only writers a delivery created and an
|
||||
// eviction can reach.
|
||||
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
t, webhookID,
|
||||
time.Now().Add(-48*time.Hour),
|
||||
time.Now().Add(-time.Minute),
|
||||
)
|
||||
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
@@ -368,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||
"the sweep must still prune an idle archive",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"the sweep must release the registry entry it created",
|
||||
)
|
||||
}
|
||||
@@ -383,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
event.WebhookID = webhookID
|
||||
d := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"a delivery's writer must stay registered",
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"a sweep must not drop a writer a delivery owns",
|
||||
)
|
||||
}
|
||||
@@ -439,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
||||
tgt.ID,
|
||||
webhookID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(
|
||||
@@ -458,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
// The delivery lands mid-sweep and adopts the entry.
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
event.WebhookID = webhookID
|
||||
d := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||
)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
adopted := env.eng.ExportArchiveWriterFor(webhookID)
|
||||
require.NotNil(t, adopted)
|
||||
require.True(
|
||||
t, sweepWriter.Same(adopted),
|
||||
"the delivery must have adopted the sweep's writer",
|
||||
)
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||
"the delivery leaves the archive handle open",
|
||||
)
|
||||
|
||||
// The sweep finishes.
|
||||
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
|
||||
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"a writer adopted by a delivery during a sweep must "+
|
||||
"stay registered, or its open handle is unreachable",
|
||||
)
|
||||
|
||||
env.eng.EvictTarget(tgt.ID)
|
||||
env.eng.EvictWebhook(webhookID)
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"the adopted writer must still be evictable",
|
||||
)
|
||||
assert.False(
|
||||
@@ -494,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
|
||||
// failure for one target does not abort the sweep for the
|
||||
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
|
||||
// failure for one webhook does not abort the sweep for the
|
||||
// others: an unparseable expiry and an unreadable archive both
|
||||
// have to be logged and stepped over.
|
||||
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
||||
func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
// Seeded first so the sweep reaches them before the healthy
|
||||
// target: targets come back in insertion order.
|
||||
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
||||
// webhook: targets come back in insertion order.
|
||||
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
||||
env.seedArchiveRows(
|
||||
t, badConfig, time.Now().Add(-48*time.Hour),
|
||||
t, badConfigID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
require.NoError(t, os.WriteFile(
|
||||
env.archivePath(corrupt),
|
||||
env.archivePath(corruptID),
|
||||
[]byte("this is not a sqlite database"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
healthyPath := env.seedArchiveRows(
|
||||
t, healthy,
|
||||
t, healthyID,
|
||||
time.Now().Add(-48*time.Hour),
|
||||
time.Now().Add(-time.Minute),
|
||||
)
|
||||
@@ -531,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
||||
assert.Equal(
|
||||
t, []string{sweepRowNew},
|
||||
archivedEventIDs(t, healthyPath),
|
||||
"a failure for an earlier target must not stop the "+
|
||||
"a failure for an earlier webhook must not stop the "+
|
||||
"sweep from pruning the ones after it",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
|
||||
// of the two no-create guards. The first is the stat in
|
||||
// sweepExpired; this one is the SQLite open mode, which is what
|
||||
// sweepWebhook; this one is the SQLite open mode, which is what
|
||||
// protects the window between that stat and the open. Flipping
|
||||
// the sweep's mode to create-if-missing makes this fail.
|
||||
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
||||
@@ -574,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
||||
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
|
||||
now := time.Now()
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
t, webhookID,
|
||||
now.Add(-48*time.Hour),
|
||||
now.Add(-time.Minute),
|
||||
)
|
||||
@@ -613,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
||||
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
@@ -653,94 +640,39 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
event.WebhookID = webhookID
|
||||
d := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||
)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||
"the delivery must leave the archive handle open",
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"the delivery's registry entry must survive the sweep",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.eng.ExportArchiveHandleOpen(webhookID),
|
||||
"the sweep must leave the archive closed",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
|
||||
// closes the handle it finds open before it reopens the file.
|
||||
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
|
||||
// close, the reopen replaces the handle without closing it, the
|
||||
// sweep then closes only the new one, and one connection leaks per
|
||||
// archive per sweep.
|
||||
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Open(time.Hour))
|
||||
|
||||
before, err := w.DB().DB()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, w.SweepExpired(time.Hour))
|
||||
|
||||
assert.Error(
|
||||
t, before.PingContext(t.Context()),
|
||||
"the handle open before the sweep must be closed by it",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
|
||||
// context is already cancelled, as when the app stops just as a
|
||||
// sweep starts, returns without an error line: stopping is not a
|
||||
// failure.
|
||||
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
var errorLines bytes.Buffer
|
||||
|
||||
sweeper := delivery.NewTestArchiveSweeper(
|
||||
env.mainDB, env.eng,
|
||||
slog.New(slog.NewTextHandler(
|
||||
&errorLines,
|
||||
&slog.HandlerOptions{Level: slog.LevelError},
|
||||
)),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
sweeper.ExportSweep(ctx)
|
||||
|
||||
assert.Empty(
|
||||
t, errorLines.String(),
|
||||
"a cancelled sweep must not log at error level",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
||||
// no-op for the default retention policy, so archives with no
|
||||
// expiry (or the literal "never") behave exactly as before.
|
||||
@@ -752,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
||||
`{"expiry":""}`,
|
||||
"",
|
||||
} {
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, configJSON)
|
||||
webhookID := env.seedDatabaseTarget(t, configJSON)
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
t, webhookID,
|
||||
time.Now().Add(-10000*time.Hour),
|
||||
)
|
||||
|
||||
@@ -767,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
||||
"config %q must keep rows forever", configJSON,
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.eng.ExportHasArchiveWriter(webhookID),
|
||||
"config %q must leave no registry entry behind",
|
||||
configJSON,
|
||||
)
|
||||
@@ -790,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
||||
path := env.archivePath(tgt)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
||||
path := env.archivePath(webhookID)
|
||||
|
||||
seedUnmigratedArchive(t, path)
|
||||
require.False(t, archiveTableExists(t, path))
|
||||
@@ -836,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
|
||||
}
|
||||
|
||||
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
||||
// never conjures an archive: a database target that has never
|
||||
// received an event must still have no archive file (nor SQLite
|
||||
// sidecar) after a sweep, and no registry entry either.
|
||||
// never conjures an archive: a webhook with a database target
|
||||
// that has never received an event must still have no archive
|
||||
// file (nor SQLite sidecar) after a sweep.
|
||||
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
path := env.archivePath(tgt)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
path := env.archivePath(webhookID)
|
||||
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
@@ -857,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||
"the sweep must not create an archive file",
|
||||
)
|
||||
}
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
"the sweep must leave no registry entry behind",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
|
||||
@@ -873,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
|
||||
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||
path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
@@ -892,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
||||
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
env.mainDB.DB().
|
||||
Where("webhook_id = ?", tgt.WebhookID).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Delete(&database.Target{}).Error,
|
||||
)
|
||||
|
||||
@@ -915,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
|
||||
// against writes through the target's writer mutex. Run under
|
||||
// -race, an unsynchronised sweep would be caught here.
|
||||
// against writes through the per-webhook writer mutex. Run
|
||||
// under -race, an unsynchronised sweep would be caught here.
|
||||
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
|
||||
@@ -935,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
|
||||
for range sweepConcurrentWrites {
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
event.WebhookID = webhookID
|
||||
|
||||
deliveries = append(
|
||||
deliveries,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"1h"}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -964,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.FileExists(t, env.archivePath(tgt))
|
||||
assert.FileExists(t, env.archivePath(webhookID))
|
||||
}
|
||||
|
||||
// TestArchiveSweeper_StopsCleanly proves the background loop
|
||||
@@ -972,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
t, webhookID, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
env.sweeper.ExportSetInterval(time.Millisecond)
|
||||
@@ -1000,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
env := setupSweeperTest(t)
|
||||
|
||||
lc := &recordingLifecycle{}
|
||||
env.sweeper.ExportRegisterHooks(lc)
|
||||
|
||||
+25
-71
@@ -14,7 +14,6 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
@@ -123,24 +122,21 @@ type Notifier interface {
|
||||
Notify(tasks []Task)
|
||||
}
|
||||
|
||||
// Archives is how the handlers keep the database targets' archive
|
||||
// files in step with the configuration. Deleting a webhook or a
|
||||
// target releases the cached archive writers, whose open file
|
||||
// handles would otherwise outlive them; renaming one renames the
|
||||
// archive files, which are named for the webhook and the target
|
||||
// (see ArchiveFileName).
|
||||
// WebhookEvictor releases the delivery engine's per-webhook
|
||||
// state for a webhook that no longer needs it — currently the
|
||||
// cached archive writer of the database target, whose open
|
||||
// file handle would otherwise outlive the webhook.
|
||||
//
|
||||
// It is deliberately separate from Notifier: archiving lifecycle
|
||||
// is not notification, and a small interface keeps the handlers
|
||||
// package free of any dependency on the engine's internals while
|
||||
// staying trivially fakeable in tests.
|
||||
// It is deliberately separate from Notifier and deliberately
|
||||
// one method wide: archiving lifecycle is not notification, and
|
||||
// a single-method interface keeps the handlers package free of
|
||||
// any dependency on the engine's internals while staying
|
||||
// trivially fakeable in tests.
|
||||
//
|
||||
// Neither eviction deletes an archive file. Both are idempotent
|
||||
// and are no-ops for a webhook or target with no engine state.
|
||||
type Archives interface {
|
||||
// EvictWebhook never deletes an archive file. It is idempotent
|
||||
// and is a no-op for a webhook with no engine state.
|
||||
type WebhookEvictor interface {
|
||||
EvictWebhook(webhookID string)
|
||||
EvictTarget(targetID string)
|
||||
Rename(targetID, webhookName, targetName string) error
|
||||
}
|
||||
|
||||
// EngineParams are the fx dependencies for the delivery
|
||||
@@ -150,10 +146,8 @@ type EngineParams struct {
|
||||
|
||||
DB *database.Database
|
||||
DBManager *database.WebhookDBManager
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
SSRFGuard *Guard
|
||||
Metrics *metrics.Set
|
||||
}
|
||||
|
||||
// Engine processes queued deliveries in the background
|
||||
@@ -173,14 +167,10 @@ type Engine struct {
|
||||
retryCh chan Task
|
||||
workers int
|
||||
|
||||
// version is the running build's version, the one the web UI
|
||||
// footer shows. userAgent puts it on every outbound request.
|
||||
version string
|
||||
|
||||
// mtr is the delivery metric set. Production wires the one
|
||||
// registered on the registry /metrics serves; a test can
|
||||
// substitute a set registered on a registry it holds, so it can
|
||||
// gather what its own deliveries recorded.
|
||||
// mtr is the delivery metric set. Production wires the
|
||||
// process-wide one; a test can substitute a set registered on
|
||||
// a private registry so its assertions are not disturbed by
|
||||
// deliveries other tests are making at the same time.
|
||||
mtr *metrics.Set
|
||||
|
||||
// targets maps each target type to its implementation.
|
||||
@@ -191,7 +181,7 @@ type Engine struct {
|
||||
httpTarget *httpTarget
|
||||
|
||||
// dbTarget is retained so the engine can reach the archive
|
||||
// writer registry for eviction, renames and the idle sweep.
|
||||
// writer registry for webhook eviction and the idle sweep.
|
||||
dbTarget *databaseTarget
|
||||
|
||||
// inflight is the set of deliveries this engine currently owns.
|
||||
@@ -214,8 +204,7 @@ func New(
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: defaultWorkers,
|
||||
version: params.Globals.Version,
|
||||
mtr: params.Metrics,
|
||||
mtr: metrics.Default(),
|
||||
}
|
||||
|
||||
e.initTargets(&http.Client{
|
||||
@@ -260,44 +249,17 @@ func (e *Engine) Notify(tasks []Task) {
|
||||
}
|
||||
}
|
||||
|
||||
// EvictWebhook implements Archives. The cached archive writer of
|
||||
// every database target of the webhook is dropped from the
|
||||
// registry and its file handle closed. The archive files
|
||||
// themselves are left on disk — they are long-term storage the
|
||||
// operator owns.
|
||||
// EvictWebhook implements WebhookEvictor. It releases the
|
||||
// engine's per-webhook archiving state: the database target's
|
||||
// cached archive writer is dropped from the registry and its
|
||||
// file handle closed. The archive file itself is left on disk
|
||||
// — it is long-term storage the operator owns.
|
||||
func (e *Engine) EvictWebhook(webhookID string) {
|
||||
if e.dbTarget == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.dbTarget.evictWebhook(webhookID)
|
||||
}
|
||||
|
||||
// EvictTarget implements Archives. It is EvictWebhook for a single
|
||||
// database target, and leaves the archive file on disk the same
|
||||
// way.
|
||||
func (e *Engine) EvictTarget(targetID string) {
|
||||
if e.dbTarget == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.dbTarget.evict(targetID)
|
||||
}
|
||||
|
||||
// Rename implements Archives. It renames a database target's
|
||||
// archive file to ArchiveFileName(webhookName, targetName,
|
||||
// targetID), under the lock the target's archive writes and the
|
||||
// idle sweep take. It never replaces a file: if one already has the
|
||||
// new name, the error is ErrArchiveNameTaken. The caller renames
|
||||
// before it saves the new name: see databaseTarget.rename.
|
||||
func (e *Engine) Rename(
|
||||
targetID, webhookName, targetName string,
|
||||
) error {
|
||||
if e.dbTarget == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.dbTarget.rename(targetID, webhookName, targetName)
|
||||
e.dbTarget.evict(webhookID)
|
||||
}
|
||||
|
||||
// ScheduleRetry schedules a task to be re-enqueued onto the
|
||||
@@ -338,13 +300,6 @@ func (e *Engine) ScheduleRetry(
|
||||
})
|
||||
}
|
||||
|
||||
// userAgent is the User-Agent header of every http and slack
|
||||
// delivery request: the program name and the running build's
|
||||
// version.
|
||||
func (e *Engine) userAgent() string {
|
||||
return "webhooker/" + e.version
|
||||
}
|
||||
|
||||
// registerHooks wires the engine's start and stop into the fx
|
||||
// lifecycle. The start hook's context is deliberately ignored
|
||||
// (see start for why the worker pool must not inherit it); the
|
||||
@@ -411,8 +366,7 @@ func (e *Engine) start() {
|
||||
// Once the pool has drained it closes the archive writers, so a
|
||||
// clean stop leaves no archive -wal behind. Nothing else holds a
|
||||
// writer for long by then: the archive sweeper stops before the
|
||||
// engine, and deleting or renaming a webhook or target only closes
|
||||
// or moves one. If the pool did
|
||||
// engine, and deleting a webhook only closes one. If the pool did
|
||||
// not drain in time, the writers are left open, as a kill would
|
||||
// leave them. Closing them would wait for any write in progress,
|
||||
// and a worker still running would then open new writers that
|
||||
|
||||
@@ -2,6 +2,7 @@ package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
@@ -272,35 +272,22 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||
}
|
||||
|
||||
// deliverToArchive gives the setup's webhook a database target,
|
||||
// runs one delivery to it through the running engine, and returns
|
||||
// the target's ID and archive file path. The archive writer holds
|
||||
// the file open afterwards.
|
||||
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
||||
// deliverToArchive runs one delivery to a database target through
|
||||
// the running engine and returns the webhook's archive file path.
|
||||
// The archive writer holds the file open afterwards.
|
||||
func deliverToArchive(t *testing.T, s iSetup) string {
|
||||
t.Helper()
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||
|
||||
tgt := &database.Target{
|
||||
WebhookID: s.WebhookID,
|
||||
Name: "archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
}
|
||||
require.NoError(
|
||||
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
||||
)
|
||||
|
||||
deliveryID, task := seedLogTask(t, s)
|
||||
task.TargetID = tgt.ID
|
||||
task.TargetType = database.TargetTypeDatabase
|
||||
|
||||
s.Engine.Notify([]delivery.Task{task})
|
||||
|
||||
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||
|
||||
return tgt.ID, filepath.Join(
|
||||
return filepath.Join(
|
||||
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
||||
"archive-hook-archive-"+tgt.ID+".db",
|
||||
fmt.Sprintf("archive-%s.db", s.WebhookID),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,7 +304,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
|
||||
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
_, path := deliverToArchive(t, s)
|
||||
path := deliverToArchive(t, s)
|
||||
require.FileExists(
|
||||
t, path+"-wal",
|
||||
"an open archive should have a -wal for the stop to remove",
|
||||
@@ -351,7 +338,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
||||
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
targetID, _ := deliverToArchive(t, s)
|
||||
deliverToArchive(t, s)
|
||||
|
||||
release := make(chan struct{})
|
||||
|
||||
@@ -365,7 +352,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||
|
||||
require.True(
|
||||
t, s.Engine.ExportArchiveHandleOpen(targetID),
|
||||
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
|
||||
"a stop that timed out must not close archive writers",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -351,15 +351,23 @@ func TestDeliverDatabase_ImmediateSuccess(
|
||||
|
||||
db := testWebhookDB(t)
|
||||
|
||||
// The database target archives for real, so the engine needs
|
||||
// the target in the main database and a data directory.
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
// The database target archives for real now, so the engine
|
||||
// needs a webhook DB manager to locate the data directory.
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil,
|
||||
database.NewTestWebhookDBManager(t.TempDir()),
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
event := seedEvent(t, db, `{"db":"target"}`)
|
||||
d := seedDatabaseTargetDelivery(t, db, event, tgt)
|
||||
d := seedDatabaseTargetDelivery(t, db, event, "")
|
||||
|
||||
env.eng.ExportDeliverDatabase(db, d)
|
||||
e.ExportDeliverDatabase(db, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
@@ -1239,6 +1247,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
testContentType,
|
||||
receivedHeaders.Get("Content-Type"),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
"webhooker/1.0",
|
||||
receivedHeaders.Get("User-Agent"),
|
||||
)
|
||||
}
|
||||
|
||||
// The event's stored inbound headers carry the same Content-Type the
|
||||
@@ -1307,7 +1320,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||
ContentType: tc.event,
|
||||
},
|
||||
cfg,
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
@@ -1324,27 +1336,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
||||
|
||||
db := testWebhookDB(t)
|
||||
|
||||
// The database target archives for real, so the engine needs
|
||||
// the target in the main database and a data directory.
|
||||
env := setupArchiveTest(t)
|
||||
archive := env.seedDatabaseTarget(t, "")
|
||||
// The database target archives for real now, so the engine
|
||||
// needs a webhook DB manager to locate the data directory.
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil,
|
||||
database.NewTestWebhookDBManager(t.TempDir()),
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetType database.TargetType
|
||||
targetID string
|
||||
wantStatus database.DeliveryStatus
|
||||
}{
|
||||
{
|
||||
"database target",
|
||||
database.TargetTypeDatabase,
|
||||
archive.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
},
|
||||
{
|
||||
"log target",
|
||||
database.TargetTypeLog,
|
||||
uuid.New().String(),
|
||||
database.DeliveryStatusDelivered,
|
||||
},
|
||||
}
|
||||
@@ -1354,7 +1371,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
||||
t.Parallel()
|
||||
|
||||
runRoutingSubtest(
|
||||
t, db, env.eng, tt.targetType, tt.targetID,
|
||||
t, db, e, tt.targetType,
|
||||
tt.wantStatus,
|
||||
)
|
||||
})
|
||||
@@ -1366,7 +1383,6 @@ func runRoutingSubtest(
|
||||
db *gorm.DB,
|
||||
e *delivery.Engine,
|
||||
targetType database.TargetType,
|
||||
targetID string,
|
||||
wantStatus database.DeliveryStatus,
|
||||
) {
|
||||
t.Helper()
|
||||
@@ -1374,7 +1390,8 @@ func runRoutingSubtest(
|
||||
event := seedEvent(t, db, `{"routing":"test"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
t, db, event.ID,
|
||||
uuid.New().String(),
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
@@ -83,9 +82,8 @@ func ExportApplyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
userAgent string,
|
||||
) []string {
|
||||
return applyRequestHeaders(req, event, cfg, userAgent)
|
||||
return applyRequestHeaders(req, event, cfg)
|
||||
}
|
||||
|
||||
// ExportTruncate exposes truncate for testing.
|
||||
@@ -401,7 +399,7 @@ func NewTestEngine(
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
mtr: metrics.Default(),
|
||||
}
|
||||
e.initTargets(client)
|
||||
|
||||
@@ -416,7 +414,7 @@ func NewTestEngineSmallRetry(
|
||||
e := &Engine{
|
||||
log: log,
|
||||
retryCh: make(chan Task, 1),
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
mtr: metrics.Default(),
|
||||
}
|
||||
e.initTargets(nil)
|
||||
|
||||
@@ -439,7 +437,7 @@ func NewTestEngineWithDB(
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
mtr: metrics.Default(),
|
||||
}
|
||||
e.initTargets(client)
|
||||
|
||||
@@ -447,7 +445,8 @@ func NewTestEngineWithDB(
|
||||
}
|
||||
|
||||
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
||||
// assert on collectors registered on a registry it holds.
|
||||
// assert on collectors registered on a private registry instead of
|
||||
// the process-wide ones every other test is also moving.
|
||||
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
||||
e.mtr = mtr
|
||||
}
|
||||
@@ -474,7 +473,7 @@ func NewTestCircuitBreaker(
|
||||
type ExportArchivedEvent = archivedEvent
|
||||
|
||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||
// can exercise the archive file mechanics.
|
||||
// can exercise the per-webhook archive file mechanics.
|
||||
type ExportArchiveWriter struct {
|
||||
w *archiveWriter
|
||||
}
|
||||
@@ -512,12 +511,6 @@ func (e *ExportArchiveWriter) Reopen(
|
||||
return e.w.reopen(expiry)
|
||||
}
|
||||
|
||||
// SetNow replaces the clock the writer measures its reopen
|
||||
// debounce on.
|
||||
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
|
||||
e.w.now = now
|
||||
}
|
||||
|
||||
// Reopens reports how many times the file has been opened.
|
||||
func (e *ExportArchiveWriter) Reopens() int {
|
||||
return e.w.reopens
|
||||
@@ -555,12 +548,6 @@ func (e *ExportArchiveWriter) Evict() {
|
||||
e.w.evict()
|
||||
}
|
||||
|
||||
// Rename gives the archive file a new name in the same directory,
|
||||
// as a rename of the webhook or target does.
|
||||
func (e *ExportArchiveWriter) Rename(name string) error {
|
||||
return e.w.rename(name)
|
||||
}
|
||||
|
||||
// HandleOpen reports whether the writer currently holds an open
|
||||
// archive handle.
|
||||
func (e *ExportArchiveWriter) HandleOpen() bool {
|
||||
@@ -580,16 +567,16 @@ func (e *ExportArchiveWriter) Same(
|
||||
}
|
||||
|
||||
// ExportArchiveWriterFor returns the archive writer the registry
|
||||
// currently caches for a database target, or nil when none is
|
||||
// cached. It never creates one, so a test can hold a reference to
|
||||
// the very writer an eviction is about to detach.
|
||||
// currently caches for a webhook, or nil when none is cached. It
|
||||
// never creates one, so a test can hold a reference to the very
|
||||
// writer an eviction is about to detach.
|
||||
func (e *Engine) ExportArchiveWriterFor(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) *ExportArchiveWriter {
|
||||
e.dbTarget.mu.Lock()
|
||||
defer e.dbTarget.mu.Unlock()
|
||||
|
||||
w, ok := e.dbTarget.writers[targetID]
|
||||
w, ok := e.dbTarget.writers[webhookID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -598,26 +585,26 @@ func (e *Engine) ExportArchiveWriterFor(
|
||||
}
|
||||
|
||||
// ExportHasArchiveWriter reports whether the database target
|
||||
// type currently caches an archive writer for a target.
|
||||
// currently caches an archive writer for a webhook.
|
||||
func (e *Engine) ExportHasArchiveWriter(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) bool {
|
||||
e.dbTarget.mu.Lock()
|
||||
defer e.dbTarget.mu.Unlock()
|
||||
|
||||
_, ok := e.dbTarget.writers[targetID]
|
||||
_, ok := e.dbTarget.writers[webhookID]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// ExportArchiveHandleOpen reports whether the cached archive
|
||||
// writer for a target holds an open database handle. It
|
||||
// writer for a webhook holds an open database handle. It
|
||||
// returns false when no writer is cached.
|
||||
func (e *Engine) ExportArchiveHandleOpen(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) bool {
|
||||
e.dbTarget.mu.Lock()
|
||||
w, ok := e.dbTarget.writers[targetID]
|
||||
w, ok := e.dbTarget.writers[webhookID]
|
||||
e.dbTarget.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
@@ -631,12 +618,12 @@ func (e *Engine) ExportArchiveHandleOpen(
|
||||
}
|
||||
|
||||
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
||||
// archive file path of the cached writer for a target, so a
|
||||
// archive file path of the cached writer for a webhook, so a
|
||||
// test can prime the registry the way a delivery would.
|
||||
func (e *Engine) ExportEnsureArchiveWriter(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) (string, error) {
|
||||
w, err := e.dbTarget.writerFor(targetID)
|
||||
w, err := e.dbTarget.writerFor(webhookID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -644,14 +631,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
|
||||
return w.path, nil
|
||||
}
|
||||
|
||||
// ExportSweepWriterFor takes a target's registry writer exactly
|
||||
// ExportSweepWriterFor takes a webhook's registry writer exactly
|
||||
// as the idle sweep does, reporting whether the sweep had to
|
||||
// create the entry. It lets a test drive the registry through the
|
||||
// sweep's own entry point instead of choreographing goroutines.
|
||||
func (e *Engine) ExportSweepWriterFor(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) (*ExportArchiveWriter, bool, error) {
|
||||
w, created, err := e.dbTarget.sweepWriterFor(targetID)
|
||||
w, created, err := e.dbTarget.sweepWriterFor(webhookID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -662,9 +649,9 @@ func (e *Engine) ExportSweepWriterFor(
|
||||
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
||||
// exactly as a finished sweep does.
|
||||
func (e *Engine) ExportReleaseSweepWriter(
|
||||
targetID string, w *ExportArchiveWriter,
|
||||
webhookID string, w *ExportArchiveWriter,
|
||||
) {
|
||||
e.dbTarget.releaseSweepWriter(targetID, w.w)
|
||||
e.dbTarget.releaseSweepWriter(webhookID, w.w)
|
||||
}
|
||||
|
||||
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
||||
|
||||
@@ -35,8 +35,9 @@ const (
|
||||
)
|
||||
|
||||
// mIsolate gives the setup's engine a metric set registered on a
|
||||
// registry this test holds, so its exact assertions can gather from
|
||||
// it.
|
||||
// private registry. The process-wide collectors are moved by every
|
||||
// other delivery test running in parallel, so exact assertions are
|
||||
// only possible against a registry this test owns.
|
||||
func mIsolate(
|
||||
t *testing.T, s iSetup,
|
||||
) *prometheus.Registry {
|
||||
|
||||
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
||||
"Content-Type": testContentType,
|
||||
},
|
||||
},
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
|
||||
+13
-57
@@ -37,8 +37,8 @@ var (
|
||||
"blocked cloud metadata address",
|
||||
)
|
||||
errBlockedMetadata = errors.New(
|
||||
"blocked link-local, cloud instance metadata or " +
|
||||
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||
"blocked link-local or cloud instance metadata " +
|
||||
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||
)
|
||||
errInvalidScheme = errors.New(
|
||||
"only http and https are allowed",
|
||||
@@ -72,17 +72,14 @@ var blockedNetworks []*net.IPNet
|
||||
var blockedPublicNetworks []*net.IPNet
|
||||
|
||||
// alwaysBlockedNetworks are the ranges no configuration can
|
||||
// open, so a supplied CIDR that covers one still leaves it
|
||||
// blocked. An entry is here for one of two reasons: it is a
|
||||
// metadata endpoint (the link-local blocks and the cloud
|
||||
// instance metadata endpoints that live outside them), or it is
|
||||
// an unspecified address. Reaching a metadata endpoint is
|
||||
// credential or user-data theft rather than delivery to an
|
||||
// internal service.
|
||||
// open: the link-local blocks and the cloud instance metadata
|
||||
// endpoints that live outside them. Reaching one is credential
|
||||
// or user-data theft rather than delivery to an internal
|
||||
// service, so a supplied CIDR that covers such an address still
|
||||
// leaves it blocked.
|
||||
//
|
||||
// Inclusion criterion for metadata endpoints — one belongs here
|
||||
// only if BOTH hold, and every metadata entry below satisfies
|
||||
// both:
|
||||
// Inclusion criterion — an address belongs here only if BOTH
|
||||
// hold, and every entry below satisfies both:
|
||||
//
|
||||
// 1. It is a fixed address assigned by the provider, or a
|
||||
// range reserved by IANA — never one the operator chose.
|
||||
@@ -93,8 +90,8 @@ var blockedPublicNetworks []*net.IPNet
|
||||
// not cheaply rotated.
|
||||
//
|
||||
// Both halves are load-bearing, so use them to refuse a
|
||||
// metadata candidate and say why. An endpoint disclosing only
|
||||
// the operator's own inventory (instance id, region, disks, NICs)
|
||||
// candidate and say why. An endpoint disclosing only the
|
||||
// operator's own inventory (instance id, region, disks, NICs)
|
||||
// fails (2): letting a delivery target reach the operator's own
|
||||
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
||||
// provide. But (2) is not "IAM credentials only" either —
|
||||
@@ -115,15 +112,6 @@ var blockedPublicNetworks []*net.IPNet
|
||||
// This is a criterion, not an enumeration of every metadata
|
||||
// address in existence.
|
||||
//
|
||||
// The unspecified addresses 0.0.0.0 and :: are here for a
|
||||
// separate reason: they disclose nothing, but no host can have
|
||||
// either, and on Linux a connection to one reaches this host's
|
||||
// own loopback. Listing them means an allowlist reaches loopback
|
||||
// only through an entry that covers a loopback address
|
||||
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
|
||||
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
|
||||
// else lives at either address, so refusing them costs nothing.
|
||||
//
|
||||
// Every entry is either already in blockedNetworks — this list is
|
||||
// what makes it unconditional — or an alternate encoding of
|
||||
// 169.254.169.254 that Contains does not match against
|
||||
@@ -143,46 +131,23 @@ var alwaysBlockedNetworks []*net.IPNet
|
||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||
func init() {
|
||||
blockedNetworks = mustParseCIDRs([]string{
|
||||
// IPv4 loopback.
|
||||
"127.0.0.0/8",
|
||||
// RFC 1918 private network.
|
||||
"10.0.0.0/8",
|
||||
// RFC 1918 private network.
|
||||
"172.16.0.0/12",
|
||||
// RFC 1918 private network.
|
||||
"192.168.0.0/16",
|
||||
// IPv4 link-local.
|
||||
"169.254.0.0/16",
|
||||
// "This network", holding the IPv4 unspecified address 0.0.0.0.
|
||||
"0.0.0.0/8",
|
||||
// Carrier-grade NAT shared address space.
|
||||
"100.64.0.0/10",
|
||||
// IETF protocol assignments.
|
||||
"192.0.0.0/24",
|
||||
// IPv4 documentation (TEST-NET-1).
|
||||
"192.0.2.0/24",
|
||||
// Benchmarking.
|
||||
"198.18.0.0/15",
|
||||
// IPv4 documentation (TEST-NET-2).
|
||||
"198.51.100.0/24",
|
||||
// IPv4 documentation (TEST-NET-3).
|
||||
"203.0.113.0/24",
|
||||
// IPv4 multicast.
|
||||
"224.0.0.0/4",
|
||||
// Reserved, including the broadcast address.
|
||||
"240.0.0.0/4",
|
||||
// IPv6 loopback.
|
||||
"::1/128",
|
||||
// IPv6 unspecified address.
|
||||
"::/128",
|
||||
// IPv6 unique local addresses.
|
||||
"fc00::/7",
|
||||
// IPv6 link-local.
|
||||
"fe80::/10",
|
||||
// IPv6 multicast.
|
||||
"ff00::/8",
|
||||
// IPv6 documentation.
|
||||
"2001:db8::/32",
|
||||
})
|
||||
|
||||
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||
@@ -242,14 +207,6 @@ func init() {
|
||||
// allowlist from opening it.
|
||||
"192.0.0.192/32",
|
||||
|
||||
// The unspecified addresses, each of which reaches this
|
||||
// host's loopback on Linux.
|
||||
//
|
||||
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
|
||||
"0.0.0.0/32",
|
||||
// IPv6 unspecified address.
|
||||
"::/128",
|
||||
|
||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||
"::a9fe:a9fe/128",
|
||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||
@@ -386,9 +343,8 @@ func (g *Guard) allows(ip net.IP) bool {
|
||||
// The order is the policy:
|
||||
//
|
||||
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
||||
// consulted, so no configured CIDR reaches link-local, a
|
||||
// cloud metadata endpoint at a non-public address, or an
|
||||
// unspecified address.
|
||||
// consulted, so no configured CIDR reaches link-local or a
|
||||
// cloud metadata endpoint at a non-public address.
|
||||
// 2. The allowlist is consulted next, so a listed private
|
||||
// network, or a listed public address on the default
|
||||
// blocklist, becomes reachable.
|
||||
|
||||
@@ -168,13 +168,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
||||
|
||||
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
||||
// case: cloud instance metadata endpoints are credential theft
|
||||
// rather than delivery to an internal service, and the
|
||||
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
||||
// on Linux, so no allowlist reaches any of them. Every guard
|
||||
// below names a CIDR that covers its target — including
|
||||
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
||||
// operator would really list — and the address must stay
|
||||
// refused anyway, on both the validation and the delivery path.
|
||||
// rather than delivery to an internal service, so no allowlist
|
||||
// reaches one. Every guard below names a CIDR that covers its
|
||||
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
||||
// CGNAT blocks an operator would really list — and the address
|
||||
// must stay refused anyway, on both the validation and the
|
||||
// delivery path.
|
||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -220,17 +219,15 @@ type metadataAlwaysRefusedCase struct {
|
||||
}
|
||||
|
||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||
// blocked address (link-local, the cloud metadata endpoints and
|
||||
// the unspecified addresses) together with an allowlist entry
|
||||
// that would otherwise reach it. Split by family of address only
|
||||
// to stay under the function-length limit.
|
||||
// blocked address together with an allowlist entry that would
|
||||
// otherwise reach it. Split by family of address only to stay
|
||||
// under the function-length limit.
|
||||
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||
cases := linkLocalRefusedCases()
|
||||
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||
cases = append(cases, encodedMetadataRefusedCases()...)
|
||||
|
||||
return append(cases, unspecifiedRefusedCases()...)
|
||||
return append(cases, encodedMetadataRefusedCases()...)
|
||||
}
|
||||
|
||||
// linkLocalRefusedCases covers the link-local blocks, including
|
||||
@@ -370,23 +367,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
||||
}
|
||||
}
|
||||
|
||||
// unspecifiedRefusedCases covers the unspecified addresses, each
|
||||
// of which reaches this host's loopback on Linux.
|
||||
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
|
||||
return []metadataAlwaysRefusedCase{
|
||||
{
|
||||
name: "IPv4 unspecified address under 0.0.0.0/0",
|
||||
allow: allowAllIPv4,
|
||||
target: "http://0.0.0.0:8080/hook",
|
||||
},
|
||||
{
|
||||
name: "IPv6 unspecified address under ::/0",
|
||||
allow: allowAllIPv6,
|
||||
target: "http://[::]:8080/hook",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
||||
// not narrow anything: public addresses were reachable before it
|
||||
// existed and stay reachable, whether or not a list is set.
|
||||
@@ -544,10 +524,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
||||
// Oracle Cloud Classic metadata, inside the blocked
|
||||
// 192.0.0.0/24.
|
||||
"192.0.0.192/32",
|
||||
// The IPv4 and IPv6 unspecified addresses, each of
|
||||
// which reaches this host's loopback on Linux.
|
||||
"0.0.0.0/32",
|
||||
"::/128",
|
||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||
"::a9fe:a9fe/128",
|
||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||
@@ -580,8 +556,7 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
||||
{cidr: "172.16.0.0/12", reopenable: true},
|
||||
{cidr: "192.168.0.0/16", reopenable: true},
|
||||
{cidr: linkLocalIPv4, reopenable: false},
|
||||
// Its first address, 0.0.0.0, is in the unconditional set.
|
||||
{cidr: "0.0.0.0/8", reopenable: false},
|
||||
{cidr: "0.0.0.0/8", reopenable: true},
|
||||
{cidr: "100.64.0.0/10", reopenable: true},
|
||||
{cidr: "192.0.0.0/24", reopenable: true},
|
||||
{cidr: "192.0.2.0/24", reopenable: true},
|
||||
@@ -591,11 +566,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
||||
{cidr: "224.0.0.0/4", reopenable: true},
|
||||
{cidr: "240.0.0.0/4", reopenable: true},
|
||||
{cidr: "::1/128", reopenable: true},
|
||||
{cidr: "::/128", reopenable: false},
|
||||
{cidr: "fc00::/7", reopenable: true},
|
||||
{cidr: "fe80::/10", reopenable: false},
|
||||
{cidr: "ff00::/8", reopenable: true},
|
||||
{cidr: "2001:db8::/32", reopenable: true},
|
||||
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -101,42 +101,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
|
||||
// covers the unspecified addresses and the IPv6 multicast and
|
||||
// documentation ranges: with no allowlist set, each is refused
|
||||
// both when a target is created and when a delivery dials it.
|
||||
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
guard := delivery.NewTestGuard()
|
||||
|
||||
targets := []string{
|
||||
// The unspecified addresses. On Linux a connection to
|
||||
// either reaches this host's loopback.
|
||||
"http://0.0.0.0:8080/hook",
|
||||
"http://[::]:8080/hook",
|
||||
// IPv6 multicast, all nodes.
|
||||
"http://[ff02::1]/hook",
|
||||
// IPv6 documentation.
|
||||
"http://[2001:db8::1]/hook",
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Error(t,
|
||||
guard.ValidateTargetURL(context.Background(), target),
|
||||
"%s must be refused at target creation", target,
|
||||
)
|
||||
|
||||
assertDialRefused(t, guard, target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,75 +11,22 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// archiveNameMaxLen is how many characters of a webhook or target
|
||||
// name an archive file name keeps.
|
||||
const archiveNameMaxLen = 40
|
||||
|
||||
// databaseTarget is a no-retry target that archives the full
|
||||
// inbound event into the target's own archive SQLite file, separate
|
||||
// from the per-webhook event database. The event is already
|
||||
// persisted in the per-webhook event DB by the time delivery runs;
|
||||
// the database target additionally writes a durable long-term copy
|
||||
// into the file ArchiveFileName names and then records a single
|
||||
// attempt whose outcome reflects whether the archive write
|
||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||
// and expiry semantics.
|
||||
// databaseTarget is a no-retry target that archives the
|
||||
// full inbound event into a per-webhook archive SQLite file,
|
||||
// separate from the per-webhook event database. The event is
|
||||
// already persisted in the per-webhook event DB by the time
|
||||
// delivery runs; the database target additionally writes a
|
||||
// durable long-term copy into archive-{webhookID}.db and then
|
||||
// records a single attempt whose outcome reflects whether the
|
||||
// archive write succeeded. See archiveWriter for the
|
||||
// close/reopen, auto-recreate, and expiry semantics.
|
||||
type databaseTarget struct {
|
||||
eng *Engine
|
||||
|
||||
// writers holds one archive writer per database target, keyed
|
||||
// by target ID.
|
||||
mu sync.Mutex
|
||||
writers map[string]*archiveWriter
|
||||
}
|
||||
|
||||
// ArchiveFileName returns the file name of a database target's
|
||||
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
|
||||
// names passed through archiveNamePart. The target ID keeps the
|
||||
// name unique when two targets' names come out the same.
|
||||
func ArchiveFileName(webhookName, targetName, targetID string) string {
|
||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||
archiveNamePart(targetName) + "-" + targetID + ".db"
|
||||
}
|
||||
|
||||
// archiveNamePart makes a webhook or target name safe to put in a
|
||||
// file name. It is lowercased; ASCII letters and digits are kept,
|
||||
// every other run of characters becomes a single "-", and no "-" is
|
||||
// left at either end. It is cut to archiveNameMaxLen characters, and
|
||||
// a name with nothing left is "unnamed".
|
||||
func archiveNamePart(name string) string {
|
||||
var b strings.Builder
|
||||
|
||||
dash := false
|
||||
|
||||
for _, r := range strings.ToLower(name) {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||
dash = b.Len() > 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if dash {
|
||||
b.WriteByte('-')
|
||||
|
||||
dash = false
|
||||
}
|
||||
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
part := b.String()
|
||||
if len(part) > archiveNameMaxLen {
|
||||
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
|
||||
}
|
||||
|
||||
if part == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
|
||||
return part
|
||||
}
|
||||
|
||||
// Deliver implements Target. It archives the event, then
|
||||
// records one successful attempt and marks the delivery
|
||||
// delivered. An archiving error fails the delivery: the
|
||||
@@ -146,7 +92,7 @@ func (t *databaseTarget) Deliver(
|
||||
)
|
||||
}
|
||||
|
||||
// archive writes the full event as a row into the target's
|
||||
// archive writes the full event as a row into the webhook's
|
||||
// archive database, honouring the optional per-target expiry
|
||||
// parsed from the target config JSON.
|
||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
@@ -160,7 +106,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
return err
|
||||
}
|
||||
|
||||
w, err := t.writerFor(d.TargetID)
|
||||
w, err := t.writerFor(webhookID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -178,31 +124,30 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
return w.write(row, expiry)
|
||||
}
|
||||
|
||||
// writerFor returns the archive writer for a database target,
|
||||
// creating and caching it on first use. Each target has one writer
|
||||
// so its close/reopen debounce state is shared across concurrent
|
||||
// deliveries, and so a rename and the idle sweep take the same lock
|
||||
// as its writes.
|
||||
// writerFor returns the archiveWriter for a webhook, creating
|
||||
// and caching it on first use. Each webhook has one writer so
|
||||
// its close/reopen debounce state is shared across concurrent
|
||||
// deliveries. The archive file lives beside the per-webhook
|
||||
// event database in the data directory.
|
||||
func (t *databaseTarget) writerFor(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) (*archiveWriter, error) {
|
||||
path, err := t.archivePath(webhookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
if t.writers == nil {
|
||||
t.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
w, ok := t.writers[webhookID]
|
||||
if !ok {
|
||||
var err error
|
||||
|
||||
w, err = t.newWriter(targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if t.writers == nil {
|
||||
t.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
t.writers[targetID] = w
|
||||
w = newArchiveWriter(path, t.eng.log)
|
||||
t.writers[webhookID] = w
|
||||
}
|
||||
|
||||
// A delivery claims the entry: even if the idle sweep created
|
||||
@@ -214,39 +159,40 @@ func (t *databaseTarget) writerFor(
|
||||
}
|
||||
|
||||
// sweepWriterFor returns the archive writer the idle sweep should
|
||||
// prune a target's archive through, together with whether the sweep
|
||||
// itself created the registry entry.
|
||||
// prune a webhook through, together with whether the sweep itself
|
||||
// created the registry entry.
|
||||
//
|
||||
// The sweep must route its prune through the registered writer so
|
||||
// the writer's mutex orders it against concurrent writes, but it
|
||||
// must never leave a registry entry behind: a sweep that ran
|
||||
// concurrently with the target's deletion would otherwise
|
||||
// concurrently with the webhook's deletion would otherwise
|
||||
// re-create an entry that nothing will ever evict again, which is
|
||||
// exactly the leak eviction exists to prevent. An entry the sweep
|
||||
// creates is therefore marked sweep-owned and handed back to
|
||||
// releaseSweepWriter when the sweep is done.
|
||||
func (t *databaseTarget) sweepWriterFor(
|
||||
targetID string,
|
||||
webhookID string,
|
||||
) (*archiveWriter, bool, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
if ok {
|
||||
return w, false, nil
|
||||
}
|
||||
|
||||
w, err := t.newWriter(targetID)
|
||||
path, err := t.archivePath(webhookID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.writers == nil {
|
||||
t.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
w, ok := t.writers[webhookID]
|
||||
if ok {
|
||||
return w, false, nil
|
||||
}
|
||||
|
||||
w = newArchiveWriter(path, t.eng.log)
|
||||
w.sweepOwned = true
|
||||
t.writers[targetID] = w
|
||||
t.writers[webhookID] = w
|
||||
|
||||
return w, true, nil
|
||||
}
|
||||
@@ -263,95 +209,57 @@ func (t *databaseTarget) sweepWriterFor(
|
||||
// delivery that adopted the writer keeps a registered, evictable
|
||||
// one.
|
||||
func (t *databaseTarget) releaseSweepWriter(
|
||||
targetID string, w *archiveWriter,
|
||||
webhookID string, w *archiveWriter,
|
||||
) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
cur, ok := t.writers[targetID]
|
||||
cur, ok := t.writers[webhookID]
|
||||
if !ok || cur != w || !cur.sweepOwned {
|
||||
return
|
||||
}
|
||||
|
||||
delete(t.writers, targetID)
|
||||
delete(t.writers, webhookID)
|
||||
}
|
||||
|
||||
// newWriter builds the writer for a database target's archive. The
|
||||
// file lives beside the webhook's event database in the data
|
||||
// directory and is named for the webhook and the target as the main
|
||||
// database has them now; from then on only rename changes the name
|
||||
// the writer uses. It does not touch the archive file.
|
||||
func (t *databaseTarget) newWriter(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
// archivePath returns the archive file path for a webhook: it
|
||||
// lives beside the per-webhook event database in the data
|
||||
// directory. It does not touch the filesystem.
|
||||
func (t *databaseTarget) archivePath(
|
||||
webhookID string,
|
||||
) (string, error) {
|
||||
if t.eng.dbManager == nil {
|
||||
return nil, errArchiveNoDataDir
|
||||
return "", errArchiveNoDataDir
|
||||
}
|
||||
|
||||
var target database.Target
|
||||
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
|
||||
|
||||
err := t.eng.database.DB().
|
||||
Preload("Webhook").
|
||||
First(&target, "id = ?", targetID).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"loading database target %s: %w", targetID, err,
|
||||
)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
||||
name := ArchiveFileName(
|
||||
target.Webhook.Name, target.Name, target.ID,
|
||||
)
|
||||
|
||||
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
||||
w.webhookID = target.WebhookID
|
||||
|
||||
return w, nil
|
||||
return filepath.Join(
|
||||
dir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||
), nil
|
||||
}
|
||||
|
||||
// rename moves a database target's archive file to the name for
|
||||
// webhookName and targetName. It goes through the target's writer,
|
||||
// so the move holds the lock that writes and the idle sweep take,
|
||||
// and later writes use the new name.
|
||||
//
|
||||
// The writer is created if there is none, and it stays cached. The
|
||||
// handlers rename before they save the new name, so until the save
|
||||
// the main database still has the old one; a delivery in that window
|
||||
// must find this writer rather than build one from the old name.
|
||||
func (t *databaseTarget) rename(
|
||||
targetID, webhookName, targetName string,
|
||||
) error {
|
||||
w, err := t.writerFor(targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
|
||||
}
|
||||
|
||||
// evict drops a database target's archive writer from the registry
|
||||
// and closes its handle, so a deleted target does not leave a
|
||||
// writer (and an open archive handle within its debounce window)
|
||||
// alive for the process lifetime.
|
||||
// evict drops a webhook's archive writer from the registry and
|
||||
// closes its handle, so a deleted webhook does not leave a
|
||||
// writer (and an open archive handle within its debounce
|
||||
// window) alive for the process lifetime.
|
||||
//
|
||||
// The map entry is removed under the registry lock, which is
|
||||
// then released before the handle is closed under the writer's
|
||||
// own lock: that ordering keeps the registry available to other
|
||||
// targets while an in-flight write on this one drains, and
|
||||
// webhooks while an in-flight write on this one drains, and
|
||||
// closing under the writer's lock means eviction can never race
|
||||
// a write.
|
||||
//
|
||||
// Eviction is idempotent and silent for a target with no writer,
|
||||
// which is the common case: only a database target that has
|
||||
// received an event or been renamed has one. It never deletes the
|
||||
// archive file.
|
||||
func (t *databaseTarget) evict(targetID string) {
|
||||
// Eviction is idempotent and silent for a webhook with no
|
||||
// writer, which is the common case: a webhook with no database
|
||||
// target never creates one. It never deletes the archive file.
|
||||
func (t *databaseTarget) evict(webhookID string) {
|
||||
t.mu.Lock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
w, ok := t.writers[webhookID]
|
||||
if ok {
|
||||
delete(t.writers, targetID)
|
||||
delete(t.writers, webhookID)
|
||||
}
|
||||
|
||||
t.mu.Unlock()
|
||||
@@ -364,41 +272,13 @@ func (t *databaseTarget) evict(targetID string) {
|
||||
|
||||
t.eng.log.Info(
|
||||
"evicted archive writer",
|
||||
"target_id", targetID,
|
||||
"webhook_id", webhookID,
|
||||
"path", w.path,
|
||||
)
|
||||
}
|
||||
|
||||
// evictWebhook evicts, exactly as evict does, the writer of every
|
||||
// database target of a webhook.
|
||||
func (t *databaseTarget) evictWebhook(webhookID string) {
|
||||
t.mu.Lock()
|
||||
|
||||
var gone []*archiveWriter
|
||||
|
||||
for targetID, w := range t.writers {
|
||||
if w.webhookID == webhookID {
|
||||
delete(t.writers, targetID)
|
||||
|
||||
gone = append(gone, w)
|
||||
}
|
||||
}
|
||||
|
||||
t.mu.Unlock()
|
||||
|
||||
for _, w := range gone {
|
||||
w.evict()
|
||||
|
||||
t.eng.log.Info(
|
||||
"evicted archive writer",
|
||||
"webhook_id", webhookID,
|
||||
"path", w.path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// evictAll evicts every cached archive writer, exactly as evict
|
||||
// does for one target. The engine calls it at shutdown, once its
|
||||
// does for one webhook. The engine calls it at shutdown, once its
|
||||
// workers have returned. Closing the last handle on an archive
|
||||
// moves the contents of its -wal into the .db and removes the
|
||||
// -wal, so a clean stop leaves each archive as a single file.
|
||||
@@ -415,25 +295,38 @@ func (t *databaseTarget) evictAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// sweepArchive prunes one database target's archive of rows older
|
||||
// than expiry, without requiring a write. A missing archive file is
|
||||
// left missing (see sweepExpired), so a sweep never creates an
|
||||
// archive for a target that has never received an event.
|
||||
// sweepWebhook prunes one webhook's archive of rows older than
|
||||
// expiry, without requiring a write. It returns nil (nothing to
|
||||
// do) when the archive file does not exist, so a sweep never
|
||||
// creates an archive for a webhook that has a database target
|
||||
// but has never received an event.
|
||||
//
|
||||
// It also never leaves a registry entry behind: an entry it had
|
||||
// to create to reach the writer's mutex is released again once
|
||||
// the prune is done, so a sweep racing a target deletion cannot
|
||||
// the prune is done, so a sweep racing a webhook deletion cannot
|
||||
// resurrect the writer the eviction just dropped.
|
||||
func (t *databaseTarget) sweepArchive(
|
||||
targetID string, expiry time.Duration,
|
||||
func (t *databaseTarget) sweepWebhook(
|
||||
webhookID string, expiry time.Duration,
|
||||
) error {
|
||||
w, created, err := t.sweepWriterFor(targetID)
|
||||
path, err := t.archivePath(webhookID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check before taking a writer at all: a webhook whose
|
||||
// archive has never been created gets no writer, no handle,
|
||||
// and no file.
|
||||
if !fileExists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
w, created, err := t.sweepWriterFor(webhookID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if created {
|
||||
defer t.releaseSweepWriter(targetID, w)
|
||||
defer t.releaseSweepWriter(webhookID, w)
|
||||
}
|
||||
|
||||
return w.sweepExpired(expiry)
|
||||
|
||||
@@ -4,10 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -43,7 +41,7 @@ const (
|
||||
|
||||
var (
|
||||
// errArchiveMissingWebhookID is returned when an event to
|
||||
// archive has no webhook id to record in its archive row.
|
||||
// archive has no webhook id to key its archive file on.
|
||||
errArchiveMissingWebhookID = errors.New(
|
||||
"cannot archive event without a webhook id",
|
||||
)
|
||||
@@ -63,19 +61,13 @@ var (
|
||||
)
|
||||
|
||||
// errArchiveWriterEvicted is returned when a writer that has
|
||||
// been evicted (its target or its webhook was deleted) is used
|
||||
// again. An evicted writer is no longer in the registry, so
|
||||
// reopening its file would leak a handle nothing owns.
|
||||
// been evicted (its webhook was deleted, or its last database
|
||||
// target was removed) is used again. An evicted writer is no
|
||||
// longer in the registry, so reopening its file would leak a
|
||||
// handle nothing owns.
|
||||
errArchiveWriterEvicted = errors.New(
|
||||
"archive writer has been evicted",
|
||||
)
|
||||
|
||||
// ErrArchiveNameTaken is returned when an archive cannot be
|
||||
// renamed because a file already has the new name. That file may
|
||||
// be an archive with rows of its own, so it is never replaced.
|
||||
ErrArchiveNameTaken = errors.New(
|
||||
"a file already has the archive's new name",
|
||||
)
|
||||
)
|
||||
|
||||
// databaseTargetConfig is the optional per-target JSON config
|
||||
@@ -88,7 +80,7 @@ type databaseTargetConfig struct {
|
||||
}
|
||||
|
||||
// archivedEvent is one fully captured webhook event stored in a
|
||||
// database target's archive for long-term retention. It is a
|
||||
// per-webhook archive database for long-term retention. It is a
|
||||
// self-contained copy — independent of the per-webhook event
|
||||
// database, which may prune events under its own retention.
|
||||
type archivedEvent struct {
|
||||
@@ -178,8 +170,8 @@ func ValidateArchiveExpiry(expiry string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// archiveWriter owns one database target's archive SQLite file.
|
||||
// It serialises writes, and after each write closes and reopens
|
||||
// archiveWriter owns one per-webhook archive SQLite file. It
|
||||
// serialises writes, and after each write closes and reopens
|
||||
// the file (debounced to at most once per debounce window) so
|
||||
// an operator can move the file away for offline archiving. The
|
||||
// next write recreates a moved or removed file, because the
|
||||
@@ -194,26 +186,17 @@ type archiveWriter struct {
|
||||
lastReopen time.Time
|
||||
reopens int
|
||||
|
||||
// now is the clock the reopen debounce is measured on. It is
|
||||
// time.Now outside tests.
|
||||
now func() time.Time
|
||||
|
||||
// evicted marks a writer that has been removed from the
|
||||
// registry. Its handle is closed and it must never open the
|
||||
// file again: nothing holds it any more, so a reopen would
|
||||
// leak the handle for the process lifetime.
|
||||
// per-webhook registry. Its handle is closed and it must
|
||||
// never open the file again: nothing holds it any more, so a
|
||||
// reopen would leak the handle for the process lifetime.
|
||||
evicted bool
|
||||
|
||||
// webhookID is the webhook the archive's target belongs to,
|
||||
// so deleting the webhook can find its writers. It is set
|
||||
// when the writer is created and never changes.
|
||||
webhookID string
|
||||
|
||||
// sweepOwned marks a registry entry that the idle sweep
|
||||
// created because no writer was cached for the target. The
|
||||
// created because no writer was cached for the webhook. The
|
||||
// sweep removes such an entry again when it is done, so a
|
||||
// sweep can never leave — or resurrect — a registry entry
|
||||
// for a target that has been deleted. A delivery that adopts
|
||||
// for a webhook that has been deleted. A delivery that adopts
|
||||
// the writer clears the flag, handing the entry to the
|
||||
// registry proper.
|
||||
//
|
||||
@@ -232,7 +215,6 @@ func newArchiveWriter(
|
||||
path: path,
|
||||
log: log,
|
||||
debounce: archiveReopenDebounce,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +250,7 @@ func (w *archiveWriter) write(
|
||||
)
|
||||
}
|
||||
|
||||
if w.now().Sub(w.lastReopen) >= w.debounce {
|
||||
if time.Since(w.lastReopen) >= w.debounce {
|
||||
return w.reopen(expiry)
|
||||
}
|
||||
|
||||
@@ -328,7 +310,7 @@ func (w *archiveWriter) openMode(
|
||||
}
|
||||
|
||||
w.db = gdb
|
||||
w.lastReopen = w.now()
|
||||
w.lastReopen = time.Now()
|
||||
w.reopens++
|
||||
|
||||
if expiry > 0 {
|
||||
@@ -403,78 +385,11 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rename gives the archive file a new name in the same directory,
|
||||
// and the writer uses the file under that name from now on. The
|
||||
// handle is closed first, which folds the -wal into the .db; any
|
||||
// -wal or -shm still beside the file (left by a crash) is moved with
|
||||
// it, because SQLite finds them by name. A missing file is not an
|
||||
// error: the operator may have moved it away, and the next write
|
||||
// creates it under the new name.
|
||||
//
|
||||
// If a file already has the new name, nothing is moved and the
|
||||
// error is ErrArchiveNameTaken. If one file fails to move, those
|
||||
// already moved are moved back before the error is returned, so the
|
||||
// archive is never split across two names.
|
||||
func (w *archiveWriter) rename(name string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.evicted {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errArchiveWriterEvicted, w.path,
|
||||
)
|
||||
}
|
||||
|
||||
path := filepath.Join(filepath.Dir(w.path), name)
|
||||
if path == w.path {
|
||||
return nil
|
||||
}
|
||||
|
||||
suffixes := []string{"", "-wal", "-shm"}
|
||||
|
||||
for _, suffix := range suffixes {
|
||||
if fileExists(path + suffix) {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
w.close()
|
||||
|
||||
for i, suffix := range suffixes {
|
||||
err := os.Rename(w.path+suffix, path+suffix)
|
||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, moved := range suffixes[:i] {
|
||||
backErr := os.Rename(path+moved, w.path+moved)
|
||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||
w.log.Error(
|
||||
"failed to move archive file back",
|
||||
"from", path+moved,
|
||||
"to", w.path+moved,
|
||||
"error", backErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
||||
)
|
||||
}
|
||||
|
||||
w.path = path
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// evict closes the writer's handle and marks it unusable. It is
|
||||
// called when the writer leaves the registry, because its target
|
||||
// or its webhook was deleted, or at shutdown. The archive FILE is
|
||||
// deliberately left on disk: it is long-term storage an operator
|
||||
// may still want.
|
||||
// called when the writer leaves the registry, either because the
|
||||
// webhook was deleted or because its last database target was
|
||||
// removed. The archive FILE is deliberately left on disk: it is
|
||||
// long-term storage an operator may still want.
|
||||
func (w *archiveWriter) evict() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -17,109 +17,85 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// deliverTo archives one event to a database target, leaving the
|
||||
// target's writer cached with its handle open.
|
||||
func deliverTo(
|
||||
t *testing.T, env *archiveEnv, tgt *database.Target,
|
||||
) {
|
||||
// evictTestEngine builds an engine backed by a temporary data
|
||||
// directory and returns it along with that directory.
|
||||
func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
|
||||
t.Helper()
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
dataDir := t.TempDir()
|
||||
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
eng := delivery.NewTestEngineWithDB(
|
||||
nil,
|
||||
database.NewTestWebhookDBManager(dataDir),
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
return eng, dataDir
|
||||
}
|
||||
|
||||
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
||||
// a webhook drops the archive writers of its database targets
|
||||
// from the registry and closes their open handles, rather than
|
||||
// leaving them alive for the process lifetime, and leaves another
|
||||
// webhook's writer alone.
|
||||
// a webhook drops its archive writer from the registry and
|
||||
// closes the open archive handle, rather than leaving both
|
||||
// alive for the process lifetime.
|
||||
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
first := env.seedDatabaseTarget(t, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||
other := env.seedDatabaseTarget(t, "")
|
||||
eng, dataDir := evictTestEngine(t)
|
||||
|
||||
for _, tgt := range []*database.Target{first, second, other} {
|
||||
deliverTo(t, env, tgt)
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
"the writer should hold an open handle after a write",
|
||||
)
|
||||
}
|
||||
eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
env.eng.EvictWebhook(first.WebhookID)
|
||||
webhookID := event.WebhookID
|
||||
|
||||
for _, tgt := range []*database.Target{first, second} {
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
"eviction should remove the registry entry",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
"eviction should close the archive handle",
|
||||
)
|
||||
assert.FileExists(
|
||||
t, env.archivePath(tgt),
|
||||
"eviction must not delete the archive file",
|
||||
)
|
||||
}
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(other.ID),
|
||||
"another webhook's writer must be left alone",
|
||||
require.True(
|
||||
t, eng.ExportHasArchiveWriter(webhookID),
|
||||
"a delivery should have cached an archive writer",
|
||||
)
|
||||
require.True(
|
||||
t, eng.ExportArchiveHandleOpen(webhookID),
|
||||
"the writer should hold an open handle after a write",
|
||||
)
|
||||
}
|
||||
|
||||
// TestEvictTarget_LeavesOtherTargets proves that evicting one
|
||||
// database target leaves the writer of another target of the same
|
||||
// webhook in place.
|
||||
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng.EvictWebhook(webhookID)
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
doomed := env.seedDatabaseTarget(t, "")
|
||||
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
|
||||
assert.False(
|
||||
t, eng.ExportHasArchiveWriter(webhookID),
|
||||
"eviction should remove the registry entry",
|
||||
)
|
||||
assert.False(
|
||||
t, eng.ExportArchiveHandleOpen(webhookID),
|
||||
"eviction should close the archive handle",
|
||||
)
|
||||
|
||||
deliverTo(t, env, doomed)
|
||||
deliverTo(t, env, kept)
|
||||
|
||||
env.eng.EvictTarget(doomed.ID)
|
||||
|
||||
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
|
||||
archivePath := filepath.Join(
|
||||
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||
)
|
||||
assert.FileExists(
|
||||
t, env.archivePath(doomed),
|
||||
t, archivePath,
|
||||
"eviction must not delete the archive file",
|
||||
)
|
||||
assert.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(kept.ID),
|
||||
"the other target's writer must be left alone",
|
||||
)
|
||||
}
|
||||
|
||||
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
|
||||
// for the common case of a webhook or target that never had an
|
||||
// archive writer, and that repeating it does not panic.
|
||||
// for the common case of a webhook that never had a database
|
||||
// target, and that repeating it does not panic.
|
||||
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
eng, _ := evictTestEngine(t)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
env.eng.EvictWebhook("no-such-webhook")
|
||||
env.eng.EvictWebhook("no-such-webhook")
|
||||
env.eng.EvictTarget("no-such-target")
|
||||
env.eng.EvictTarget("no-such-target")
|
||||
eng.EvictWebhook("no-such-webhook")
|
||||
eng.EvictWebhook("no-such-webhook")
|
||||
})
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter("no-such-target"),
|
||||
t, eng.ExportHasArchiveWriter("no-such-webhook"),
|
||||
"eviction must not create a writer",
|
||||
)
|
||||
}
|
||||
@@ -313,14 +289,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
eng, _ := evictTestEngine(t)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
// Prime the registry so the test can hold the very writer the
|
||||
// eviction is about to detach.
|
||||
deliverTo(t, env, tgt)
|
||||
eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
||||
require.NotNil(t, w)
|
||||
require.True(t, w.HandleOpen())
|
||||
|
||||
@@ -330,7 +309,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
// eviction has to contend for the writer's mutex.
|
||||
race.awaitFirstWrite()
|
||||
|
||||
env.eng.EvictWebhook(tgt.WebhookID)
|
||||
eng.EvictWebhook(event.WebhookID)
|
||||
|
||||
sawEvicted, otherErr := race.wait()
|
||||
|
||||
@@ -345,33 +324,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
"been evicted",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||
"the registry entry must stay gone",
|
||||
)
|
||||
}
|
||||
|
||||
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
|
||||
// does not break archiving for a target that is still alive: a
|
||||
// does not break archiving for a webhook that is still alive: a
|
||||
// subsequent delivery gets a brand new writer from the registry.
|
||||
// It says nothing about the evicted writer itself — that is what
|
||||
// TestEvictedWriter_WriteDoesNotReopenFile covers.
|
||||
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
eng, _ := evictTestEngine(t)
|
||||
|
||||
deliverTo(t, env, tgt)
|
||||
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
env.eng.EvictWebhook(tgt.WebhookID)
|
||||
eng.ExportDeliverDatabase(webhookDB, d)
|
||||
require.True(
|
||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||
)
|
||||
|
||||
// A fresh delivery for the same target gets a brand new
|
||||
eng.EvictWebhook(event.WebhookID)
|
||||
|
||||
// A fresh delivery for the same webhook gets a brand new
|
||||
// writer from the registry, so archiving keeps working.
|
||||
deliverTo(t, env, tgt)
|
||||
second := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, "",
|
||||
)
|
||||
eng.ExportDeliverDatabase(webhookDB, second)
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||
"a later delivery should recreate the writer",
|
||||
)
|
||||
}
|
||||
@@ -383,16 +370,19 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
||||
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
eng, _ := evictTestEngine(t)
|
||||
|
||||
deliverTo(t, env, tgt)
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
w := eng.ExportArchiveWriterFor(event.WebhookID)
|
||||
require.NotNil(t, w)
|
||||
require.True(t, w.HandleOpen())
|
||||
|
||||
require.NoError(t, env.eng.ExportStop(context.Background()))
|
||||
require.NoError(t, eng.ExportStop(context.Background()))
|
||||
|
||||
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
||||
|
||||
@@ -405,7 +395,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||
"a refused write must not reopen the archive",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, eng.ExportHasArchiveWriter(event.WebhookID),
|
||||
"the stop should empty the registry",
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -73,18 +74,25 @@ func removeArchiveFiles(t *testing.T, path string) {
|
||||
|
||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||
// a database target marks the delivery delivered and archives
|
||||
// the full event into the target's own archive file.
|
||||
// the full event into a separate per-webhook archive file.
|
||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
dataDir := t.TempDir()
|
||||
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil, dbMgr,
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
e.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
@@ -97,7 +105,8 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||
)
|
||||
|
||||
archivePath := filepath.Join(
|
||||
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
||||
dataDir,
|
||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||
)
|
||||
assert.FileExists(t, archivePath)
|
||||
|
||||
@@ -184,20 +193,13 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const debounce = 2 * time.Second
|
||||
|
||||
// A generous debounce keeps the two rapid writes inside
|
||||
// the window even on a heavily loaded test machine.
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), debounce,
|
||||
path, archiveTestLogger(), 2*time.Second,
|
||||
)
|
||||
|
||||
// The writer measures its reopen debounce on this clock, which
|
||||
// only the test moves, so how long the host takes between
|
||||
// writes cannot change the result.
|
||||
now := time.Now()
|
||||
|
||||
w.SetNow(func() time.Time { return now })
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
@@ -209,7 +211,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
// initial open — no extra close/reopen.
|
||||
assert.Equal(t, 1, w.Reopens())
|
||||
|
||||
now = now.Add(debounce)
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||
@@ -286,31 +288,31 @@ func TestParseArchiveExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedDatabaseTargetDelivery seeds a pending delivery of an event
|
||||
// to a database target and returns the in-memory delivery the
|
||||
// target handler is invoked with.
|
||||
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
||||
// database target with the given config JSON and returns the
|
||||
// in-memory delivery the target handler is invoked with.
|
||||
func seedDatabaseTargetDelivery(
|
||||
t *testing.T,
|
||||
webhookDB *gorm.DB,
|
||||
event database.Event,
|
||||
tgt *database.Target,
|
||||
config string,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, webhookDB, event.ID, tgt.ID,
|
||||
t, webhookDB, event.ID, uuid.New().String(),
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: tgt.ID,
|
||||
TargetID: dlv.TargetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: tgt.Name,
|
||||
Name: "test-db",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: tgt.Config,
|
||||
Config: config,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -328,14 +330,22 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
|
||||
dataDir := t.TempDir()
|
||||
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil, database.NewTestWebhookDBManager(dataDir),
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
||||
)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
e.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
@@ -363,7 +373,10 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
)
|
||||
|
||||
assert.NoFileExists(t,
|
||||
env.archivePath(tgt),
|
||||
filepath.Join(
|
||||
dataDir,
|
||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||
),
|
||||
"no archive file should exist for a failed config",
|
||||
)
|
||||
}
|
||||
@@ -387,290 +400,3 @@ func TestValidateArchiveExpiry(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveFileName pins the archive file name and the rules
|
||||
// that make a webhook or target name safe to put in it.
|
||||
func TestArchiveFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
webhook string
|
||||
target string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"plain names", "orders", "archive",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"lowercased", "Orders", "Main Archive",
|
||||
"archive-orders-main-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"a run of other characters is one dash",
|
||||
`a /\..b`, "c__--d",
|
||||
"archive-a-b-c-d-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash at either end", " --orders!! ", "(archive)",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"path separators", "../../etc/passwd", "a/b",
|
||||
"archive-etc-passwd-a-b-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"letters outside ASCII are dropped",
|
||||
"Bestellungen Größe", "café",
|
||||
"archive-bestellungen-gr-e-caf-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"nothing left is unnamed", "", "!!!",
|
||||
"archive-unnamed-unnamed-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"cut to 40 characters", strings.Repeat("a", 50), "x",
|
||||
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash left by the cut",
|
||||
strings.Repeat("a", 39) + " b", "x",
|
||||
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want,
|
||||
delivery.ArchiveFileName(tc.webhook, tc.target, id),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
|
||||
// database targets of one webhook archive into separate files.
|
||||
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
first := env.seedDatabaseTarget(t, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
|
||||
for _, tgt := range []*database.Target{first, second} {
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
}
|
||||
|
||||
require.NotEqual(
|
||||
t, env.archivePath(first), env.archivePath(second),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(first)),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(second)),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
||||
// and all, and that later writes go to the new name.
|
||||
func TestRename_MovesTheFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||
)
|
||||
require.FileExists(t, oldPath)
|
||||
|
||||
require.NoError(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
|
||||
assert.NoFileExists(t, oldPath)
|
||||
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
|
||||
|
||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||
)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, newPath),
|
||||
)
|
||||
assert.NoFileExists(
|
||||
t, oldPath, "a write after the rename must use the new name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_NeverReplacesAFile plants a file at the new name, once
|
||||
// the .db alone, once a lone -wal and once a lone -shm, and proves
|
||||
// each time that the rename is refused, the planted file survives,
|
||||
// and the archive keeps its name and its rows.
|
||||
func TestRename_NeverReplacesAFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
t.Run("planted .db"+suffix, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
plantedPath := newPath + suffix
|
||||
require.NoError(
|
||||
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
|
||||
)
|
||||
|
||||
require.ErrorIs(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
delivery.ErrArchiveNameTaken,
|
||||
)
|
||||
|
||||
//nolint:gosec // reads the file the test planted under t.TempDir()
|
||||
planted, err := os.ReadFile(plantedPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "planted", string(planted))
|
||||
|
||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||
)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, oldPath),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRename_BeforeTheNameIsSaved covers the order the handlers
|
||||
// use: they rename before they save the new name, so a delivery in
|
||||
// between must write under the new name although the main database
|
||||
// still has the old one. It also shows that renaming an archive that
|
||||
// does not exist yet is not an error.
|
||||
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
|
||||
require.NoError(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
|
||||
assert.FileExists(
|
||||
t,
|
||||
filepath.Join(
|
||||
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
|
||||
),
|
||||
)
|
||||
assert.NoFileExists(t, env.archivePath(tgt))
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
|
||||
// the -wal and -shm a crash can leave beside an archive no handle
|
||||
// has opened since. SQLite finds them by name, so a -wal left
|
||||
// behind would lose the transactions it holds.
|
||||
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newPath := filepath.Join(dir, "archive-new.db")
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Rename("archive-new.db"))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.NoFileExists(t, oldPath+suffix)
|
||||
assert.FileExists(t, newPath+suffix)
|
||||
}
|
||||
|
||||
assert.Equal(t, newPath, w.Path())
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
|
||||
// move after the .db has moved, and proves the .db is moved back, so
|
||||
// the archive is never split across two names. The new name is 255
|
||||
// bytes, the longest a file name may be, so the .db can take it but
|
||||
// the -wal, four bytes longer, cannot.
|
||||
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newName := strings.Repeat("a", 252) + ".db"
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.Error(t, w.Rename(newName))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.FileExists(t, oldPath+suffix)
|
||||
}
|
||||
|
||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||
assert.Equal(t, oldPath, w.Path())
|
||||
}
|
||||
|
||||
@@ -442,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
|
||||
)
|
||||
}
|
||||
|
||||
originScoped := applyRequestHeaders(
|
||||
req, event, cfg, t.eng.userAgent(),
|
||||
)
|
||||
originScoped := applyRequestHeaders(req, event, cfg)
|
||||
|
||||
client := t.clientForRequest(cfg, originScoped)
|
||||
|
||||
@@ -564,13 +562,10 @@ func isForwardableHeader(name string) bool {
|
||||
// Content-Type goes out once: a Content-Type configured on the target
|
||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||
// Content-Type in the event's headers is never forwarded.
|
||||
//
|
||||
// userAgent is set last, over any configured or inbound User-Agent.
|
||||
func applyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
userAgent string,
|
||||
) []string {
|
||||
if event.ContentType != "" {
|
||||
req.Header.Set(
|
||||
@@ -585,7 +580,7 @@ func applyRequestHeaders(
|
||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
|
||||
// A Content-Type configured on the target describes the body
|
||||
// being sent rather than the sender. A 307/308 preserves the
|
||||
|
||||
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", t.eng.userAgent())
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
|
||||
resp, doErr := executeHTTPRequest(t.client, req)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
)
|
||||
|
||||
// Both the http and the slack target send webhooker/ and the version
|
||||
// in Globals, the value the web UI footer shows. A User-Agent
|
||||
// configured on the target or carried in by the sender does not
|
||||
// replace it.
|
||||
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const want = "webhooker/1.2.3-test"
|
||||
|
||||
userAgents := make(chan string, 1)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
userAgents <- r.Header.Get("User-Agent")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
defer ts.Close()
|
||||
|
||||
g := &globals.Globals{Version: "1.2.3-test"}
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||
require.NoError(t, err)
|
||||
|
||||
e := delivery.New(lc, delivery.EngineParams{
|
||||
Globals: g,
|
||||
Logger: log,
|
||||
// httptest listens on loopback, which the default guard
|
||||
// refuses.
|
||||
SSRFGuard: delivery.NewTestGuard(
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
),
|
||||
Metrics: metrics.New(prometheus.NewRegistry()),
|
||||
})
|
||||
|
||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||
context.Background(),
|
||||
&delivery.HTTPTargetConfig{
|
||||
URL: ts.URL,
|
||||
Headers: map[string]string{"User-Agent": "configured/1"},
|
||||
},
|
||||
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, statusCode)
|
||||
require.Len(t, userAgents, 1, "the http target sent no request")
|
||||
assert.Equal(t, want, <-userAgents, "http target")
|
||||
|
||||
db := testWebhookDB(t)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
slackCfg, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"action":"test"}`)
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID, database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
|
||||
dlv, event, targetID, "test-slack", string(slackCfg),
|
||||
))
|
||||
require.Len(t, userAgents, 1, "the slack target sent no request")
|
||||
assert.Equal(t, want, <-userAgents, "slack target")
|
||||
}
|
||||
@@ -137,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||
// running after a start or stop timeout would write there after
|
||||
// the test has returned.
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
|
||||
+27
-10
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -139,7 +139,8 @@ func (h *Handlers) renderLoginError(
|
||||
),
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, "login.html", data, status)
|
||||
w.WriteHeader(status)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
}
|
||||
|
||||
// authenticateUser looks up and verifies a user's credentials.
|
||||
@@ -211,7 +212,11 @@ func (h *Handlers) authenticateUser(
|
||||
|
||||
valid, err := database.VerifyPassword(password, user.Password)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to verify password", err)
|
||||
h.log.Error("failed to verify password", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return user, err
|
||||
}
|
||||
@@ -283,14 +288,24 @@ func (h *Handlers) createAuthenticatedSession(
|
||||
) error {
|
||||
oldSess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get session", err)
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to regenerate session", err)
|
||||
h.log.Error(
|
||||
"failed to regenerate session", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -299,7 +314,11 @@ func (h *Handlers) createAuthenticatedSession(
|
||||
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to save session", err)
|
||||
h.log.Error("failed to save session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -332,9 +351,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
)
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, withNotice("/pages/login", signedOut),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
// Redirect to login page
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -405,60 +404,6 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
|
||||
// form shown again with an error still answers 400 with the whole page.
|
||||
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(
|
||||
t, w.Body.String(), "Username and password are required",
|
||||
)
|
||||
assert.Contains(
|
||||
t, w.Body.String(), "</html>",
|
||||
"the page must render to completion",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
|
||||
// error page whose template fails answers 500 with the error page and
|
||||
// none of the form page, rather than the 400 it meant to send.
|
||||
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// The page prints its error message and then fails.
|
||||
h.AddTemplateForTest("login.html", template.Must(
|
||||
template.New("login").Funcs(template.FuncMap{
|
||||
"fail": func() (string, error) { return "", errMidRender },
|
||||
}).Parse(`{{.Error}}{{fail}}`),
|
||||
))
|
||||
|
||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.NotContains(
|
||||
t, w.Body.String(), "Username and password are required",
|
||||
"the response must carry no part of the aborted page",
|
||||
)
|
||||
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
|
||||
}
|
||||
|
||||
// TestLogin_SuccessCreatesSession is the control for the tests above:
|
||||
// the success path they assert on really does authenticate.
|
||||
func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||
|
||||
@@ -11,37 +11,72 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// The outcomes of a replay POST, as the notice codes its redirect
|
||||
// carries. noticeFor holds the line each one shows.
|
||||
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||
// with and the event log page reads its banner from.
|
||||
const replayOutcomeParam = "replay"
|
||||
|
||||
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||
// carries one of these fixed codes rather than a message, so nothing a
|
||||
// client submits can reach the rendered page through it.
|
||||
type replayOutcomeCode string
|
||||
|
||||
const (
|
||||
// replayQueued reports that a new delivery was created and handed
|
||||
// to the delivery engine.
|
||||
replayQueued noticeCode = "replay-queued"
|
||||
replayQueued replayOutcomeCode = "queued"
|
||||
|
||||
// replayTargetDeleted reports a target that once existed and has
|
||||
// since been deleted. Deletes are soft and deliveries carry no
|
||||
// foreign key to the target row, so the history survives its
|
||||
// target and this is the ordinary case for an old event.
|
||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
||||
|
||||
// replayTargetMissing reports a target id that names no row at
|
||||
// all, deleted or otherwise.
|
||||
replayTargetMissing noticeCode = "replay-target-missing"
|
||||
replayTargetMissing replayOutcomeCode = "target-missing"
|
||||
|
||||
// replayTargetInactive reports a target the operator has
|
||||
// deactivated. A deactivated target receives no new deliveries, so
|
||||
// a replay to it would be a delivery they switched off.
|
||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
||||
replayTargetInactive replayOutcomeCode = "target-inactive"
|
||||
|
||||
// replayNotTerminal reports a delivery the engine has not finished
|
||||
// with.
|
||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
||||
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||
|
||||
// replayInFlight reports that an earlier replay of this event to
|
||||
// this target is still running.
|
||||
replayInFlight noticeCode = "replay-in-flight"
|
||||
replayInFlight replayOutcomeCode = "in-flight"
|
||||
)
|
||||
|
||||
// replayOutcome returns the banner the event log page shows for an
|
||||
// outcome code, and whether the replay was queued. An unrecognised
|
||||
// code yields no banner.
|
||||
func replayOutcome(code string) (string, bool) {
|
||||
switch replayOutcomeCode(code) {
|
||||
case replayQueued:
|
||||
return "Replay queued: a new delivery was created against " +
|
||||
"the target's current configuration.", true
|
||||
case replayTargetDeleted:
|
||||
return "Not replayed: the target this delivery was for has " +
|
||||
"been deleted. Recreate the target, then replay.", false
|
||||
case replayTargetMissing:
|
||||
return "Not replayed: the target this delivery was for no " +
|
||||
"longer exists.", false
|
||||
case replayTargetInactive:
|
||||
return "Not replayed: the target this delivery was for is " +
|
||||
"deactivated. Activate it, then replay.", false
|
||||
case replayNotTerminal:
|
||||
return "Not replayed: this delivery has not finished yet.",
|
||||
false
|
||||
case replayInFlight:
|
||||
return "Not replayed: a delivery of this event to this " +
|
||||
"target is already in flight.", false
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||
// target.
|
||||
//
|
||||
@@ -70,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -87,14 +124,14 @@ func (h *Handlers) replayDelivery(
|
||||
webhook database.Webhook,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -105,14 +142,14 @@ func (h *Handlers) replayDelivery(
|
||||
}
|
||||
|
||||
if !original.Status.Terminal() {
|
||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
||||
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||
if target == nil {
|
||||
redirectToEventLog(w, r, webhook, code)
|
||||
h.finishReplay(w, r, webhook, code)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -136,7 +173,7 @@ func (h *Handlers) loadReplaySource(
|
||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||
).Error
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
@@ -158,14 +195,14 @@ func (h *Handlers) queueReplay(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, r, "failed to count in-flight deliveries", err,
|
||||
w, "failed to count in-flight deliveries", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if inFlight > 0 {
|
||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
||||
h.finishReplay(w, r, webhook, replayInFlight)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -175,7 +212,7 @@ func (h *Handlers) queueReplay(
|
||||
err = webhookDB.
|
||||
First(&event, "id = ?", original.EventID).Error
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to load event for replay", err)
|
||||
h.serverError(w, "failed to load event for replay", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -185,7 +222,7 @@ func (h *Handlers) queueReplay(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, r, "failed to create replay delivery", err,
|
||||
w, "failed to create replay delivery", err,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -203,7 +240,7 @@ func (h *Handlers) queueReplay(
|
||||
"delivery_id", task.DeliveryID,
|
||||
)
|
||||
|
||||
redirectToEventLog(w, r, webhook, replayQueued)
|
||||
h.finishReplay(w, r, webhook, replayQueued)
|
||||
}
|
||||
|
||||
// replayTarget loads the delivery's target as it stands now.
|
||||
@@ -216,7 +253,7 @@ func (h *Handlers) queueReplay(
|
||||
// with the returned code saying why.
|
||||
func (h *Handlers) replayTarget(
|
||||
webhookID, targetID string,
|
||||
) (*database.Target, noticeCode) {
|
||||
) (*database.Target, replayOutcomeCode) {
|
||||
var target database.Target
|
||||
|
||||
err := h.db.DB().Unscoped().Where(
|
||||
@@ -326,16 +363,17 @@ func replayBody(body string) *string {
|
||||
return &body
|
||||
}
|
||||
|
||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||
// log it was triggered from, carrying the outcome as its notice and
|
||||
// the page number the form submitted.
|
||||
func redirectToEventLog(
|
||||
// finishReplay redirects back to the event log the replay was
|
||||
// triggered from, carrying the outcome code the page turns into a
|
||||
// banner and the page number the form submitted.
|
||||
func (h *Handlers) finishReplay(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code noticeCode,
|
||||
code replayOutcomeCode,
|
||||
) {
|
||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
replayOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
|
||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
||||
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
||||
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||
missing.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||
require.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
first.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
||||
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||
second.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
||||
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||
pending.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
assert.Contains(t, body, ">Replay<")
|
||||
|
||||
refused := renderSourceLogsPageWithQuery(
|
||||
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
||||
t, h, sess, wh.ID, "?replay=target-deleted",
|
||||
)
|
||||
|
||||
assert.Contains(t, refused, "alert-error")
|
||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
|
||||
// An outcome code nobody issued renders no banner at all.
|
||||
unknown := renderSourceLogsPageWithQuery(
|
||||
t, h, sess, wh.ID, "?notice=made-up",
|
||||
t, h, sess, wh.ID, "?replay=made-up",
|
||||
)
|
||||
|
||||
assert.NotContains(t, unknown, "alert-error")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
)
|
||||
|
||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
||||
// which cannot render answers with the status it was reporting, as
|
||||
// plain text, and is not attempted again: a page whose own render
|
||||
// fails reaches the error page, and the error page failing as well
|
||||
// ends there with the 500.
|
||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// .Status is an int, so asking it for a field fails the render.
|
||||
failing := `{{.Status.Missing}}`
|
||||
h.AddTemplateForTest("error.html", template.Must(
|
||||
template.New("error").Parse(failing),
|
||||
))
|
||||
h.AddTemplateForTest("failing.html", template.Must(
|
||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||
// steered by a client.
|
||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
||||
eventID string,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to read event body", err)
|
||||
h.serverError(w, "failed to read event body", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
||||
// row and the whole body is served, or it does not and the
|
||||
// response is a clean 404.
|
||||
if !found {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
@@ -10,19 +11,43 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// The outcomes of a resubmit POST, as the notice codes its redirect
|
||||
// carries. noticeFor holds the line each one shows.
|
||||
// resubmitOutcomeParam is the query parameter the resubmit POST
|
||||
// redirects with and the event log page reads its banner from.
|
||||
const resubmitOutcomeParam = "resubmit"
|
||||
|
||||
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
||||
// carries one of these fixed codes rather than a message, so nothing a
|
||||
// client submits can reach the rendered page through it.
|
||||
type resubmitOutcomeCode string
|
||||
|
||||
const (
|
||||
// resubmitQueued reports that a new event was stored and its
|
||||
// deliveries handed to the delivery engine.
|
||||
resubmitQueued noticeCode = "resubmit-queued"
|
||||
resubmitQueued resubmitOutcomeCode = "queued"
|
||||
|
||||
// resubmitNoTargets reports a source with no active targets. The
|
||||
// new event is stored either way, exactly as a received event
|
||||
// with no targets is.
|
||||
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
||||
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
||||
)
|
||||
|
||||
// resubmitOutcome returns the banner the event log page shows for an
|
||||
// outcome code, and whether the resubmit was queued. An unrecognised
|
||||
// code yields no banner.
|
||||
func resubmitOutcome(code string) (string, bool) {
|
||||
switch resubmitOutcomeCode(code) {
|
||||
case resubmitQueued:
|
||||
return "Resubmitted: a new event was created from the stored " +
|
||||
"one and queued to every active target.", true
|
||||
case resubmitNoTargets:
|
||||
return "Resubmitted: a new event was created, but this " +
|
||||
"source has no active targets, so nothing was queued.",
|
||||
true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||
// read as bytes rather than as a string so the copy is byte-identical
|
||||
// to what was received, whatever the payload's encoding.
|
||||
@@ -74,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -95,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
||||
// alphabet rather than from the request.
|
||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -122,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
||||
webhookDB, webhook.ID, eventID.String(),
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to load event to resubmit", err)
|
||||
h.serverError(w, "failed to load event to resubmit", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -130,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
||||
// A miss is a 404 whether the event was reaped, belongs to
|
||||
// another webhook, or never existed.
|
||||
if !found {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -182,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
||||
// inactive one is skipped rather than refused.
|
||||
targets, err := h.loadActiveTargets(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to query targets", err)
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -200,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
||||
targets,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to store resubmitted event", err)
|
||||
h.serverError(w, "failed to store resubmitted event", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -220,5 +245,29 @@ func (h *Handlers) queueResubmit(
|
||||
code = resubmitNoTargets
|
||||
}
|
||||
|
||||
redirectToEventLog(w, r, webhook, code)
|
||||
h.finishResubmit(w, r, webhook, code)
|
||||
}
|
||||
|
||||
// finishResubmit redirects back to the event log the resubmit was
|
||||
// triggered from, carrying the outcome code the page turns into a
|
||||
// banner and the page number the form submitted.
|
||||
func (h *Handlers) finishResubmit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code resubmitOutcomeCode,
|
||||
) {
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
resubmitOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
// headers record.
|
||||
if page := pageOrFirst(
|
||||
r.PostFormValue("page"),
|
||||
); page > 1 {
|
||||
dest += "&page=" + strconv.Itoa(page)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"a resubmit must not be refused while an earlier "+
|
||||
"one is in flight",
|
||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"an inactive target is skipped, not an error",
|
||||
)
|
||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -67,7 +65,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
||||
page int,
|
||||
) []EventLogView {
|
||||
views, _, _ := s.loadEventsWithDeliveries(
|
||||
w, newRequestForTest(), webhook, nil, page,
|
||||
w, webhook, nil, page,
|
||||
)
|
||||
|
||||
return views
|
||||
@@ -96,14 +94,6 @@ func FinishedByTargetForTest(
|
||||
return finishedByTarget(webhookDB, since)
|
||||
}
|
||||
|
||||
// newRequestForTest is the request the helpers here pass on for
|
||||
// callers that have none: it is used only to render the error page.
|
||||
func newRequestForTest() *http.Request {
|
||||
return httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
}
|
||||
|
||||
// AddTemplateForTest registers a template under a page name so that
|
||||
// the handlers_test package can drive the render path with a
|
||||
// template of its own.
|
||||
@@ -157,5 +147,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
expiry string,
|
||||
) (string, error) {
|
||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
||||
return s.buildDatabaseTargetConfig(w, expiry)
|
||||
}
|
||||
|
||||
+36
-148
@@ -10,12 +10,9 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
@@ -59,17 +56,14 @@ type HandlersParams struct {
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
WebhookDBMgr *database.WebhookDBManager
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Session *session.Session
|
||||
Middleware *middleware.Middleware
|
||||
Notifier delivery.Notifier
|
||||
Archives delivery.Archives
|
||||
Evictor delivery.WebhookEvictor
|
||||
SSRFGuard *delivery.Guard
|
||||
Metrics *metrics.Set
|
||||
Registry *prometheus.Registry
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler methods for all application
|
||||
@@ -83,7 +77,7 @@ type Handlers struct {
|
||||
session *session.Session
|
||||
mw *middleware.Middleware
|
||||
notifier delivery.Notifier
|
||||
archives delivery.Archives
|
||||
evictor delivery.WebhookEvictor
|
||||
mtr *metrics.Set
|
||||
templates map[string]*template.Template
|
||||
|
||||
@@ -92,14 +86,6 @@ type Handlers struct {
|
||||
// is one delivery will actually attempt.
|
||||
ssrf *delivery.Guard
|
||||
|
||||
// renameMu makes the webhook edit, the target edit and target
|
||||
// creation run one at a time, each held from loading the stored
|
||||
// names through the archive rename, the save and any move back.
|
||||
// Interleaved, one could rename an archive between another's
|
||||
// rename and save, leaving the file named for one edit and the
|
||||
// stored names from the other.
|
||||
renameMu sync.Mutex
|
||||
|
||||
// dummyVerifications counts the equivalent-cost verifications
|
||||
// charged for usernames that do not exist. It exists so a test
|
||||
// can prove that path runs without measuring wall-clock time.
|
||||
@@ -108,10 +94,10 @@ type Handlers struct {
|
||||
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, navbar and notice templates, and with any further
|
||||
// files the page includes. The page file must be listed first so that
|
||||
// its root action ({{template "base" .}}) becomes the template set's
|
||||
// entry point.
|
||||
// base, htmlheader, and navbar templates, and with any further files
|
||||
// the page includes. The page file must be listed first so that its
|
||||
// root action ({{template "base" .}}) becomes the template set's entry
|
||||
// point.
|
||||
func parsePageTemplate(
|
||||
pageFile string, included ...string,
|
||||
) *template.Template {
|
||||
@@ -120,7 +106,6 @@ func parsePageTemplate(
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
"notice.html",
|
||||
}, included...)
|
||||
|
||||
return template.Must(
|
||||
@@ -143,22 +128,20 @@ func New(
|
||||
s.session = params.Session
|
||||
s.mw = params.Middleware
|
||||
s.notifier = params.Notifier
|
||||
s.archives = params.Archives
|
||||
s.mtr = params.Metrics
|
||||
s.evictor = params.Evictor
|
||||
s.mtr = metrics.Default()
|
||||
s.ssrf = params.SSRFGuard
|
||||
|
||||
// Parse all page templates once at startup
|
||||
s.templates = map[string]*template.Template{
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"settings.html": parsePageTemplate("settings.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -170,16 +153,6 @@ func New(
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HandleErrorPage returns a handler that answers every request with
|
||||
// the error page for status. The router uses it for unknown paths, the
|
||||
// CSRF middleware for a refused form, and each admin page route
|
||||
// group's recoverer for a panic.
|
||||
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
@@ -197,78 +170,15 @@ func (s *Handlers) respondJSON(
|
||||
}
|
||||
}
|
||||
|
||||
// serverError logs an error and answers with the 500 error page.
|
||||
// serverError logs an error and sends a 500 response.
|
||||
func (s *Handlers) serverError(
|
||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
) {
|
||||
s.log.Error(msg, "error", err)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// renderError answers with status and the error page: the normal
|
||||
// layout, one fixed line explaining the status, and a link back to the
|
||||
// webhook list, or to sign-in when nobody is signed in.
|
||||
//
|
||||
// It renders the page itself rather than through renderTemplate,
|
||||
// whose own failure comes here. If the error page cannot render
|
||||
// either, the answer is the same status in plain text: never a second
|
||||
// attempt, and never a different status.
|
||||
func (s *Handlers) renderError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
status int,
|
||||
) {
|
||||
// The page names the signed-in user, and some error pages are
|
||||
// served outside the routes where NoCache runs.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// No notice: one would say an action worked above a page saying
|
||||
// the request failed.
|
||||
data := s.pageData(r, map[string]any{
|
||||
"Status": status,
|
||||
"StatusText": http.StatusText(status),
|
||||
"Message": errorPageText(status),
|
||||
}, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := s.templates["error.html"].Execute(&buf, data)
|
||||
if err != nil {
|
||||
s.log.Error("failed to render error page", "error", err)
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_, err = buf.WriteTo(w)
|
||||
if err != nil {
|
||||
s.log.Error("failed to write error page", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errorPageText is the line the error page shows for status. It is
|
||||
// fixed per status, so the page tells the reader no more than the
|
||||
// plain-text answers it replaced did.
|
||||
func errorPageText(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "The request could not be read."
|
||||
case http.StatusForbidden:
|
||||
return "The request was refused. If it came from a form " +
|
||||
"left open for a long time, reload the page and try " +
|
||||
"again."
|
||||
case http.StatusNotFound:
|
||||
return "There is nothing here. It may have been deleted, " +
|
||||
"or the address may be wrong."
|
||||
case http.StatusServiceUnavailable:
|
||||
return "The server is busy. Please try again in a moment."
|
||||
default: // http.StatusInternalServerError
|
||||
return "Something went wrong on the server. Please try " +
|
||||
"again."
|
||||
}
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// UserInfo represents user information for templates
|
||||
@@ -282,7 +192,6 @@ type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Version string
|
||||
Notice *notice
|
||||
Data any
|
||||
}
|
||||
|
||||
@@ -309,26 +218,12 @@ func (s *Handlers) getUserInfo(
|
||||
}
|
||||
|
||||
// renderTemplate renders a pre-parsed template with common
|
||||
// data and answers 200.
|
||||
// data
|
||||
func (s *Handlers) renderTemplate(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
pageTemplate string,
|
||||
data any,
|
||||
) {
|
||||
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
|
||||
}
|
||||
|
||||
// renderTemplateStatus is renderTemplate answering with status, for a
|
||||
// form shown again with an error. Call it instead of WriteHeader
|
||||
// followed by renderTemplate: the status is written only once the page
|
||||
// has rendered, so a failed render can still answer 500.
|
||||
func (s *Handlers) renderTemplateStatus(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
pageTemplate string,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
tmpl, ok := s.templates[pageTemplate]
|
||||
if !ok {
|
||||
@@ -336,22 +231,14 @@ func (s *Handlers) renderTemplateStatus(
|
||||
"template not found",
|
||||
"template", pageTemplate,
|
||||
)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.executeTemplate(
|
||||
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
|
||||
)
|
||||
}
|
||||
|
||||
// pageData adds the fields the shared layout renders to a page's own
|
||||
// data. The layout shows the notice, when there is one, above the
|
||||
// page.
|
||||
func (s *Handlers) pageData(
|
||||
r *http.Request, data any, pageNotice *notice,
|
||||
) any {
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
@@ -365,34 +252,33 @@ func (s *Handlers) pageData(
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
m["Notice"] = pageNotice
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
return m
|
||||
return
|
||||
}
|
||||
|
||||
return templateDataWrapper{
|
||||
wrapper := templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Notice: pageNotice,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
s.executeTemplate(w, tmpl, wrapper)
|
||||
}
|
||||
|
||||
// executeTemplate renders the template into a buffer and writes status
|
||||
// and the page to the response only once rendering has fully
|
||||
// succeeded. Executing straight into the ResponseWriter commits a
|
||||
// partial body and the status before a mid-render error can be
|
||||
// reported, leaving no way to serve a 500. Buffering makes a page's
|
||||
// rendered size resident memory per concurrent viewer, so every page
|
||||
// owes it a bound: the event log caps each stored body at
|
||||
// maxRenderedBodyBytes for exactly this reason.
|
||||
// executeTemplate renders the template into a buffer and writes to
|
||||
// the response only once rendering has fully succeeded. Executing
|
||||
// straight into the ResponseWriter commits a partial body and a 200
|
||||
// status before a mid-render error can be reported, leaving no way
|
||||
// to serve a 500. Buffering makes a page's rendered size resident
|
||||
// memory per concurrent viewer, so every page owes it a bound: the
|
||||
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
||||
// this reason.
|
||||
func (s *Handlers) executeTemplate(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
tmpl *template.Template,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -401,13 +287,15 @@ func (s *Handlers) executeTemplate(
|
||||
s.log.Error(
|
||||
"failed to execute template", "error", err,
|
||||
)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_, err = buf.WriteTo(w)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
@@ -53,103 +51,23 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
|
||||
return out
|
||||
}
|
||||
|
||||
// recordingArchives is a delivery.Archives that records what it
|
||||
// was asked to do, so a test can prove that a deletion or rename
|
||||
// path reached the delivery engine. After FailRenames, every
|
||||
// rename of that target fails with the given error. After
|
||||
// BlockNextRename, the next rename is recorded and then waits.
|
||||
type recordingArchives struct {
|
||||
mu sync.Mutex
|
||||
evicted []string
|
||||
evictedTargets []string
|
||||
renames []archiveRename
|
||||
renameErrs map[string]error
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
// recordingEvictor is a delivery.WebhookEvictor that records
|
||||
// the webhook ids it was asked to evict, so a test can prove
|
||||
// that a deletion path reached the delivery engine.
|
||||
type recordingEvictor struct {
|
||||
mu sync.Mutex
|
||||
evicted []string
|
||||
}
|
||||
|
||||
// errInjectedRename is the failure a test hands FailRenames.
|
||||
var errInjectedRename = errors.New("injected rename failure")
|
||||
|
||||
// errNameTaken is what the delivery engine returns when a file
|
||||
// already has an archive's new name, here archive-taken.db.
|
||||
var errNameTaken = fmt.Errorf(
|
||||
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
|
||||
)
|
||||
|
||||
// archiveRename is one recorded Rename call.
|
||||
type archiveRename struct {
|
||||
TargetID string
|
||||
WebhookName string
|
||||
TargetName string
|
||||
}
|
||||
|
||||
func (r *recordingArchives) EvictWebhook(webhookID string) {
|
||||
func (r *recordingEvictor) EvictWebhook(webhookID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.evicted = append(r.evicted, webhookID)
|
||||
}
|
||||
|
||||
func (r *recordingArchives) EvictTarget(targetID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.evictedTargets = append(r.evictedTargets, targetID)
|
||||
}
|
||||
|
||||
func (r *recordingArchives) Rename(
|
||||
targetID, webhookName, targetName string,
|
||||
) error {
|
||||
r.mu.Lock()
|
||||
|
||||
r.renames = append(r.renames, archiveRename{
|
||||
TargetID: targetID,
|
||||
WebhookName: webhookName,
|
||||
TargetName: targetName,
|
||||
})
|
||||
err := r.renameErrs[targetID]
|
||||
entered, release := r.entered, r.release
|
||||
r.entered, r.release = nil, nil
|
||||
|
||||
r.mu.Unlock()
|
||||
|
||||
if entered != nil {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BlockNextRename makes the next rename, once recorded, wait until
|
||||
// the returned release is called. The returned channel is closed
|
||||
// when that rename starts waiting.
|
||||
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
r.mu.Lock()
|
||||
r.entered, r.release = entered, release
|
||||
r.mu.Unlock()
|
||||
|
||||
return entered, func() { close(release) }
|
||||
}
|
||||
|
||||
// FailRenames makes every later rename of targetID fail with err.
|
||||
func (r *recordingArchives) FailRenames(targetID string, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.renameErrs == nil {
|
||||
r.renameErrs = map[string]error{}
|
||||
}
|
||||
|
||||
r.renameErrs[targetID] = err
|
||||
}
|
||||
|
||||
// Evicted returns a copy of the recorded webhook ids.
|
||||
func (r *recordingArchives) Evicted() []string {
|
||||
func (r *recordingEvictor) Evicted() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -159,62 +77,22 @@ func (r *recordingArchives) Evicted() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// EvictedTargets returns a copy of the recorded target ids.
|
||||
func (r *recordingArchives) EvictedTargets() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
out := make([]string, len(r.evictedTargets))
|
||||
copy(out, r.evictedTargets)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Renames returns a copy of the recorded renames.
|
||||
func (r *recordingArchives) Renames() []archiveRename {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
out := make([]archiveRename, len(r.renames))
|
||||
copy(out, r.renames)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// newTestApp returns an app whose RequireStart fails the test when
|
||||
// starting takes longer than fx's default start timeout of 15s. That
|
||||
// limit catches a start that hangs, not a busy host: measured with make
|
||||
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
|
||||
// package's starts took 0.49s.
|
||||
func newTestApp(
|
||||
t *testing.T,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return newTestAppWithConfig(
|
||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||
)
|
||||
}
|
||||
|
||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||
func newTestAppWithConfig(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return fxtest.New(
|
||||
t,
|
||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||
// running after a start or stop timeout would write there after
|
||||
// the test has returned.
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config { return cfg },
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
@@ -225,14 +103,12 @@ func newTestAppWithConfig(
|
||||
func(n *recordingNotifier) delivery.Notifier {
|
||||
return n
|
||||
},
|
||||
func() *recordingArchives {
|
||||
return &recordingArchives{}
|
||||
func() *recordingEvictor {
|
||||
return &recordingEvictor{}
|
||||
},
|
||||
func(r *recordingArchives) delivery.Archives {
|
||||
func(r *recordingEvictor) delivery.WebhookEvictor {
|
||||
return r
|
||||
},
|
||||
metrics.NewRegistry,
|
||||
metrics.New,
|
||||
middleware.New,
|
||||
delivery.NewGuard,
|
||||
handlers.New,
|
||||
@@ -431,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
"a failed render must report a 500",
|
||||
)
|
||||
assert.NotContains(
|
||||
t, w.Body.String(), partialPageMarker,
|
||||
assert.Equal(
|
||||
t, "Internal server error\n", w.Body.String(),
|
||||
"the response must carry no part of the aborted page",
|
||||
)
|
||||
assert.Contains(
|
||||
t, w.Body.String(), "500 Internal Server Error",
|
||||
"a failed render must answer with the error page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// HandleMetrics returns the Prometheus scrape handler for the
|
||||
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
|
||||
// runtime and process collectors register on. It is what
|
||||
// promhttp.Handler builds for the global default registry, including
|
||||
// the promhttp_metric_handler_* series that count scrapes, pointed at
|
||||
// that registry instead.
|
||||
func (s *Handlers) HandleMetrics() http.HandlerFunc {
|
||||
reg := s.params.Registry
|
||||
|
||||
return promhttp.InstrumentMetricHandler(
|
||||
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
|
||||
).ServeHTTP
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// noticeParam is the query parameter an action's redirect carries its
|
||||
// notice code in.
|
||||
const noticeParam = "notice"
|
||||
|
||||
// noticeCode names one of the fixed lines noticeFor knows. An action
|
||||
// redirects with the code rather than the line, so nothing a client
|
||||
// puts in the URL reaches the page: a code noticeFor does not know
|
||||
// shows nothing.
|
||||
type noticeCode string
|
||||
|
||||
// The codes of the actions on the webhook pages and of signing out.
|
||||
// Replay's codes, with the reasons a replay can be refused, and
|
||||
// resubmit's codes are defined beside those actions.
|
||||
const (
|
||||
webhookCreated noticeCode = "webhook-created"
|
||||
webhookSaved noticeCode = "webhook-saved"
|
||||
webhookDeleted noticeCode = "webhook-deleted"
|
||||
entrypointAdded noticeCode = "entrypoint-added"
|
||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||
entrypointActivated noticeCode = "entrypoint-activated"
|
||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||
targetAdded noticeCode = "target-added"
|
||||
targetSaved noticeCode = "target-saved"
|
||||
targetDeleted noticeCode = "target-deleted"
|
||||
targetActivated noticeCode = "target-activated"
|
||||
targetDeactivated noticeCode = "target-deactivated"
|
||||
signedOut noticeCode = "signed-out"
|
||||
)
|
||||
|
||||
// notice is the line templates/notice.html shows above a page to say
|
||||
// what an action did.
|
||||
type notice struct {
|
||||
Text string
|
||||
|
||||
// Failed shows the line as an error: the action was refused.
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// noticeFor returns the notice the request's URL names, or nil when it
|
||||
// names none or an unknown code.
|
||||
func noticeFor(r *http.Request) *notice {
|
||||
n, ok := map[noticeCode]notice{
|
||||
webhookCreated: {Text: "Webhook created."},
|
||||
webhookSaved: {Text: "Webhook saved."},
|
||||
webhookDeleted: {Text: "Webhook deleted."},
|
||||
entrypointAdded: {Text: "Entrypoint added."},
|
||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||
entrypointActivated: {Text: "Entrypoint activated."},
|
||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||
targetAdded: {Text: "Target added."},
|
||||
targetSaved: {Text: "Target saved."},
|
||||
targetDeleted: {Text: "Target deleted."},
|
||||
targetActivated: {Text: "Target activated."},
|
||||
targetDeactivated: {Text: "Target deactivated."},
|
||||
signedOut: {Text: "Signed out."},
|
||||
|
||||
replayQueued: {
|
||||
Text: "Replay queued: a new delivery was created " +
|
||||
"against the target's current configuration.",
|
||||
},
|
||||
replayTargetDeleted: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"has been deleted. Recreate the target, then replay.",
|
||||
Failed: true,
|
||||
},
|
||||
replayTargetMissing: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"no longer exists.",
|
||||
Failed: true,
|
||||
},
|
||||
replayTargetInactive: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"is deactivated. Activate it, then replay.",
|
||||
Failed: true,
|
||||
},
|
||||
replayNotTerminal: {
|
||||
Text: "Not replayed: this delivery has not finished yet.",
|
||||
Failed: true,
|
||||
},
|
||||
replayInFlight: {
|
||||
Text: "Not replayed: a delivery of this event to this " +
|
||||
"target is already in flight.",
|
||||
Failed: true,
|
||||
},
|
||||
|
||||
resubmitQueued: {
|
||||
Text: "Resubmitted: a new event was created from the " +
|
||||
"stored one and queued to every active target.",
|
||||
},
|
||||
resubmitNoTargets: {
|
||||
Text: "Resubmitted: a new event was created, but this " +
|
||||
"source has no active targets, so nothing was queued.",
|
||||
},
|
||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &n
|
||||
}
|
||||
|
||||
// withNotice returns path with code added as its notice.
|
||||
func withNotice(path string, code noticeCode) string {
|
||||
return path + "?" + noticeParam + "=" + string(code)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||
r.Context(),
|
||||
w,
|
||||
r,
|
||||
sessionUsername,
|
||||
// PostFormValue, not FormValue: the credential must
|
||||
// come from the body, never from the query string.
|
||||
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
||||
// applyPasswordChange verifies the current password and, on success,
|
||||
// persists a fresh hash for the user, reusing the same helpers that
|
||||
// bootstrap the admin user. It returns the success and error messages
|
||||
// to display on the profile page. On an internal failure it writes the
|
||||
// error page itself and returns handled=false, signalling the caller
|
||||
// to display on the profile page. On an internal failure it writes a
|
||||
// 500 response itself and returns handled=false, signalling the caller
|
||||
// to stop without re-rendering the page.
|
||||
func (h *Handlers) applyPasswordChange(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
username, currentPassword, newPassword, confirmPassword string,
|
||||
) (string, string, bool) {
|
||||
// This endpoint verifies one password and hashes another, at
|
||||
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
|
||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||
// this path outside it would leave a hole in it. The slot is held
|
||||
// across both hashes.
|
||||
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
||||
release, ok := h.mw.BeginPasswordVerification(ctx)
|
||||
if !ok {
|
||||
h.log.Warn("password verification capacity exhausted")
|
||||
h.renderError(w, r, http.StatusServiceUnavailable)
|
||||
http.Error(
|
||||
w,
|
||||
"The server is busy verifying credentials. "+
|
||||
"Please try again.",
|
||||
http.StatusServiceUnavailable,
|
||||
)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
).First(&user).Error
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, r, "failed to load user for password change", err,
|
||||
w, "failed to load user for password change", err,
|
||||
)
|
||||
|
||||
return "", "", false
|
||||
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
currentPassword, user.Password,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to verify password", err)
|
||||
h.serverError(w, "failed to verify password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
|
||||
hashedPassword, err := database.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to hash new password", err)
|
||||
h.serverError(w, "failed to hash new password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
"password", hashedPassword,
|
||||
).Error
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to update password", err)
|
||||
h.serverError(w, "failed to update password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
) (string, string, bool) {
|
||||
requestedUsername := chi.URLParam(r, "username")
|
||||
if requestedUsername == "" {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
// unexpected retrieval error.
|
||||
sess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to get session", err)
|
||||
h.serverError(w, "failed to get session", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
sessionUsername, ok := h.session.GetUsername(sess)
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing username")
|
||||
h.renderError(w, r, http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
sessionUserID, ok := h.session.GetUserID(sess)
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing user ID")
|
||||
h.renderError(w, r, http.StatusInternalServerError)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Only allow users to act on their own profile.
|
||||
if requestedUsername != sessionUsername {
|
||||
h.renderError(w, r, http.StatusForbidden)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -128,9 +128,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &log, &cfg, &sess, &h)
|
||||
app := newTestApp(t, &log, &cfg, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
@@ -141,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/user/{username}", func(r chi.Router) {
|
||||
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(mw.CSRF())
|
||||
r.Use(mw.RequireAuth())
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
handlerReached = true
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
)
|
||||
|
||||
// notSet is what the Settings page shows for a value that is empty.
|
||||
const notSet = "not set"
|
||||
|
||||
// settingRow is one line of the Settings page: an environment
|
||||
// variable, what it controls, and the value the server loaded for it.
|
||||
type settingRow struct {
|
||||
Name string
|
||||
Description string
|
||||
Value string
|
||||
}
|
||||
|
||||
// HandleSettings returns a handler for the read-only Settings page,
|
||||
// which lists the configuration the server started with.
|
||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||
"Settings": settingRows(h.params.Config),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// settingRows lists every field of cfg under the environment variable
|
||||
// it is read from, with the description the README's configuration
|
||||
// table gives it (less its pointers to other README sections), in the
|
||||
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
|
||||
// their values never reach the page: only whether they are set.
|
||||
func settingRows(cfg *config.Config) []settingRow {
|
||||
metricsUsername := cfg.MetricsUsername
|
||||
if metricsUsername == "" {
|
||||
metricsUsername = notSet
|
||||
}
|
||||
|
||||
return []settingRow{
|
||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||
{
|
||||
"BIND_ADDRESS",
|
||||
"IP address the HTTP listener binds. Loopback by default, " +
|
||||
"so the cleartext listener is not published on every " +
|
||||
"interface. The Docker image ships 0.0.0.0 instead",
|
||||
cfg.BindAddress,
|
||||
},
|
||||
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
|
||||
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
|
||||
{
|
||||
"METRICS_USERNAME",
|
||||
"Basic auth username for /metrics. Must be set together " +
|
||||
"with METRICS_PASSWORD; one without the other fails " +
|
||||
"startup",
|
||||
metricsUsername,
|
||||
},
|
||||
{
|
||||
"METRICS_PASSWORD",
|
||||
"Basic auth password for /metrics. Must be set together " +
|
||||
"with METRICS_USERNAME; one without the other fails " +
|
||||
"startup",
|
||||
setOrNotSet(cfg.MetricsPassword),
|
||||
},
|
||||
{
|
||||
"SENTRY_DSN",
|
||||
"Sentry error reporting DSN. Unset leaves error reporting " +
|
||||
"off; a value the Sentry SDK cannot parse fails startup " +
|
||||
"rather than serving with reporting silently off",
|
||||
setOrNotSet(cfg.SentryDSN),
|
||||
},
|
||||
{
|
||||
"RETENTION_SWEEP_INTERVAL",
|
||||
"How often the retention reaper and archive sweeper run " +
|
||||
"(Go duration, must be positive)",
|
||||
cfg.RetentionSweepInterval.String(),
|
||||
},
|
||||
{
|
||||
"SESSION_IDLE_TIMEOUT",
|
||||
"Idle session timeout (Go duration)",
|
||||
cfg.SessionIdleTimeout.String(),
|
||||
},
|
||||
{
|
||||
"RECEIVER_RATE_LIMIT",
|
||||
"Receiver requests/minute per IP per entrypoint " +
|
||||
"(10x that per IP across the route)",
|
||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||
},
|
||||
{
|
||||
"TRUSTED_PROXIES",
|
||||
"CIDRs whose forwarded headers are trusted. A set value " +
|
||||
"replaces the default. If any client can reach webhooker, " +
|
||||
"or the proxy in front of it, from an RFC 1918 source " +
|
||||
"address, set it to the proxy's address alone",
|
||||
cidrList(cfg.TrustedProxies),
|
||||
},
|
||||
{
|
||||
"ALLOWED_EGRESS_CIDRS",
|
||||
"CIDRs that delivery targets may reach despite the " +
|
||||
"SSRF blocklist",
|
||||
cidrList(cfg.AllowedEgressCIDRs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||
// has a value, never the value itself.
|
||||
func setOrNotSet(value string) string {
|
||||
if value == "" {
|
||||
return notSet
|
||||
}
|
||||
|
||||
return "set"
|
||||
}
|
||||
|
||||
// cidrList renders a CIDR list setting for the Settings page.
|
||||
func cidrList(prefixes []netip.Prefix) string {
|
||||
if len(prefixes) == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||
// and returns the value it shows for each variable name, plus the
|
||||
// whole page.
|
||||
func settingsShown(
|
||||
t *testing.T, cfg *config.Config,
|
||||
) (map[string]string, string) {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/settings", nil,
|
||||
)
|
||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSettings().ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
row := regexp.MustCompile(
|
||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||
)
|
||||
|
||||
shown := map[string]string{}
|
||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||
shown[match[1]] = html.UnescapeString(match[2])
|
||||
}
|
||||
|
||||
return shown, body
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
|
||||
// only one of the three set in one of the content tests, so each
|
||||
// row is checked against its own field.
|
||||
cfg := &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Debug: true,
|
||||
Environment: config.EnvironmentDev,
|
||||
MetricsUsername: "scraper",
|
||||
MetricsPassword: "",
|
||||
Port: 9123,
|
||||
SentryDSN: "",
|
||||
BindAddress: "192.0.2.10",
|
||||
RetentionSweepInterval: 17 * time.Minute,
|
||||
SessionIdleTimeout: 3 * time.Hour,
|
||||
ReceiverRateLimit: 77,
|
||||
TrustedProxies: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.1.0.0/16"),
|
||||
},
|
||||
AllowedEgressCIDRs: []netip.Prefix{
|
||||
netip.MustParsePrefix("192.168.5.0/24"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
}
|
||||
|
||||
shown, body := settingsShown(t, cfg)
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||
"PORT": "9123",
|
||||
"BIND_ADDRESS": "192.0.2.10",
|
||||
"DATA_DIR": cfg.DataDir,
|
||||
"DEBUG": "true",
|
||||
"METRICS_USERNAME": "scraper",
|
||||
"METRICS_PASSWORD": "not set",
|
||||
"SENTRY_DSN": "not set",
|
||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||
"RECEIVER_RATE_LIMIT": "77",
|
||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||
}, shown)
|
||||
|
||||
assert.Contains(
|
||||
t, body, `href="/settings"`,
|
||||
"the navigation bar links to the page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const metricsPassword = "metrics-password-1f9a"
|
||||
|
||||
shown, body := settingsShown(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
MetricsPassword: metricsPassword,
|
||||
})
|
||||
|
||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
||||
assert.NotContains(t, body, metricsPassword)
|
||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
sentryKey = "dsnkey7c2e"
|
||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||
)
|
||||
|
||||
shown, body := settingsShown(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
SentryDSN: sentryDSN,
|
||||
})
|
||||
|
||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
||||
assert.NotContains(t, body, sentryKey)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
@@ -80,10 +79,6 @@ func seedTarget(
|
||||
// from a delete statement.
|
||||
var errInjectedDelete = errors.New("injected delete failure")
|
||||
|
||||
// errInjectedSave is the failure failSaveOnTable reports from a
|
||||
// save of an existing row.
|
||||
var errInjectedSave = errors.New("injected save failure")
|
||||
|
||||
// seedEntrypoint inserts an entrypoint for a webhook.
|
||||
func seedEntrypoint(
|
||||
t *testing.T,
|
||||
@@ -151,42 +146,19 @@ func failDeleteOnTable(
|
||||
)
|
||||
}
|
||||
|
||||
// failSaveOnTable is failDeleteOnTable for saves: every update of
|
||||
// an existing row in the named table fails.
|
||||
func failSaveOnTable(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
table string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, db.DB().Callback().Update().
|
||||
Before("gorm:update").
|
||||
Register(
|
||||
"test:fail_save_"+table,
|
||||
func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == table {
|
||||
_ = tx.AddError(errInjectedSave)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// archivePathFor returns the archive database path the
|
||||
// delivery engine would use for a database target: beside the
|
||||
// webhook's event database in the data directory.
|
||||
// delivery engine would use for a webhook: beside the webhook's
|
||||
// event database in the data directory.
|
||||
func archivePathFor(
|
||||
t *testing.T,
|
||||
mgr *database.WebhookDBManager,
|
||||
wh *database.Webhook,
|
||||
tgt *database.Target,
|
||||
webhookID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
return filepath.Join(
|
||||
filepath.Dir(mgr.DBPath(wh.ID)),
|
||||
delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
|
||||
filepath.Dir(mgr.DBPath(webhookID)),
|
||||
"archive-"+webhookID+".db",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,8 +195,8 @@ func postRequest(
|
||||
|
||||
// TestHandleSourceDelete_EvictsArchiveWriter proves that
|
||||
// deleting a webhook reaches the delivery engine and releases
|
||||
// the webhook's archive writers, exercised through the real
|
||||
// deletion handler rather than by calling the engine directly.
|
||||
// the webhook's archive writer, exercised through the real
|
||||
// deletion handler rather than by calling the evictor directly.
|
||||
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -232,7 +204,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
ev *recordingArchives
|
||||
ev *recordingEvictor
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||
@@ -282,10 +254,9 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
// Place an archive file where the delivery engine would.
|
||||
archivePath := archivePathFor(t, mgr, wh, tgt)
|
||||
archivePath := archivePathFor(t, mgr, wh.ID)
|
||||
require.NoError(
|
||||
t,
|
||||
writeArchivePlaceholder(archivePath),
|
||||
@@ -440,9 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
@@ -466,17 +435,68 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
||||
// database target releases that target's archive writer and no
|
||||
// other: the webhook's other database target keeps its own.
|
||||
func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
|
||||
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
||||
// proves that removing the last database target releases the
|
||||
// archive writer.
|
||||
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
ev *recordingArchives
|
||||
ev *recordingEvictor
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
tgt := seedTarget(
|
||||
t, db, wh.ID, database.TargetTypeDatabase,
|
||||
)
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
paramTargetID: tgt.ID,
|
||||
},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleTargetDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, []string{wh.ID}, ev.Evicted(),
|
||||
"removing the last database target should evict",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
|
||||
// proves that deleting one of several database targets leaves
|
||||
// the still-needed archive writer alone: the surviving target
|
||||
// keeps archiving to the same file, so the writer must stay.
|
||||
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
ev *recordingEvictor
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||
@@ -507,17 +527,17 @@ func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
|
||||
h.HandleTargetDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, []string{doomed.ID}, ev.EvictedTargets(),
|
||||
"deleting a database target should evict its writer",
|
||||
assert.Empty(
|
||||
t, ev.Evicted(),
|
||||
"a second database target still needs the writer",
|
||||
)
|
||||
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
|
||||
}
|
||||
|
||||
// TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
|
||||
// a target id from the URL that is not a target of the webhook
|
||||
// deletes nothing and so evicts nothing.
|
||||
func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
||||
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves
|
||||
// that deleting a target of an unrelated type leaves a
|
||||
// still-needed archive writer alone: the webhook's database
|
||||
// target is untouched, so its writer must stay.
|
||||
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -526,7 +546,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
ev *recordingArchives
|
||||
ev *recordingEvictor
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &ev)
|
||||
@@ -535,20 +555,19 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
elsewhere := seedTarget(
|
||||
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
|
||||
)
|
||||
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
paramTargetID: elsewhere.ID,
|
||||
paramTargetID: other.ID,
|
||||
},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -557,7 +576,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Empty(
|
||||
t, ev.EvictedTargets(),
|
||||
"another webhook's target must not be evicted",
|
||||
t, ev.Evicted(),
|
||||
"a surviving database target must keep its writer",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,10 +244,10 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
||||
|
||||
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
|
||||
// page's maximum width at 108rem (1728 px), half again the 72rem of
|
||||
// max-w-6xl that the webhook list and the event log use, so an
|
||||
// entrypoint URL fits on one line in a 1920-pixel window; and the
|
||||
// wrapping of its title row, so the buttons beside the title do not
|
||||
// push a phone-width window into scrolling sideways.
|
||||
// max-w-6xl that the other pages use, so an entrypoint URL fits on
|
||||
// one line in a 1920-pixel window; and the wrapping of its title
|
||||
// row, so the buttons beside the title do not push a phone-width
|
||||
// window into scrolling sideways.
|
||||
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// failedHighlight is how the list marks a number of failed deliveries
|
||||
// that is not zero.
|
||||
const failedHighlight = `class="font-medium text-red-600"`
|
||||
|
||||
// listWebhook adds a webhook with the given name, owned by the test
|
||||
// user.
|
||||
func listWebhook(
|
||||
t *testing.T, db *database.Database, name string,
|
||||
) *database.Webhook {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
return wh
|
||||
}
|
||||
|
||||
// addEntrypoints adds the given number of entrypoints, all active or
|
||||
// all inactive, to a webhook and returns their paths.
|
||||
func addEntrypoints(
|
||||
t *testing.T, db *database.Database, webhookID string,
|
||||
count int, active bool,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
paths := make([]string, count)
|
||||
for i := range paths {
|
||||
paths[i] = statsEntrypoint(t, db, webhookID, active)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// addTargets adds the given number of targets, all active or all
|
||||
// inactive, to a webhook and returns them.
|
||||
func addTargets(
|
||||
t *testing.T, db *database.Database, webhookID string,
|
||||
count int, active bool,
|
||||
) []*database.Target {
|
||||
t.Helper()
|
||||
|
||||
targets := make([]*database.Target, count)
|
||||
for i := range targets {
|
||||
targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog)
|
||||
require.NoError(t, db.DB().Model(targets[i]).
|
||||
Update("active", active).Error)
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
// renderWebhookList runs the real webhook list handler as the test user
|
||||
// and returns the rendered page.
|
||||
func renderWebhookList(
|
||||
t *testing.T, h *handlers.Handlers, sess *session.Session,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSourceList().ServeHTTP(
|
||||
w, getRequest(t, "/hooks", cookies, nil),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// listCard returns one webhook's entry in a rendered webhook list, its
|
||||
// markup as rendered and its text with the markup taken out and each
|
||||
// run of space made one space.
|
||||
func listCard(t *testing.T, page, webhookID string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
|
||||
require.True(t, found, "the list has no entry for %s", webhookID)
|
||||
|
||||
card, _, _ = strings.Cut(card, "</a>")
|
||||
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
|
||||
|
||||
return card, strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
// receiveEvents posts the given number of events to an entrypoint
|
||||
// through the real receiver, and returns the webhook's event database
|
||||
// and its events, oldest first.
|
||||
func receiveEvents(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
webhookID, path string,
|
||||
count int,
|
||||
) (*gorm.DB, []database.Event) {
|
||||
t.Helper()
|
||||
|
||||
router := receiverRouter(h)
|
||||
|
||||
for range count {
|
||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||
}
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
events := listEvents(t, webhookDB)
|
||||
require.Len(t, events, count)
|
||||
|
||||
return webhookDB, events
|
||||
}
|
||||
|
||||
// seedFailingWebhook adds a webhook with six entrypoints, two of them
|
||||
// inactive, and seven targets, five of them inactive. Four events reach
|
||||
// its two active targets, arriving 31, 5, 4 and 3 hours ago, and its
|
||||
// event totals row records the last one. Three deliveries failed in the
|
||||
// last 24 hours, two to the first target and one to the second, one
|
||||
// failed 30 hours ago, two were delivered, and two are still pending.
|
||||
// It returns the webhook and when its last event arrived.
|
||||
func seedFailingWebhook(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
) (*database.Webhook, time.Time) {
|
||||
t.Helper()
|
||||
|
||||
wh := listWebhook(t, db, "failing")
|
||||
paths := addEntrypoints(t, db, wh.ID, 4, true)
|
||||
addEntrypoints(t, db, wh.ID, 2, false)
|
||||
|
||||
active := addTargets(t, db, wh.ID, 2, true)
|
||||
first, second := active[0], active[1]
|
||||
|
||||
addTargets(t, db, wh.ID, 5, false)
|
||||
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4)
|
||||
now := time.Now()
|
||||
lastEventAt := now.Add(-3 * time.Hour)
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour))
|
||||
statsAge(t, webhookDB, events[1].ID, now.Add(-5*time.Hour))
|
||||
statsAge(t, webhookDB, events[2].ID, now.Add(-4*time.Hour))
|
||||
statsAge(t, webhookDB, events[3].ID, lastEventAt)
|
||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
||||
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[0].ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[0].ID, second.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-30*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[1].ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[2].ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[2].ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[3].ID, second.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-time.Minute))
|
||||
|
||||
return wh, lastEventAt
|
||||
}
|
||||
|
||||
// seedHealthyWebhook adds a webhook with four entrypoints and two
|
||||
// targets, all active, and three events, arriving 8, 7 and 6 hours ago
|
||||
// and each delivered to both targets. Its event totals row records the
|
||||
// last event. It returns the webhook and when its last event arrived.
|
||||
func seedHealthyWebhook(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
) (*database.Webhook, time.Time) {
|
||||
t.Helper()
|
||||
|
||||
wh := listWebhook(t, db, "healthy")
|
||||
paths := addEntrypoints(t, db, wh.ID, 4, true)
|
||||
targets := addTargets(t, db, wh.ID, 2, true)
|
||||
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
|
||||
now := time.Now()
|
||||
lastEventAt := now.Add(-6 * time.Hour)
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, now.Add(-8*time.Hour))
|
||||
statsAge(t, webhookDB, events[1].ID, now.Add(-7*time.Hour))
|
||||
statsAge(t, webhookDB, events[2].ID, lastEventAt)
|
||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
||||
|
||||
for _, ev := range events {
|
||||
for _, target := range targets {
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, ev.ID, target.ID),
|
||||
database.DeliveryStatusDelivered, now)
|
||||
}
|
||||
}
|
||||
|
||||
return wh, lastEventAt
|
||||
}
|
||||
|
||||
// lastEventText is how the list shows when the last event arrived.
|
||||
func lastEventText(at time.Time) string {
|
||||
return at.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
|
||||
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
|
||||
// shows for a webhook with recent failures, a healthy one, a new one
|
||||
// that has received no event, and one without an event database.
|
||||
func TestSourceList_ShowsActivityOfEachWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
failing, failingLastEvent := seedFailingWebhook(t, h, db, dbMgr)
|
||||
healthy, healthyLastEvent := seedHealthyWebhook(t, h, db, dbMgr)
|
||||
|
||||
// Creating a webhook creates its event database.
|
||||
fresh := listWebhook(t, db, "fresh")
|
||||
require.NoError(t, dbMgr.CreateDB(fresh.ID))
|
||||
addEntrypoints(t, db, fresh.ID, 2, true)
|
||||
addTargets(t, db, fresh.ID, 3, true)
|
||||
|
||||
quiet := listWebhook(t, db, "quiet")
|
||||
addEntrypoints(t, db, quiet.ID, 2, true)
|
||||
addTargets(t, db, quiet.ID, 3, true)
|
||||
|
||||
page := renderWebhookList(t, h, sess)
|
||||
|
||||
card, text := listCard(t, page, failing.ID)
|
||||
assert.Contains(t, text, "6 entrypoints, 2 inactive")
|
||||
assert.Contains(t, text, "7 targets, 5 inactive")
|
||||
assert.Contains(t, text, "4 events within retention")
|
||||
assert.Contains(t, text, "Last event "+lastEventText(failingLastEvent))
|
||||
assert.Contains(t, card,
|
||||
failedHighlight+">3 failed deliveries in the last 24 hours<")
|
||||
|
||||
card, text = listCard(t, page, healthy.ID)
|
||||
assert.Contains(t, text, "4 entrypoints")
|
||||
assert.Contains(t, text, "2 targets")
|
||||
assert.Contains(t, text, "3 events within retention")
|
||||
assert.Contains(t, text, "Last event "+lastEventText(healthyLastEvent))
|
||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
||||
assert.NotContains(t, text, "inactive")
|
||||
assert.NotContains(t, card, failedHighlight)
|
||||
|
||||
card, text = listCard(t, page, fresh.ID)
|
||||
assert.Contains(t, text, "2 entrypoints")
|
||||
assert.Contains(t, text, "3 targets")
|
||||
assert.Contains(t, text, "0 events within retention")
|
||||
assert.Contains(t, text, "No events yet")
|
||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
||||
assert.NotContains(t, card, failedHighlight)
|
||||
|
||||
card, text = listCard(t, page, quiet.ID)
|
||||
assert.Contains(t, text, "2 entrypoints")
|
||||
assert.Contains(t, text, "3 targets")
|
||||
assert.Contains(t, text, "0 events within retention")
|
||||
assert.Contains(t, text, "No events yet")
|
||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
||||
assert.NotContains(t, card, failedHighlight)
|
||||
assert.False(t, dbMgr.DBExists(quiet.ID),
|
||||
"showing the list must not create an event database")
|
||||
}
|
||||
|
||||
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
|
||||
// retention has removed one of a webhook's three events, the list
|
||||
// counts the two still stored.
|
||||
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
paths := addEntrypoints(t, db, wh.ID, 3, true)
|
||||
addTargets(t, db, wh.ID, 4, true)
|
||||
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
|
||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||
require.Len(t, listEvents(t, webhookDB), 2)
|
||||
|
||||
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
||||
assert.Contains(t, text, "3 entrypoints")
|
||||
assert.Contains(t, text, "4 targets")
|
||||
assert.Contains(t, text, "2 events within retention")
|
||||
}
|
||||
|
||||
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
|
||||
// retention has removed every event of a webhook, the list still shows
|
||||
// when the last one arrived rather than "No events yet".
|
||||
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
paths := addEntrypoints(t, db, wh.ID, 2, true)
|
||||
addTargets(t, db, wh.ID, 3, true)
|
||||
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
|
||||
lastEventAt := time.Now().Add(-50 * time.Hour)
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, lastEventAt)
|
||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||
require.Empty(t, listEvents(t, webhookDB))
|
||||
|
||||
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
||||
assert.Contains(t, text, "0 events within retention")
|
||||
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
|
||||
assert.NotContains(t, text, "No events yet")
|
||||
}
|
||||
|
||||
// TestSourceList_CountsOfOneInSingular checks that a webhook with one
|
||||
// entrypoint, one target, one event within retention and one failed
|
||||
// delivery in the last 24 hours has each written in the singular.
|
||||
func TestSourceList_CountsOfOneInSingular(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := listWebhook(t, db, "single")
|
||||
paths := addEntrypoints(t, db, wh.ID, 1, true)
|
||||
targets := addTargets(t, db, wh.ID, 1, true)
|
||||
|
||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
|
||||
now := time.Now()
|
||||
lastEventAt := now.Add(-9 * time.Hour)
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, lastEventAt)
|
||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, events[0].ID, targets[0].ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Hour))
|
||||
|
||||
card, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
||||
assert.Contains(t, card, ">1 entrypoint<")
|
||||
assert.Contains(t, card, ">1 target<")
|
||||
assert.Contains(t, card, ">1 event within retention<")
|
||||
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
|
||||
assert.Contains(t, card,
|
||||
failedHighlight+">1 failed delivery in the last 24 hours<")
|
||||
}
|
||||
|
||||
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
|
||||
// event database cannot be read says so in its entry instead of
|
||||
// showing zeros, and that the rest of the list is still shown.
|
||||
func TestSourceList_UnreadableEventDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
broken := listWebhook(t, db, "broken")
|
||||
addEntrypoints(t, db, broken.ID, 2, true)
|
||||
addTargets(t, db, broken.ID, 3, true)
|
||||
|
||||
brokenDB, err := dbMgr.GetDB(broken.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t,
|
||||
brokenDB.Migrator().DropTable(&database.EventTotals{}))
|
||||
|
||||
quiet := listWebhook(t, db, "quiet")
|
||||
addEntrypoints(t, db, quiet.ID, 2, true)
|
||||
addTargets(t, db, quiet.ID, 3, true)
|
||||
|
||||
page := renderWebhookList(t, h, sess)
|
||||
|
||||
_, text := listCard(t, page, broken.ID)
|
||||
assert.Contains(t, text, "2 entrypoints")
|
||||
assert.Contains(t, text, "3 targets")
|
||||
assert.Contains(t, text, "The event figures could not be read.")
|
||||
assert.NotContains(t, text, "events")
|
||||
assert.NotContains(t, text, "failed")
|
||||
|
||||
_, text = listCard(t, page, quiet.ID)
|
||||
assert.Contains(t, text, "No events yet")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,16 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
@@ -191,7 +187,6 @@ func storedRetentionDays(
|
||||
type sourceTestEnv struct {
|
||||
handlers *handlers.Handlers
|
||||
db *database.Database
|
||||
archives *recordingArchives
|
||||
cookies []*http.Cookie
|
||||
}
|
||||
|
||||
@@ -204,9 +199,7 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
|
||||
var db *database.Database
|
||||
|
||||
var archives *recordingArchives
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &archives)
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
@@ -214,7 +207,6 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
||||
return &sourceTestEnv{
|
||||
handlers: h,
|
||||
db: db,
|
||||
archives: archives,
|
||||
cookies: authenticatedCookies(
|
||||
t, sess, sourceTestUserID, "sourceuser",
|
||||
),
|
||||
@@ -368,42 +360,31 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
|
||||
// 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.
|
||||
// out of range.
|
||||
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, days := range []int{
|
||||
env := setupSourceTest(t)
|
||||
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||
|
||||
w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
wh := onlyWebhook(t, env.db)
|
||||
assert.Equal(
|
||||
t,
|
||||
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),
|
||||
)
|
||||
})
|
||||
}
|
||||
storedRetentionDays(t, env.db, wh.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
||||
// validation failure hands the user's typing back, matching what the
|
||||
// edit form already does. Losing a long description to a mistyped
|
||||
// retention value is the kind of thing that makes people give up on a
|
||||
// form. Both values carry HTML-special characters, which must come
|
||||
// back escaped rather than as markup.
|
||||
// form.
|
||||
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -412,8 +393,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
env := setupSourceTest(t)
|
||||
|
||||
const (
|
||||
name = `kept"><b>name`
|
||||
description = `a </textarea> worth not losing`
|
||||
name = "kept-name"
|
||||
description = "a description worth not losing"
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
@@ -430,10 +411,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(t, body, `value="kept"><b>name"`)
|
||||
assert.Contains(t, body, `a </textarea> worth not losing`)
|
||||
assert.NotContains(t, body, name)
|
||||
assert.NotContains(t, body, description)
|
||||
assert.Contains(t, body, `value="`+name+`"`)
|
||||
assert.Contains(t, body, description)
|
||||
}
|
||||
|
||||
// submitEdit posts the webhook edit form for the given webhook.
|
||||
@@ -519,301 +498,6 @@ func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
|
||||
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
|
||||
}
|
||||
|
||||
// renamedWebhookName is the name the rename tests give a webhook.
|
||||
const renamedWebhookName = "Renamed"
|
||||
|
||||
// TestHandleSourceEditSubmit_RenamesArchives proves that a save
|
||||
// that keeps the webhook's name renames nothing, and that renaming a
|
||||
// webhook renames the archive of each of its database targets and
|
||||
// asks nothing of its other targets.
|
||||
func TestHandleSourceEditSubmit_RenamesArchives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Empty(t, env.archives.Renames())
|
||||
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w = submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{first.ID, renamedWebhookName, first.Name},
|
||||
{second.ID, renamedWebhookName, second.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_FailedRenameKeepsTheName proves that a
|
||||
// webhook whose archive cannot be renamed keeps its stored name, so
|
||||
// the name on disk and the name in the UI do not part, and that the
|
||||
// handler puts back what it may already have moved.
|
||||
func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
env.archives.FailRenames(tgt.ID, errInjectedRename)
|
||||
|
||||
oldName := wh.Name
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, oldName, stored.Name)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{tgt.ID, renamedWebhookName, tgt.Name},
|
||||
{tgt.ID, oldName, tgt.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_FailedSaveRenamesBack proves that when
|
||||
// the archive is renamed but the new name cannot be saved, the
|
||||
// archive is renamed back to the stored name and the stored name
|
||||
// stays.
|
||||
func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
failSaveOnTable(t, env.db, "webhooks")
|
||||
|
||||
oldName := wh.Name
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, oldName, stored.Name)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{tgt.ID, renamedWebhookName, tgt.Name},
|
||||
{tgt.ID, oldName, tgt.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
// errInjectedRead is the failure a test makes reads of the main
|
||||
// database report.
|
||||
var errInjectedRead = errors.New("injected read failure")
|
||||
|
||||
// TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading
|
||||
// proves that when the save fails and every later read of the main
|
||||
// database fails too, each archive the rename moved is still renamed
|
||||
// back: the move back needs no second read of the webhook's targets.
|
||||
func TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
var saveFailed atomic.Bool
|
||||
|
||||
require.NoError(t, env.db.DB().Callback().Update().
|
||||
Before("gorm:update").
|
||||
Register("test:fail_save", func(tx *gorm.DB) {
|
||||
saveFailed.Store(true)
|
||||
|
||||
_ = tx.AddError(errInjectedSave)
|
||||
}),
|
||||
)
|
||||
require.NoError(t, env.db.DB().Callback().Query().
|
||||
Before("gorm:query").
|
||||
Register("test:fail_reads_after_save", func(tx *gorm.DB) {
|
||||
if saveFailed.Load() {
|
||||
_ = tx.AddError(errInjectedRead)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
oldName := wh.Name
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{first.ID, renamedWebhookName, first.Name},
|
||||
{second.ID, renamedWebhookName, second.Name},
|
||||
{first.ID, oldName, first.Name},
|
||||
{second.ID, oldName, second.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_EditsDoNotInterleave proves that a second
|
||||
// webhook edit submitted while the first is inside its archive rename
|
||||
// does not run until the first is saved, so afterwards the stored
|
||||
// names are the ones the archive was last renamed to. The stand-in's
|
||||
// last rename is the name the file has on disk.
|
||||
func TestHandleSourceEditSubmit_EditsDoNotInterleave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
entered, release := env.archives.BlockNextRename()
|
||||
|
||||
firstEdit, secondEdit := wh, wh
|
||||
firstEdit.Name = "First"
|
||||
secondEdit.Name = "Second"
|
||||
|
||||
firstCode := make(chan int, 1)
|
||||
|
||||
go func() { firstCode <- submitEdit(t, env, firstEdit, "").Code }()
|
||||
|
||||
<-entered
|
||||
|
||||
secondCode := make(chan int, 1)
|
||||
|
||||
go func() { secondCode <- submitEdit(t, env, secondEdit, "").Code }()
|
||||
|
||||
// Were the edits not ordered, the second would run to its end in
|
||||
// this time, while the first is still inside its rename.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
release()
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, <-firstCode)
|
||||
assert.Equal(t, http.StatusSeeOther, <-secondCode)
|
||||
|
||||
var (
|
||||
storedWebhook database.Webhook
|
||||
storedTarget database.Target
|
||||
)
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&storedWebhook, "id = ?", wh.ID).Error,
|
||||
)
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&storedTarget, "id = ?", tgt.ID).Error,
|
||||
)
|
||||
|
||||
renames := env.archives.Renames()
|
||||
require.NotEmpty(t, renames)
|
||||
assert.Equal(
|
||||
t,
|
||||
archiveRename{tgt.ID, storedWebhook.Name, storedTarget.Name},
|
||||
renames[len(renames)-1],
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves
|
||||
// that when a webhook has three database targets and only the middle
|
||||
// one's archive cannot be renamed, the stored name stays and both
|
||||
// others are renamed back, the last one included: the move back does
|
||||
// not stop at the target it cannot rename. The handler reaches the
|
||||
// targets in the order they were created, which the exact sequence
|
||||
// below pins, so the refused target always comes before the last.
|
||||
func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
middle := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
last := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
env.archives.FailRenames(middle.ID, errNameTaken)
|
||||
|
||||
oldName := wh.Name
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, oldName, stored.Name)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{first.ID, renamedWebhookName, first.Name},
|
||||
{middle.ID, renamedWebhookName, middle.Name},
|
||||
{last.ID, renamedWebhookName, last.Name},
|
||||
{first.ID, oldName, first.Name},
|
||||
{middle.ID, oldName, middle.Name},
|
||||
{last.ID, oldName, last.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
|
||||
// already has an archive's new name, the edit is refused with an
|
||||
// error naming that file, and the webhook keeps its stored name.
|
||||
func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
env.archives.FailRenames(tgt.ID, errNameTaken)
|
||||
|
||||
oldName := wh.Name
|
||||
wh.Name = renamedWebhookName
|
||||
|
||||
w := submitEdit(t, env, wh, "")
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
require.NoError(
|
||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, oldName, stored.Name)
|
||||
}
|
||||
|
||||
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
|
||||
// the removed max="365" cap used to break: render the edit form for a
|
||||
// retain-forever webhook, confirm the pre-filled sentinel is not capped
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
@@ -80,9 +79,6 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||
// HandleTargetEditSubmit handles the target edit form submission.
|
||||
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.renameMu.Lock()
|
||||
defer h.renameMu.Unlock()
|
||||
|
||||
webhook, target, ok := h.ownedTarget(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -92,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -154,69 +152,21 @@ func (h *Handlers) applyTargetEdit(
|
||||
target.MaxRetries = retries
|
||||
}
|
||||
|
||||
oldName := target.Name
|
||||
target.Name = name
|
||||
target.Config = configJSON
|
||||
|
||||
// A new name renames the archive file before it is saved (see
|
||||
// delivery.Engine.Rename). If either step fails, it goes back to
|
||||
// the name that is still stored.
|
||||
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
|
||||
if err == nil {
|
||||
err = h.db.DB().Save(target).Error
|
||||
}
|
||||
|
||||
err = h.db.DB().Save(target).Error
|
||||
if err != nil {
|
||||
restoreErr := h.renameTargetArchive(
|
||||
target, webhook.Name, name, oldName,
|
||||
)
|
||||
if restoreErr != nil {
|
||||
h.log.Error(
|
||||
"failed to rename archive back",
|
||||
"target_id", target.ID,
|
||||
"error", restoreErr,
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||
http.Error(
|
||||
w,
|
||||
"Not saved: "+err.Error()+
|
||||
". Move that archive out of the data directory, "+
|
||||
"its .db together with any -wal and -shm beside "+
|
||||
"it, then save again.",
|
||||
http.StatusConflict,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.serverError(w, r, "failed to update target", err)
|
||||
h.serverError(w, "failed to update target", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
||||
http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// renameTargetArchive renames a database target's archive file from
|
||||
// the target name oldName to newName. It does nothing when the name
|
||||
// is unchanged; other target types have no archive.
|
||||
func (h *Handlers) renameTargetArchive(
|
||||
target *database.Target,
|
||||
webhookName, oldName, newName string,
|
||||
) error {
|
||||
if h.archives == nil || oldName == newName ||
|
||||
target.Type != database.TargetTypeDatabase {
|
||||
return nil
|
||||
}
|
||||
|
||||
return h.archives.Rename(target.ID, webhookName, newName)
|
||||
}
|
||||
|
||||
// renderTargetEdit renders the target edit page with an optional
|
||||
// error message.
|
||||
func (h *Handlers) renderTargetEdit(
|
||||
@@ -270,7 +220,7 @@ func (h *Handlers) ownedTarget(
|
||||
chi.URLParam(r, "targetID"), webhook.ID,
|
||||
).First(&target).Error
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return database.Webhook{}, nil, false
|
||||
}
|
||||
|
||||
@@ -635,100 +635,3 @@ func assertWebhookOfAnotherUser404s(
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// renamedTargetName is the name the rename tests give a target.
|
||||
const renamedTargetName = "Long Term"
|
||||
|
||||
// TestHandleTargetEditSubmit_RenamesArchive proves that renaming a
|
||||
// database target renames its archive, that a save that keeps the
|
||||
// name renames nothing, that a target of another type has no archive
|
||||
// to rename, and that a target whose archive cannot be renamed keeps
|
||||
// its stored name. When a file already has the archive's new name,
|
||||
// the edit is refused with an error naming that file.
|
||||
func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
rename := url.Values{"name": {renamedTargetName}}
|
||||
|
||||
w := submitTargetEdit(env, wh.ID, archive.ID, rename)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{{archive.ID, wh.Name, renamedTargetName}},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
|
||||
w = submitTargetEdit(env, wh.ID, archive.ID, rename)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
assert.Len(
|
||||
t, env.archives.Renames(), 1,
|
||||
"a save that keeps the name renames nothing",
|
||||
)
|
||||
|
||||
httpWebhook, httpTarget := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
w = submitTargetEdit(
|
||||
env, httpWebhook.ID, httpTarget.ID,
|
||||
editForm(editOriginalURL, "", ""),
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
assert.Len(
|
||||
t, env.archives.Renames(), 1,
|
||||
"an HTTP target has no archive to rename",
|
||||
)
|
||||
|
||||
again := url.Values{"name": {"Again"}}
|
||||
|
||||
env.archives.FailRenames(archive.ID, errInjectedRename)
|
||||
|
||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(
|
||||
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||
"a target whose archive was not renamed keeps its name",
|
||||
)
|
||||
|
||||
env.archives.FailRenames(archive.ID, errNameTaken)
|
||||
|
||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "archive-taken.db")
|
||||
assert.Equal(
|
||||
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetEditSubmit_FailedSaveRenamesBack proves that when a
|
||||
// database target's archive is renamed but the new name cannot be
|
||||
// saved, the archive is renamed back to the stored name and the
|
||||
// stored name stays.
|
||||
func TestHandleTargetEditSubmit_FailedSaveRenamesBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
failSaveOnTable(t, env.db, "targets")
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", renamedTargetName)
|
||||
|
||||
w := submitTargetEdit(env, wh.ID, archive.ID, form)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(
|
||||
t, archive.Name, storedTarget(t, env, archive.ID).Name,
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]archiveRename{
|
||||
{archive.ID, wh.Name, renamedTargetName},
|
||||
{archive.ID, wh.Name, archive.Name},
|
||||
},
|
||||
env.archives.Renames(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
|
||||
// loaded the target before an edit of it was saved does not write the
|
||||
// old name and settings back over the edit. The edit is submitted from
|
||||
// a callback on the toggle's own read of the target, so it is saved
|
||||
// after that read and before the toggle writes.
|
||||
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
wh, tgt := seedHTTPTarget(t, env, "", "")
|
||||
require.True(t, tgt.Active)
|
||||
|
||||
var (
|
||||
edited bool
|
||||
editCode int
|
||||
)
|
||||
|
||||
require.NoError(t, env.db.DB().Callback().Query().
|
||||
After("gorm:query").
|
||||
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
|
||||
// The edit reads the target too; only the toggle's read,
|
||||
// the first, submits it.
|
||||
if tx.Statement.Table != "targets" || edited {
|
||||
return
|
||||
}
|
||||
|
||||
edited = true
|
||||
editCode = submitTargetEdit(
|
||||
env, wh.ID, tgt.ID,
|
||||
editForm(editReplacedURL, "", ""),
|
||||
).Code
|
||||
}),
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
|
||||
env.cookies,
|
||||
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, http.StatusSeeOther, editCode)
|
||||
|
||||
stored := storedTarget(t, env, tgt.ID)
|
||||
assert.False(t, stored.Active)
|
||||
assert.Equal(t, "edited-name", stored.Name)
|
||||
assert.Equal(t, 5, stored.MaxRetries)
|
||||
assert.Equal(
|
||||
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -58,8 +57,7 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
h.log.Info("webhook request received",
|
||||
"entrypoint_uuid", entrypointUUID,
|
||||
"method", r.Method,
|
||||
"remoteIP", middleware.RemoteIP(r),
|
||||
"clientIP", middleware.ClientIP(r),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
if !entrypoint.Active {
|
||||
@@ -90,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
||||
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
if err != nil {
|
||||
h.receiverError(w, "failed to serialize headers", err)
|
||||
h.serverError(w, "failed to serialize headers", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
h.receiverError(w, "failed to query targets", err)
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -152,9 +150,7 @@ func (h *Handlers) lookupEntrypoint(
|
||||
return entrypoint, true
|
||||
}
|
||||
|
||||
// readWebhookBody reads and validates the request body size. This is
|
||||
// the receiver's only body cap: /h/{uuid} has no MaxBodySize
|
||||
// middleware (see Server.setupWebhookRoutes).
|
||||
// readWebhookBody reads and validates the request body size.
|
||||
func (h *Handlers) readWebhookBody(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -200,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
||||
targets,
|
||||
)
|
||||
if err != nil {
|
||||
h.receiverError(w, "failed to store webhook event", err)
|
||||
h.serverError(w, "failed to store webhook event", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -208,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
|
||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||
}
|
||||
|
||||
// receiverError logs an error and answers the sender with a plain-text
|
||||
// 500. The receiver's answers are for programs, so it never sends the
|
||||
// error page the web UI uses.
|
||||
func (h *Handlers) receiverError(
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
) {
|
||||
h.log.Error(msg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// eventSource carries the fields a new event is built from. The
|
||||
// receiver fills it from the live request; the resubmit handler fills
|
||||
// it from a stored event. Both then go through createAndFanOut, so an
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
||||
// receiver's "webhook request received" line carries both addresses:
|
||||
// remoteIP, the connecting peer, and clientIP, the client the access
|
||||
// log attributes the request to.
|
||||
func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// untrustedPeer is outside the trusted 10.0.0.0/8, so its
|
||||
// X-Forwarded-For is ignored and it is the client.
|
||||
const untrustedPeer = "192.0.2.10"
|
||||
|
||||
cases := map[string]struct {
|
||||
peer string
|
||||
wantRemote string
|
||||
wantClient string
|
||||
}{
|
||||
"trusted proxy with a forwarded chain": {
|
||||
peer: "10.0.0.1:44444",
|
||||
wantRemote: "10.0.0.1",
|
||||
wantClient: "198.51.100.7",
|
||||
},
|
||||
"untrusted peer": {
|
||||
peer: untrustedPeer + ":5555",
|
||||
wantRemote: untrustedPeer,
|
||||
wantClient: untrustedPeer,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
mw *middleware.Middleware
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestAppWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
TrustedProxies: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
},
|
||||
}, &h, &mw, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
h.SetLogForTest(slog.New(slog.NewJSONHandler(buf, nil)))
|
||||
|
||||
webhook := seedWebhook(t, db)
|
||||
seedEntrypoint(t, db, webhook.ID)
|
||||
|
||||
// Logging is what works the client address out, so the
|
||||
// request goes through it as it does in production.
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.Logging())
|
||||
router.Post("/h/{uuid}", h.HandleWebhook())
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost,
|
||||
"/h/ep-"+webhook.ID, strings.NewReader("{}"),
|
||||
)
|
||||
req.RemoteAddr = tc.peer
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.2")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
line := receivedLine(t, buf)
|
||||
assert.Equal(t, tc.wantRemote, line["remoteIP"])
|
||||
assert.Equal(t, tc.wantClient, line["clientIP"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// receivedLine returns the one "webhook request received" line in the
|
||||
// captured JSON log.
|
||||
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var found []map[string]any
|
||||
|
||||
for line := range strings.SplitSeq(
|
||||
strings.TrimSpace(buf.String()), "\n",
|
||||
) {
|
||||
var entry map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(line), &entry))
|
||||
|
||||
if entry["msg"] == "webhook request received" {
|
||||
found = append(found, entry)
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, found, 1)
|
||||
|
||||
return found[0]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
@@ -17,6 +18,7 @@ type HealthcheckParams struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
||||
UptimeHuman: s.uptime().String(),
|
||||
Appname: s.params.Globals.Appname,
|
||||
Version: s.params.Globals.Version,
|
||||
Maintenance: s.params.Config.MaintenanceMode,
|
||||
}
|
||||
|
||||
return resp
|
||||
@@ -75,6 +78,7 @@ type Response struct {
|
||||
UptimeHuman string `json:"uptimeHuman"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenanceMode"`
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
|
||||
+20
-28
@@ -3,18 +3,17 @@
|
||||
// deliveries are attempted, how they end, how long they take, how
|
||||
// deep the queues are, and how many circuit breakers are open.
|
||||
//
|
||||
// It also builds the registry the authenticated /metrics route
|
||||
// serves. In production, these collectors, the inbound HTTP metrics
|
||||
// recorded in internal/middleware, and the Go runtime and process
|
||||
// collectors all register on that one registry, never on Prometheus's
|
||||
// global default.
|
||||
// The inbound HTTP metrics come from the go-http-metrics recorder in
|
||||
// internal/middleware and land on prometheus.DefaultRegisterer. These
|
||||
// collectors register there too, so both surfaces are gathered by the
|
||||
// one promhttp handler mounted on the authenticated /metrics route.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
@@ -58,31 +57,25 @@ var knownTargetTypes = []database.TargetType{
|
||||
database.TargetTypeSlack,
|
||||
}
|
||||
|
||||
// NewRegistry returns the registry /metrics serves, carrying the Go
|
||||
// runtime and process collectors that Prometheus's global default
|
||||
// registry carries, so the go_* and process_* series stay in the
|
||||
// scrape.
|
||||
// defaultSet is the process-wide metric set, registered on the same
|
||||
// registry the HTTP middleware and the /metrics handler already use.
|
||||
// It is built on first use rather than in an init so that a test
|
||||
// binary that never touches metrics never registers them.
|
||||
//
|
||||
// A registry of its own, rather than the global default, is what lets
|
||||
// two dependency graphs in one process — two tests, say — each
|
||||
// register their collectors without the second registration
|
||||
// panicking.
|
||||
func NewRegistry() *prometheus.Registry {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(
|
||||
collectors.ProcessCollectorOpts{},
|
||||
),
|
||||
)
|
||||
//nolint:gochecknoglobals // one process-wide registration, by design
|
||||
var defaultSet = sync.OnceValue(func() *Set {
|
||||
return New(prometheus.DefaultRegisterer)
|
||||
})
|
||||
|
||||
return reg
|
||||
// Default returns the process-wide metric set.
|
||||
func Default() *Set {
|
||||
return defaultSet()
|
||||
}
|
||||
|
||||
// Set is one registered group of webhooker's delivery collectors.
|
||||
// Production builds one on the registry /metrics serves; tests build
|
||||
// one on a registry of their own so they can gather what their own
|
||||
// deliveries recorded.
|
||||
// Production uses the single Default set; tests build their own
|
||||
// against a private registry so assertions are not disturbed by
|
||||
// deliveries other tests are making concurrently.
|
||||
type Set struct {
|
||||
eventsReceived prometheus.Counter
|
||||
deliveryAttempts *prometheus.CounterVec
|
||||
@@ -100,7 +93,7 @@ type Set struct {
|
||||
// New registers a full set of delivery collectors on reg and returns
|
||||
// it. It panics if reg already holds them, which is the intended
|
||||
// behaviour for a duplicate registration.
|
||||
func New(reg *prometheus.Registry) *Set {
|
||||
func New(reg prometheus.Registerer) *Set {
|
||||
factory := promauto.With(reg)
|
||||
|
||||
s := &Set{
|
||||
@@ -384,7 +377,6 @@ func (s *Set) initSeries() {
|
||||
s.deliveriesFailed.WithLabelValues(label)
|
||||
s.deliveryRetries.WithLabelValues(label)
|
||||
s.deliveryReplays.WithLabelValues(label)
|
||||
s.deliveryDuration.WithLabelValues(label)
|
||||
s.deliveriesPending.WithLabelValues(label)
|
||||
s.deliveriesRetrying.WithLabelValues(label)
|
||||
s.circuitBreakersOpen.WithLabelValues(label)
|
||||
|
||||
@@ -167,7 +167,6 @@ func TestKnownSeriesExistBeforeAnyDelivery(t *testing.T) {
|
||||
"webhooker_deliveries_succeeded_total",
|
||||
"webhooker_deliveries_failed_total",
|
||||
"webhooker_delivery_retries_total",
|
||||
"webhooker_delivery_duration_seconds",
|
||||
"webhooker_circuit_breakers_open",
|
||||
} {
|
||||
assert.ElementsMatch(t,
|
||||
|
||||
@@ -63,12 +63,6 @@ const (
|
||||
// capturingMiddleware returns a Middleware whose logger writes JSON
|
||||
// lines into the returned buffer, so the access log can be asserted
|
||||
// on directly.
|
||||
//
|
||||
// It trusts 192.0.2.1, the peer address httptest.NewRequestWithContext
|
||||
// gives a request, as a proxy, the way a deployment trusts its reverse
|
||||
// proxy: a request built that way and carrying X-Forwarded-For is
|
||||
// logged with the client that header names as clientIP, and one
|
||||
// without it with the peer.
|
||||
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
@@ -78,10 +72,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
||||
}
|
||||
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||
|
||||
return middleware.NewForTest(log, cfg, nil), buf
|
||||
}
|
||||
@@ -90,7 +81,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||
// internal/logger can select: slog's text handler, which
|
||||
// internal/logger/logger.go installs when stderr is a tty. It escapes
|
||||
// differently from the JSON one, so the line bound has to be asserted
|
||||
// against both. It trusts the same peer.
|
||||
// against both.
|
||||
func capturingTextMiddleware(
|
||||
t *testing.T,
|
||||
) (*middleware.Middleware, *bytes.Buffer) {
|
||||
@@ -102,10 +93,7 @@ func capturingTextMiddleware(
|
||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
||||
}
|
||||
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||
|
||||
return middleware.NewForTest(log, cfg, nil), buf
|
||||
}
|
||||
@@ -346,12 +334,11 @@ func oversizedHeaders(value string) map[string]string {
|
||||
// sizeCase is one way of pointing 8 KB of client-chosen text at the
|
||||
// access log.
|
||||
type sizeCase struct {
|
||||
target string
|
||||
headers map[string]string
|
||||
wantStatus int
|
||||
wantURL string
|
||||
wantClientIP string
|
||||
bound int
|
||||
target string
|
||||
headers map[string]string
|
||||
wantStatus int
|
||||
wantURL string
|
||||
bound int
|
||||
}
|
||||
|
||||
// lineSizeCases enumerates every part of a request that reaches the
|
||||
@@ -388,7 +375,8 @@ func lineSizeCases() map[string]sizeCase {
|
||||
}
|
||||
|
||||
// The url field on a 5xx keeps the concrete path, so it reaches its
|
||||
// own budget on the same line as the three header fields.
|
||||
// own budget on the same line as the three header fields. That is
|
||||
// the widest access log line the service can be made to write.
|
||||
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
|
||||
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
|
||||
|
||||
@@ -432,29 +420,6 @@ func lineSizeCases() map[string]sizeCase {
|
||||
}
|
||||
}
|
||||
|
||||
// From a trusted proxy, clientIP is read out of X-Forwarded-For,
|
||||
// which the client writes. What bounds the field is that only one
|
||||
// address from the header is written, and it is written parsed, with
|
||||
// no zone. An IPv6 address with all eight groups at four digits is
|
||||
// the longest such address; here it carries an 8 KB zone, which must
|
||||
// not reach the line. It goes on the 5xx line with all three header
|
||||
// fields at their budget.
|
||||
const longestIPv6 = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
|
||||
forwarded := oversizedHeaders(oversizedValue("h"))
|
||||
forwarded[headerXFF] = oversizedValue("h") + ", " +
|
||||
longestIPv6 + "%" + oversizedValue("h")
|
||||
|
||||
cases["oversized X-Forwarded-For from a trusted proxy "+
|
||||
"with a 5xx concrete url"] = sizeCase{
|
||||
target: longPath,
|
||||
headers: forwarded,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantURL: wantLongURL,
|
||||
wantClientIP: longestIPv6,
|
||||
bound: maxCappedLineBytes,
|
||||
}
|
||||
|
||||
return cases
|
||||
}
|
||||
|
||||
@@ -495,14 +460,6 @@ func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, tc.wantURL, entries[0]["url"])
|
||||
|
||||
// Set only by the X-Forwarded-For case, where it proves
|
||||
// the header was read rather than ignored.
|
||||
if tc.wantClientIP != "" {
|
||||
assert.Equal(
|
||||
t, tc.wantClientIP, entries[0]["clientIP"],
|
||||
)
|
||||
}
|
||||
|
||||
// The markers sit at the far end of the client-chosen
|
||||
// text, so their absence is what proves the redaction and
|
||||
// the truncation actually ran.
|
||||
@@ -691,8 +648,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
||||
|
||||
for _, key := range []string{
|
||||
"request_start", "method", "url", "useragent", "request_id",
|
||||
"referer", "proto", "remoteIP", "clientIP", "status",
|
||||
"latency_ms",
|
||||
"referer", "proto", "remoteIP", "status", "latency_ms",
|
||||
} {
|
||||
assert.Contains(t, entries[0], key)
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
// forwardedChain is the X-Forwarded-For a request arrives with:
|
||||
// the client, then a second proxy inside trustedProxyCIDR that the
|
||||
// request passed through before reaching trustedPeer.
|
||||
forwardedChain = clientIPv4 + ", 10.0.0.2"
|
||||
|
||||
// untrustedPeer is a peer outside trustedProxyCIDR, so its
|
||||
// X-Forwarded-For is ignored and the peer is the client.
|
||||
untrustedPeer = "192.0.2.10:5555"
|
||||
|
||||
// oneRequestPerMinute is the receiver limit these tests install:
|
||||
// the second request on a path is rejected, and the aggregate
|
||||
// limit is ReceiverAggregateMultiplierConst.
|
||||
oneRequestPerMinute = 1
|
||||
)
|
||||
|
||||
// clientLogSite is one log line that names the client. build wraps the
|
||||
// middleware that writes it around a handler, and requests is how many
|
||||
// identical requests it takes before the line is written.
|
||||
type clientLogSite struct {
|
||||
build func(m *middleware.Middleware) http.Handler
|
||||
requests int
|
||||
}
|
||||
|
||||
// clientLogSites maps the message of each line that names the client
|
||||
// to the way to make it be written.
|
||||
func clientLogSites() map[string]clientLogSite {
|
||||
served := func(*middleware.Middleware) http.Handler {
|
||||
return okHandler()
|
||||
}
|
||||
|
||||
receiver := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ReceiverRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
login := func(m *middleware.Middleware) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
m.RecordLoginFailure(r, "someone")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
csrf := func(m *middleware.Middleware) http.Handler {
|
||||
return m.CSRF(http.HandlerFunc(forbidden))(okHandler())
|
||||
}
|
||||
|
||||
passwordChange := func(m *middleware.Middleware) http.Handler {
|
||||
return m.PasswordChangeRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
replay := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ReplayRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
resubmit := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ResubmitRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
return map[string]clientLogSite{
|
||||
"http request": {
|
||||
build: served,
|
||||
requests: 1,
|
||||
},
|
||||
"webhook receiver rate limit exceeded": {
|
||||
build: receiver,
|
||||
requests: oneRequestPerMinute + 1,
|
||||
},
|
||||
// The aggregate limit sits in front of the per-entrypoint
|
||||
// one, so the requests that one rejects count towards it.
|
||||
"webhook receiver aggregate rate limit exceeded": {
|
||||
build: receiver,
|
||||
requests: middleware.ReceiverAggregateMultiplierConst*
|
||||
oneRequestPerMinute + 1,
|
||||
},
|
||||
"login failure limit exceeded": {
|
||||
build: login,
|
||||
requests: middleware.LoginRateLimitConst + 1,
|
||||
},
|
||||
"csrf: token validation failed": {
|
||||
build: csrf,
|
||||
requests: 1,
|
||||
},
|
||||
"password change rate limit exceeded": {
|
||||
build: passwordChange,
|
||||
requests: middleware.PasswordChangeRateLimitConst + 1,
|
||||
},
|
||||
"delivery replay rate limit exceeded": {
|
||||
build: replay,
|
||||
requests: middleware.ReplayRateLimitConst + 1,
|
||||
},
|
||||
"event resubmit rate limit exceeded": {
|
||||
build: resubmit,
|
||||
requests: middleware.ResubmitRateLimitConst + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// clientLogLines sends the site's requests from peer, each carrying
|
||||
// forwardedChain, through Logging and then the site, as production
|
||||
// does, and returns the logged lines whose message is msg.
|
||||
func clientLogLines(
|
||||
t *testing.T, site clientLogSite, msg, peer string,
|
||||
) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
log := slog.New(slog.NewJSONHandler(
|
||||
buf,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: oneRequestPerMinute,
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
}
|
||||
|
||||
m := middleware.NewForTest(
|
||||
log, cfg, newTestSessionManager(cfg, log, nil),
|
||||
)
|
||||
handler := m.Logging()(site.build(m))
|
||||
|
||||
for range site.requests {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/h/x", nil,
|
||||
)
|
||||
req.RemoteAddr = peer
|
||||
req.Header.Set(headerXFF, forwardedChain)
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
var lines []map[string]any
|
||||
|
||||
for _, entry := range accessLogEntries(t, buf) {
|
||||
if entry["msg"] == msg {
|
||||
lines = append(lines, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// TestClientIP_LoggedNextToThePeer checks that every line that names
|
||||
// the client carries both addresses: remoteIP, the connecting peer,
|
||||
// and clientIP, the client the rate limiters key on.
|
||||
func TestClientIP_LoggedNextToThePeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
peer string
|
||||
wantRemote string
|
||||
wantClient string
|
||||
}{
|
||||
"trusted proxy with a forwarded chain": {
|
||||
peer: trustedPeer,
|
||||
wantRemote: "10.0.0.1",
|
||||
wantClient: clientIPv4,
|
||||
},
|
||||
"untrusted peer": {
|
||||
peer: untrustedPeer,
|
||||
wantRemote: "192.0.2.10",
|
||||
wantClient: "192.0.2.10",
|
||||
},
|
||||
}
|
||||
|
||||
for msg, site := range clientLogSites() {
|
||||
for name, tc := range cases {
|
||||
t.Run(msg+"/"+name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := clientLogLines(t, site, msg, tc.peer)
|
||||
require.NotEmpty(t, lines, "%q was never logged", msg)
|
||||
|
||||
for _, line := range lines {
|
||||
assert.Equal(t, tc.wantRemote, line["remoteIP"])
|
||||
assert.Equal(t, tc.wantClient, line["clientIP"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||
// token are logged and answered by forbidden, which must write the 403.
|
||||
// token receive a 403 Forbidden response.
|
||||
//
|
||||
// The middleware detects the client-facing transport protocol
|
||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||
@@ -36,19 +36,17 @@ func CSRFToken(r *http.Request) string {
|
||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||
// csrf.Secure option is set at creation time, not per-request.
|
||||
func (m *Middleware) CSRF(
|
||||
forbidden http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF is registered ahead of RequireAuth on every route
|
||||
// group that uses it, so this WARN is reachable by an
|
||||
// unauthenticated client: a POST with no token to
|
||||
// /hook/<any length of any text>/edit lands here. The
|
||||
// method and path are capped against the same budgets as
|
||||
// the access log. remoteIP and clientIP are the same
|
||||
// addresses the access log carries, and
|
||||
// the access log. remote_addr is set by net/http from the
|
||||
// accepted connection rather than by the client, and
|
||||
// csrf.FailureReason returns one of gorilla/csrf's own
|
||||
// fixed error values, so none of them is client-sized.
|
||||
// fixed error values, so neither is client-sized.
|
||||
m.log.Warn("csrf: token validation failed",
|
||||
"method", logfield.Truncate(
|
||||
r.Method, maxLogMethodBytes,
|
||||
@@ -56,11 +54,10 @@ func (m *Middleware) CSRF(
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"reason", csrf.FailureReason(r),
|
||||
)
|
||||
forbidden.ServeHTTP(w, r)
|
||||
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||
})
|
||||
|
||||
key := m.session.GetKey()
|
||||
|
||||
@@ -18,12 +18,6 @@ import (
|
||||
// csrfCookieName is the gorilla/csrf cookie name.
|
||||
const csrfCookieName = "_gorilla_csrf"
|
||||
|
||||
// forbidden stands in for the error page the server hands CSRF to
|
||||
// answer a refused request with.
|
||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
|
||||
// csrfGetToken performs a GET request through the CSRF middleware
|
||||
// and returns the token and cookies.
|
||||
func csrfGetToken(
|
||||
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
||||
|
||||
var gotToken string
|
||||
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotToken = middleware.CSRFToken(r)
|
||||
},
|
||||
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
newReq := func(method string) *http.Request {
|
||||
r := httptest.NewRequestWithContext(
|
||||
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
|
||||
// MetricsMiddlewareForTest builds the metrics recording middleware
|
||||
// against a caller-supplied recorder, so a test can gather from its
|
||||
// own Prometheus registry without building a whole Middleware.
|
||||
// own Prometheus registry rather than the process-wide default one
|
||||
// that Middleware.Metrics uses.
|
||||
func MetricsMiddlewareForTest(
|
||||
rec httpmetrics.Recorder,
|
||||
) func(http.Handler) http.Handler {
|
||||
@@ -132,12 +133,6 @@ func (g *LoginGuard) TrackedKeysForTest() (int, int) {
|
||||
// passwordChangeRateLimit constant.
|
||||
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
||||
|
||||
// ReplayRateLimitConst exposes the replayRateLimit constant.
|
||||
const ReplayRateLimitConst = replayRateLimit
|
||||
|
||||
// ResubmitRateLimitConst exposes the resubmitRateLimit constant.
|
||||
const ResubmitRateLimitConst = resubmitRateLimit
|
||||
|
||||
// ReceiverAggregateMultiplierConst exposes the
|
||||
// receiverAggregateMultiplier constant.
|
||||
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
|
||||
|
||||
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
||||
unreachable(t),
|
||||
)
|
||||
return m.CSRF()(unreachable(t))
|
||||
},
|
||||
send: postNoToken,
|
||||
wantStatus: http.StatusForbidden,
|
||||
|
||||
@@ -385,8 +385,6 @@ func (m *Middleware) RecordLoginFailure(
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
)
|
||||
|
||||
// inflightHandler is the fixed `handler` label on
|
||||
@@ -149,17 +151,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
|
||||
|
||||
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics
|
||||
// with the Middleware's one recorder, which New builds on the registry
|
||||
// the /metrics route serves and NewForTest on a registry of its own.
|
||||
// Every call reuses that recorder, so any number of routers can
|
||||
// install it.
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||
// the default registry, which is the one the /metrics route gathers.
|
||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
return metricsMiddleware(s.metricsRecorder)
|
||||
return metricsMiddleware(
|
||||
prommetrics.NewRecorder(prommetrics.Config{}),
|
||||
)
|
||||
}
|
||||
|
||||
// metricsMiddleware builds the recording middleware against a given
|
||||
// recorder, so tests can gather from a registry of their own.
|
||||
// recorder, so tests can gather from a registry of their own instead
|
||||
// of the process-wide default.
|
||||
func metricsMiddleware(
|
||||
rec httpmetrics.Recorder,
|
||||
) func(http.Handler) http.Handler {
|
||||
@@ -168,72 +170,13 @@ func metricsMiddleware(
|
||||
})
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mw := &metricsResponseWriter{
|
||||
ResponseWriter: w,
|
||||
request: r,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
// The handler id is unmatchedRoute rather than "" so
|
||||
// that the client-chosen URL path never enters the
|
||||
// metrics pipeline at all: an empty id is the library's
|
||||
// signal to substitute it. boundedLabelRecorder
|
||||
// overwrites this value on every observation, so it is
|
||||
// reachable only if that decorator is removed — in which
|
||||
// case the metrics collapse to one series instead of
|
||||
// leaking again.
|
||||
mdlw.Measure(unmatchedRoute, mw, func() {
|
||||
next.ServeHTTP(mw, r)
|
||||
})
|
||||
})
|
||||
// The handler id is unmatchedRoute rather than "" so that
|
||||
// the client-chosen URL path never enters the metrics
|
||||
// pipeline at all: an empty id is the library's signal to
|
||||
// substitute it. boundedLabelRecorder overwrites this value
|
||||
// on every observation, so it is reachable only if that
|
||||
// decorator is removed — in which case the metrics collapse
|
||||
// to one series instead of leaking again.
|
||||
return std.Handler(unmatchedRoute, mdlw, next)
|
||||
}
|
||||
}
|
||||
|
||||
// metricsResponseWriter records the status code and body size of a
|
||||
// response, and hands them with the request to go-http-metrics'
|
||||
// Measure as its Reporter.
|
||||
//
|
||||
// It stands in for the library's std.Handler, whose writer has no
|
||||
// Unwrap: behind it, http.ResponseController cannot reach net/http's
|
||||
// own writer, so a handler's write deadline fails with metrics on.
|
||||
type metricsResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
request *http.Request
|
||||
statusCode int
|
||||
bytesWritten int64
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) Write(b []byte) (int, error) {
|
||||
w.bytesWritten += int64(len(b))
|
||||
|
||||
//nolint:wrapcheck // Pass the writer's own error through unchanged.
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||
// a handler can still flush or set a write deadline with metrics on.
|
||||
func (w *metricsResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) Method() string { return w.request.Method }
|
||||
|
||||
func (w *metricsResponseWriter) Context() context.Context {
|
||||
return w.request.Context()
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) URLPath() string { return w.request.URL.Path }
|
||||
|
||||
func (w *metricsResponseWriter) StatusCode() int { return w.statusCode }
|
||||
|
||||
func (w *metricsResponseWriter) BytesWritten() int64 { return w.bytesWritten }
|
||||
|
||||
var _ ghmm.Reporter = (*metricsResponseWriter)(nil)
|
||||
|
||||
@@ -57,8 +57,9 @@ const (
|
||||
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
||||
// defect, so a test that flattens it would prove nothing.
|
||||
//
|
||||
// The recorder writes to a registry of the test's own, so each test
|
||||
// observes only its own traffic.
|
||||
// The recorder writes to a registry of the test's own rather than the
|
||||
// process-wide default one, so each test observes only its own
|
||||
// traffic.
|
||||
func metricsTestRouter(
|
||||
t *testing.T,
|
||||
receiverLimit int,
|
||||
@@ -454,29 +455,3 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
|
||||
"the interceptor must still count written bytes",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
|
||||
// by NewForTest has a recorder of its own: its Metrics() serves a
|
||||
// request instead of panicking, and a second one does not collide
|
||||
// with the first.
|
||||
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
cfg := &config.Config{Environment: "prod"}
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(okBody))
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, okRoute, nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,9 +14,6 @@ import (
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
@@ -70,19 +66,18 @@ const (
|
||||
// url, useragent, referer 3*(512+11) = 1569
|
||||
// request_id 128+11 = 139
|
||||
// method 32+11 = 43
|
||||
// fixed portion = 405
|
||||
// fixed portion = 336
|
||||
// ----
|
||||
// 2156
|
||||
// 2087
|
||||
//
|
||||
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
|
||||
// charged on top of each budget rather than inside it.
|
||||
//
|
||||
// The fixed portion is the JSON punctuation, the field names, the
|
||||
// level and the message, both timestamps at their longest, remoteIP
|
||||
// and clientIP each charged as an IPv6 address with a zone, a
|
||||
// three-digit status and a full-width int64 latency. Stated at 2560
|
||||
// so the figure carries headroom rather than sitting on the
|
||||
// arithmetic.
|
||||
// level and the message, both timestamps at their longest, an IPv6
|
||||
// remoteIP with a zone, a three-digit status and a full-width int64
|
||||
// latency. Stated at 2560 so the figure carries headroom rather
|
||||
// than sitting on the arithmetic.
|
||||
//
|
||||
// The tty text handler in internal/logger is covered by the same
|
||||
// figure. logfield.EncodedBytes charges every rune at least what
|
||||
@@ -90,8 +85,8 @@ const (
|
||||
// bytes strconv.Quote spends on a non-printable rune at or above
|
||||
// U+10000, which is four more than the JSON handler ever spends —
|
||||
// so each budget bounds the encoded field under either handler.
|
||||
// The text handler's fixed portion is 351, the smaller of the two,
|
||||
// which puts its worst case at 2102.
|
||||
// The text handler's fixed portion is 286, the smaller of the two,
|
||||
// which puts its worst case at 2037.
|
||||
//
|
||||
// It is also the ceiling on every OTHER line this service writes
|
||||
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
||||
@@ -154,11 +149,10 @@ const (
|
||||
type MiddlewareParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
Registry *prometheus.Registry
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||
@@ -168,14 +162,6 @@ type Middleware struct {
|
||||
params *MiddlewareParams
|
||||
session *session.Session
|
||||
|
||||
// metricsRecorder records the inbound HTTP metrics. New builds
|
||||
// it on the registry /metrics serves, NewForTest on a registry
|
||||
// of its own. Either way it is built once per Middleware and
|
||||
// Metrics reuses it, because building it registers its
|
||||
// collectors, and a second registration on the same registry
|
||||
// panics.
|
||||
metricsRecorder httpmetrics.Recorder
|
||||
|
||||
// loginGuard counts failed credential verifications and bounds
|
||||
// concurrent password hashing. It is built on first use so that
|
||||
// every construction path gets one; see guard().
|
||||
@@ -194,9 +180,6 @@ func New(
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.session = params.Session
|
||||
s.metricsRecorder = prommetrics.NewRecorder(
|
||||
prommetrics.Config{Registry: params.Registry},
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -217,28 +200,6 @@ func ipFromHostPort(hp string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// RemoteIP returns the address of the connecting peer, without its
|
||||
// port. Behind a reverse proxy it is the proxy. Every log line that
|
||||
// names the client logs it as remoteIP, next to clientIP.
|
||||
func RemoteIP(r *http.Request) string {
|
||||
return ipFromHostPort(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// clientIPKey is the request context key under which Logging stores
|
||||
// the value ClientIP returns.
|
||||
type clientIPKey struct{}
|
||||
|
||||
// ClientIP returns the address the request is attributed to, which
|
||||
// Logging works out once per request with clientAddr in ratelimit.go
|
||||
// and logs as clientIP. The other lines that name the client read it
|
||||
// from here, so all of them agree. It is empty for a request Logging
|
||||
// has not seen.
|
||||
func ClientIP(r *http.Request) string {
|
||||
ip, _ := r.Context().Value(clientIPKey{}).(string)
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
@@ -257,13 +218,6 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||
// a handler can still flush or set a write deadline through the access
|
||||
// log.
|
||||
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return lrw.ResponseWriter
|
||||
}
|
||||
|
||||
// concreteLogURL renders the request's own URL for the access log
|
||||
// branches that keep it, with the query string replaced by a fixed
|
||||
// marker.
|
||||
@@ -340,13 +294,6 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
lrw := newLoggingResponseWriter(w)
|
||||
ctx := r.Context()
|
||||
|
||||
// When RemoteAddr is not an address, the peer's own
|
||||
// text is all the request can be attributed to.
|
||||
clientIP := RemoteIP(r)
|
||||
if addr, ok := s.clientAddr(r); ok {
|
||||
clientIP = addr.String()
|
||||
}
|
||||
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
requestID := ""
|
||||
@@ -381,16 +328,13 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
r.Referer(), logfield.MaxBytes,
|
||||
),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", clientIP,
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, r.WithContext(
|
||||
context.WithValue(ctx, clientIPKey{}, clientIP),
|
||||
))
|
||||
next.ServeHTTP(lrw, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
@@ -77,7 +78,14 @@ func newTestSessionManager(
|
||||
key[i] = byte(i)
|
||||
}
|
||||
|
||||
store := session.NewStore(key)
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
var now func() time.Time
|
||||
|
||||
@@ -923,7 +931,8 @@ func metricsAuthMiddleware(
|
||||
}
|
||||
|
||||
key := make([]byte, testKeySize)
|
||||
store := session.NewStore(key)
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||
|
||||
|
||||
@@ -202,61 +202,44 @@ func (m *Middleware) forwardedClientAddr(
|
||||
}
|
||||
|
||||
// rateLimitKey is the client identity every rate limiter in this
|
||||
// package buckets on: the address clientAddr attributes the request
|
||||
// to, reduced to a bucket by bucketKey — full address for IPv4, /64
|
||||
// prefix for IPv6.
|
||||
// package buckets on. Forwarded headers are honoured only when the
|
||||
// direct peer (RemoteAddr) is inside the configured trusted-proxy
|
||||
// set; otherwise the peer address itself is the key. Without that
|
||||
// gate any client could mint a fresh bucket per request, or starve
|
||||
// another client's bucket, by picking an X-Forwarded-For value —
|
||||
// which makes every limit here decorative against a deliberate
|
||||
// attacker.
|
||||
//
|
||||
// The address that identifies the client is then reduced to a bucket
|
||||
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
||||
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
||||
return m.clientKey(r), nil
|
||||
}
|
||||
|
||||
// clientKey computes the bucket key described on rateLimitKey.
|
||||
func (m *Middleware) clientKey(r *http.Request) string {
|
||||
addr, ok := m.clientAddr(r)
|
||||
if !ok {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
// Not an address we can reason about; key on the raw
|
||||
// value, the most specific identity left. Distinct
|
||||
// RemoteAddr values stay in distinct buckets, so this
|
||||
// path cannot silently collapse unrelated clients
|
||||
// together. On a Unix-socket listener every peer
|
||||
// carries the same RemoteAddr and so shares one bucket,
|
||||
// which is the fail-closed direction. An empty RemoteAddr
|
||||
// is a different case, which net/http never produces for
|
||||
// a TCP listener and only a hand-built request carries,
|
||||
// but it fails closed the same way: every such request
|
||||
// shares the one bucket keyed on the empty string.
|
||||
// which is the fail-closed direction.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return bucketKey(addr)
|
||||
}
|
||||
|
||||
// clientAddr is the address a request is attributed to. The rate
|
||||
// limiters key on it and the logs name it as clientIP.
|
||||
//
|
||||
// Forwarded headers are honoured only when the direct peer
|
||||
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
|
||||
// the peer address itself is the client. Without that gate any client
|
||||
// could mint a fresh bucket per request, or starve another client's
|
||||
// bucket, by picking an X-Forwarded-For value — which makes every
|
||||
// limit here decorative against a deliberate attacker.
|
||||
//
|
||||
// ok is false when RemoteAddr is not an address at all.
|
||||
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
peer = normalizeAddr(peer)
|
||||
if !m.isTrustedProxy(peer) {
|
||||
return peer, true
|
||||
return bucketKey(peer)
|
||||
}
|
||||
|
||||
if addr, ok := m.forwardedClientAddr(r); ok {
|
||||
return addr, true
|
||||
return bucketKey(addr)
|
||||
}
|
||||
|
||||
return peer, true
|
||||
return bucketKey(peer)
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler used by the
|
||||
@@ -279,8 +262,6 @@ func (m *Middleware) tooManyRequests(
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
@@ -305,12 +286,8 @@ func (m *Middleware) tooManyRequests(
|
||||
func (m *Middleware) floodTooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.log.Debug(
|
||||
logMessage,
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.log.Debug(logMessage)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1012,23 +1012,6 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
|
||||
)
|
||||
}
|
||||
|
||||
// TestRateLimitKey_EmptyPeerSharesOneBucket pins what the fallback
|
||||
// does with an empty RemoteAddr: it keys on the empty string, so every
|
||||
// such request shares one bucket. That is the fail-closed direction
|
||||
// and is kept on purpose; only a hand-built request carries an empty
|
||||
// RemoteAddr.
|
||||
func TestRateLimitKey_EmptyPeerSharesOneBucket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
|
||||
assert.Empty(
|
||||
t, clientKeyFor(t, m, ""),
|
||||
"every peer with an empty RemoteAddr must key on the "+
|
||||
"empty string and so share one bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
||||
// half, and the regression test for the bypass itself: a client that
|
||||
// rotates source addresses inside its own routed /64 must stay in one
|
||||
|
||||
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
|
||||
// Recoverer returns middleware that turns a handler panic into one
|
||||
// structured ERROR record and a 500, rather than a dropped
|
||||
// connection. The 500 is page when page is not nil, and plain text
|
||||
// when it is nil or when page panics before writing anything.
|
||||
// connection.
|
||||
//
|
||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||
@@ -137,13 +136,9 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
//
|
||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||
// set before panicking, because a request that failed must not hand
|
||||
// the client a credential. It touches no other header: when page
|
||||
// answers, every other header the handler set goes out with it, apart
|
||||
// from any page sets itself; otherwise they are left to http.Error.
|
||||
// the client a credential; every other header is left to http.Error.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||
func (s *Middleware) Recoverer(
|
||||
page http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
@@ -176,14 +171,6 @@ func (s *Middleware) Recoverer(
|
||||
|
||||
rw.Header().Del("Set-Cookie")
|
||||
|
||||
if page != nil {
|
||||
s.servePage(rw, r, page)
|
||||
}
|
||||
|
||||
if rw.committed {
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(
|
||||
rw,
|
||||
http.StatusText(
|
||||
@@ -198,27 +185,6 @@ func (s *Middleware) Recoverer(
|
||||
}
|
||||
}
|
||||
|
||||
// servePage answers with page. A panic in page itself is logged and
|
||||
// recovered here, so the Recoverer can still send its plain 500.
|
||||
func (s *Middleware) servePage(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
page http.Handler,
|
||||
) {
|
||||
defer func() {
|
||||
rvr := recover()
|
||||
if rvr != nil {
|
||||
s.log.Error("error page panic",
|
||||
"panic", logfield.Truncate(
|
||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
page.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// logPanic writes the record. Every field it can grow is truncated to
|
||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||
//
|
||||
|
||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
||||
// Logging outside so the recovered 500 is the status it records.
|
||||
router.Use(chimw.RequestID)
|
||||
router.Use(m.Logging())
|
||||
router.Use(m.Recoverer(nil))
|
||||
router.Use(m.Recoverer())
|
||||
router.Get("/probe", handler)
|
||||
|
||||
serverErrors := new(bytes.Buffer)
|
||||
@@ -627,15 +627,17 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
|
||||
// net/http's own writer from http.ResponseController, so a handler
|
||||
// that flushes or sets a deadline starts failing.
|
||||
//
|
||||
// The recoverer is the only middleware in the chain here;
|
||||
// TestResponseControllerThroughProductionRouter in internal/server
|
||||
// covers the shipped chain.
|
||||
// The recoverer is the only middleware in the chain here. The access
|
||||
// logger's own wrapper does not implement Unwrap, so a chain
|
||||
// containing it fails this regardless of what the recoverer does;
|
||||
// what is being pinned is that the recoverer adds no such opacity of
|
||||
// its own.
|
||||
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := capturingMiddleware(t)
|
||||
|
||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
||||
handler := m.Recoverer()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("chunk"))
|
||||
|
||||
@@ -670,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "chunk", string(body))
|
||||
}
|
||||
|
||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
||||
// the panic is logged as before, and the 500 is that page.
|
||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("the error page"))
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "the error page", w.Body.String())
|
||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
}
|
||||
|
||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
||||
// before writing anything: both panics are logged, and the client
|
||||
// still gets the plain 500.
|
||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
panic(pagePanic)
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
assert.Contains(t, logs.String(), pagePanic)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,12 @@ package middleware
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// NewForTest creates a Middleware with the minimum dependencies
|
||||
// needed for testing. This bypasses the fx lifecycle.
|
||||
//
|
||||
// Its metrics recorder writes to a fresh registry of its own, so
|
||||
// Metrics() works on it and two of them never collide.
|
||||
func NewForTest(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
@@ -25,8 +20,5 @@ func NewForTest(
|
||||
Config: cfg,
|
||||
},
|
||||
session: sess,
|
||||
metricsRecorder: prommetrics.NewRecorder(
|
||||
prommetrics.Config{Registry: prometheus.NewRegistry()},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
@@ -132,15 +131,9 @@ type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
type noopArchives struct{}
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (n *noopArchives) EvictWebhook(string) {}
|
||||
|
||||
func (n *noopArchives) EvictTarget(string) {}
|
||||
|
||||
func (n *noopArchives) Rename(_, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (n *noopEvictor) EvictWebhook(string) {}
|
||||
|
||||
// newServerApp starts the real login path against dir: the handlers,
|
||||
// the middleware that bounds password verification, the session store
|
||||
@@ -158,10 +151,6 @@ func newServerApp(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||
// running after a start or stop timeout would write there after
|
||||
// the test has returned.
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -173,9 +162,7 @@ func newServerApp(
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
func() delivery.Archives { return &noopArchives{} },
|
||||
metrics.NewRegistry,
|
||||
metrics.New,
|
||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||
middleware.New,
|
||||
delivery.NewGuard,
|
||||
handlers.New,
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
//go:build browser
|
||||
|
||||
// This test needs a headless browser, so it is built only with the
|
||||
// browser build tag: `make test` leaves it out, and `make test-browser`
|
||||
// runs it in the browser image that Dockerfile.browser pins.
|
||||
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
const (
|
||||
// browserTimeout bounds everything one test does in the browser.
|
||||
browserTimeout = 60 * time.Second
|
||||
|
||||
// settleTimeout bounds the wait for an element to show or hide.
|
||||
settleTimeout = 5 * time.Second
|
||||
|
||||
// The window size of a phone, narrow enough that the pages show
|
||||
// the mobile menu button instead of the navigation links.
|
||||
phoneWidth = 390
|
||||
phoneHeight = 844
|
||||
)
|
||||
|
||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||
// event log in a headless browser, served by the real router and so
|
||||
// under the real Content-Security-Policy, and checks that the pages'
|
||||
// Alpine.js directives work.
|
||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, problems := startBrowser(t)
|
||||
|
||||
env := newTestEnv(t)
|
||||
srv := httptest.NewServer(env.router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||
webhook := env.seedWebhook(t, userID)
|
||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||
target := env.seedTarget(t, webhook.ID)
|
||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
||||
|
||||
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
||||
&database.DeliveryResult{
|
||||
DeliveryID: dlv.ID,
|
||||
AttemptNum: 1,
|
||||
StatusCode: http.StatusBadGateway,
|
||||
},
|
||||
).Error)
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||
))
|
||||
|
||||
page := srv.URL + "/hook/" + webhook.ID
|
||||
|
||||
checkAddForms(ctx, t, page)
|
||||
checkTargetType(ctx, t, page+"/events")
|
||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||
checkMobileMenu(ctx, t, page)
|
||||
|
||||
assert.Empty(t, problems(), "the browser reported problems")
|
||||
}
|
||||
|
||||
// startBrowser starts a headless browser for one test. It returns the
|
||||
// context that drives it, and a function listing what the browser
|
||||
// reported going wrong on its pages: console warnings and errors,
|
||||
// which is how Alpine.js reports an expression it cannot run; uncaught
|
||||
// exceptions; and every entry in the browser's own security log, which
|
||||
// is where it reports each script, style, image or request the
|
||||
// Content-Security-Policy refused.
|
||||
//
|
||||
// The browser library finds the browser on PATH. Without one the first
|
||||
// chromedp.Run fails, and with it the test.
|
||||
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
||||
t.Helper()
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
||||
t.Context(),
|
||||
append(
|
||||
chromedp.DefaultExecAllocatorOptions[:],
|
||||
// Dockerfile.browser runs the test as root, where the
|
||||
// browser's sandbox cannot start.
|
||||
chromedp.NoSandbox,
|
||||
)...,
|
||||
)
|
||||
t.Cleanup(cancelAlloc)
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
||||
t.Cleanup(cancelTimeout)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
problems []string
|
||||
)
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
var problem string
|
||||
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventConsoleAPICalled:
|
||||
if ev.Type != runtime.APITypeWarning &&
|
||||
ev.Type != runtime.APITypeError {
|
||||
return
|
||||
}
|
||||
|
||||
args := make([]string, 0, len(ev.Args))
|
||||
for _, arg := range ev.Args {
|
||||
args = append(args, string(arg.Value))
|
||||
}
|
||||
|
||||
problem = strings.Join(args, " ")
|
||||
case *runtime.EventExceptionThrown:
|
||||
problem = ev.ExceptionDetails.Error()
|
||||
case *log.EventEntryAdded:
|
||||
if ev.Entry.Source != log.SourceSecurity {
|
||||
return
|
||||
}
|
||||
|
||||
problem = ev.Entry.Text
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
problems = append(problems, problem)
|
||||
})
|
||||
|
||||
return ctx, func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return slices.Clone(problems)
|
||||
}
|
||||
}
|
||||
|
||||
// setCookies gives the browser the cookies for the server at base.
|
||||
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
for _, c := range cookies {
|
||||
err := network.SetCookie(c.Name, c.Value).
|
||||
WithURL(base).
|
||||
Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadPage opens url and waits for Alpine.js to start, which it does
|
||||
// by removing every x-cloak attribute. Until then x-cloak hides the
|
||||
// elements Alpine would hide, so a check made earlier proves nothing.
|
||||
func loadPage(url string) chromedp.Tasks {
|
||||
return chromedp.Tasks{
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||
}
|
||||
}
|
||||
|
||||
// shown waits up to settleTimeout for the elements matching a CSS
|
||||
// selector or an XPath expression to be rendered, and reports whether
|
||||
// they were. The wait is needed because Alpine.js shows an element on
|
||||
// the next animation frame, not at once.
|
||||
func shown(ctx context.Context, selector string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||
defer cancel()
|
||||
|
||||
return chromedp.Run(
|
||||
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
||||
) == nil
|
||||
}
|
||||
|
||||
// hidden is shown's opposite: it waits for the elements to be hidden.
|
||||
func hidden(ctx context.Context, selector string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||
defer cancel()
|
||||
|
||||
return chromedp.Run(
|
||||
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
||||
) == nil
|
||||
}
|
||||
|
||||
// click clicks the element matching an XPath expression.
|
||||
func click(ctx context.Context, t *testing.T, xpath string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
||||
))
|
||||
}
|
||||
|
||||
// checkAddForms loads a webhook page and checks that each section's add
|
||||
// form stays hidden until the Add button beside its heading is clicked.
|
||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
sections := []struct{ heading, form string }{
|
||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
||||
{"Targets", `form[action$="/targets"]`},
|
||||
}
|
||||
|
||||
for _, s := range sections {
|
||||
assert.Truef(
|
||||
t, hidden(ctx, s.form),
|
||||
"%s: the add form shows before Add is clicked", s.heading,
|
||||
)
|
||||
|
||||
click(ctx, t, `//h2[text()="`+s.heading+
|
||||
`"]/following-sibling::button`)
|
||||
|
||||
assert.Truef(
|
||||
t, shown(ctx, s.form),
|
||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTargetType chooses Slack in the open add target form and checks
|
||||
// what the form would then submit: one url field, the Slack one, and
|
||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
||||
//
|
||||
// It then opens the page at elsewhere and goes back. The browser loads
|
||||
// the webhook page again and restores the form as it was left, Slack
|
||||
// chosen, without a change event; the form must again show and submit
|
||||
// Slack's fields, not the HTTP ones.
|
||||
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
chooseSlack = `(() => {
|
||||
const type = document.querySelector('select[name="type"]');
|
||||
type.value = "slack";
|
||||
type.dispatchEvent(new Event("change"));
|
||||
})()`
|
||||
chosen = `document.querySelector('select[name="type"]').value`
|
||||
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
||||
submitted = `[...new FormData(
|
||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
||||
httpURL = `input[placeholder="https://example.com/webhook"]`
|
||||
)
|
||||
|
||||
slackFields := strings.Fields("csrf_token name type max_retries url")
|
||||
|
||||
var fields []string
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
chromedp.Evaluate(chooseSlack, nil),
|
||||
chromedp.Evaluate(submitted, &fields),
|
||||
))
|
||||
|
||||
assert.Equal(
|
||||
t, slackFields, fields,
|
||||
"with Slack chosen, the HTTP fields must not be submitted",
|
||||
)
|
||||
|
||||
var loaded, restored string
|
||||
|
||||
// Going back waits for the load event, after which the browser has
|
||||
// restored the form.
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
loadPage(elsewhere),
|
||||
chromedp.NavigateBack(),
|
||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||
chromedp.Evaluate(howLoaded, &loaded),
|
||||
chromedp.Evaluate(chosen, &restored),
|
||||
))
|
||||
|
||||
// A page the browser kept in memory and showed again as it was
|
||||
// would prove nothing here.
|
||||
require.Equal(
|
||||
t, "back_forward", loaded,
|
||||
"going back, the browser did not load the page again",
|
||||
)
|
||||
require.Equal(
|
||||
t, "slack", restored,
|
||||
"going back, the browser did not restore the chosen type",
|
||||
)
|
||||
|
||||
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
||||
|
||||
assert.True(t, shown(ctx, slackURL),
|
||||
"going back with Slack chosen, the Slack fields are not shown")
|
||||
assert.True(t, hidden(ctx, httpURL),
|
||||
"going back with Slack chosen, the HTTP fields are shown")
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, chromedp.Evaluate(submitted, &fields),
|
||||
))
|
||||
|
||||
assert.Equal(
|
||||
t, slackFields, fields,
|
||||
"going back with Slack chosen, the HTTP fields must not be submitted",
|
||||
)
|
||||
}
|
||||
|
||||
// checkEventLog loads the event log and checks that clicking an event's
|
||||
// row expands it, that in there clicking its delivery shows the
|
||||
// delivery's attempts and clicking again hides them, and that clicking
|
||||
// the event's row again collapses it.
|
||||
func checkEventLog(
|
||||
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// The event's row shows its ID, and its Resubmit form is in the part
|
||||
// that expands. The delivery's row there shows the target's name.
|
||||
eventRow := `//span[text()="` + eventID + `"]`
|
||||
expanded := `form[action$="/resubmit"]`
|
||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||
attempt := `//span[text()="Attempt 1"]`
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||
|
||||
click(ctx, t, eventRow)
|
||||
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
||||
|
||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||
|
||||
click(ctx, t, deliveryRow)
|
||||
assert.True(t, shown(ctx, attempt),
|
||||
"clicking the delivery does not show its attempts")
|
||||
|
||||
click(ctx, t, deliveryRow)
|
||||
assert.True(t, hidden(ctx, attempt),
|
||||
"clicking the delivery again does not hide its attempts")
|
||||
|
||||
click(ctx, t, eventRow)
|
||||
assert.True(t, hidden(ctx, expanded),
|
||||
"clicking the event again does not collapse it")
|
||||
}
|
||||
|
||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||
// the menu button opens and closes the mobile menu.
|
||||
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
// The menu button is the only button directly in the navigation
|
||||
// bar's top row. Profile is a link only the mobile menu has.
|
||||
button := `//nav/div/button`
|
||||
menu := `//nav//a[text()="Profile"]`
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
||||
loadPage(url),
|
||||
))
|
||||
|
||||
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
||||
|
||||
click(ctx, t, button)
|
||||
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
||||
|
||||
click(ctx, t, button)
|
||||
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// The link back the error page offers: to the webhook list for a
|
||||
// signed-in user, to sign-in for anyone else.
|
||||
const (
|
||||
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
|
||||
`Back to webhooks</a>`
|
||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
||||
`Sign in</a>`
|
||||
)
|
||||
|
||||
// assertErrorPage checks that w is the error page for status, in the
|
||||
// normal layout, offering link.
|
||||
func assertErrorPage(
|
||||
t *testing.T,
|
||||
w *httptest.ResponseRecorder,
|
||||
status int,
|
||||
link string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Equal(t, status, w.Code)
|
||||
assert.Equal(
|
||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
||||
)
|
||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
||||
assert.Contains(
|
||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
||||
)
|
||||
assert.Contains(t, body, link)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
||||
|
||||
w := env.get("/hook/"+wh.ID, cookies)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
tgt := env.seedTarget(t, wh.ID)
|
||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
||||
|
||||
w := env.get(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
||||
)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
|
||||
// page that fails is not shown above the error.
|
||||
func TestErrorPage_ShowsNoNotice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.NotContains(t, w.Body.String(), "Webhook saved.")
|
||||
}
|
||||
|
||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.get("/no-such-page", nil),
|
||||
http.StatusNotFound, backToSignIn,
|
||||
)
|
||||
|
||||
// Outside every route group there is no form token, so the
|
||||
// page leaves out the logout form rather than offer one that
|
||||
// would be refused.
|
||||
w := env.get("/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
|
||||
// Inside a route group the page has a token, and logout works.
|
||||
wh := env.seedWebhook(t, userID)
|
||||
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
}
|
||||
|
||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("username", "someone")
|
||||
form.Set("password", "irrelevant")
|
||||
form.Set("csrf_token", "not-a-token")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/pages/login", form, nil),
|
||||
http.StatusForbidden, backToSignIn,
|
||||
)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
|
||||
edit := url.Values{}
|
||||
edit.Set("name", "renamed")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
|
||||
http.StatusForbidden, backToWebhooks,
|
||||
)
|
||||
}
|
||||
|
||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
||||
// admin page route group through the real router, with error
|
||||
// tracking on: the client gets the 500 error page, and the tracker
|
||||
// still gets the panic, once. The same panic outside the admin page
|
||||
// route groups keeps the plain 500.
|
||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
transport := &captureTransport{}
|
||||
|
||||
opts := server.SentryClientOptionsForTest(
|
||||
"https://public@sentry.invalid/1", "webhooker-test",
|
||||
)
|
||||
opts.Transport = transport
|
||||
|
||||
client, err := sentry.NewClient(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
sentry.SetHubOnContext(
|
||||
context.Background(),
|
||||
sentry.NewHub(client, sentry.NewScope()),
|
||||
),
|
||||
http.MethodGet, path, nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
w := serve(
|
||||
server.NewRouterWithPageProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.PageProbePattern,
|
||||
)
|
||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
||||
|
||||
w = serve(
|
||||
server.NewRouterWithProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.ProbePattern,
|
||||
)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
|
||||
require.Len(t, transport.events, 2)
|
||||
|
||||
for _, event := range transport.events {
|
||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
||||
// the web UI only: a sender posting to an entrypoint that does not
|
||||
// exist still gets the plain-text answer.
|
||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// newTestEnv leaves the receiver rate limit at zero, which
|
||||
// refuses every request before it reaches the receiver.
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: 10,
|
||||
})
|
||||
|
||||
w := env.post(
|
||||
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
||||
// probe: inside the /pages route group, the admin page group a
|
||||
// request reaches without signing in.
|
||||
const PageProbePattern = "/pages/probe"
|
||||
|
||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
||||
// probe added to the /pages route group once SetupRoutes has built
|
||||
// it, so the probe runs behind that group's own middleware exactly as
|
||||
// the group's real routes do.
|
||||
func NewRouterWithPageProbeForTest(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
sentryEnabled bool,
|
||||
probe http.HandlerFunc,
|
||||
) http.Handler {
|
||||
s := &Server{
|
||||
log: log,
|
||||
mw: mw,
|
||||
h: h,
|
||||
params: ServerParams{Config: cfg},
|
||||
}
|
||||
s.sentryEnabled.Store(sentryEnabled)
|
||||
s.SetupRoutes()
|
||||
|
||||
for _, route := range s.router.Routes() {
|
||||
pages, ok := route.SubRoutes.(chi.Router)
|
||||
if ok && route.Pattern == "/pages/*" {
|
||||
pages.Get("/probe", probe)
|
||||
}
|
||||
}
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// TestResponseControllerThroughProductionRouter sets a write deadline
|
||||
// and flushes through http.ResponseController, behind the shipped
|
||||
// router and over a real connection, and checks that both reach
|
||||
// net/http's own writer.
|
||||
//
|
||||
// Every middleware that wraps the writer has to let them through with
|
||||
// an Unwrap method. One that does not makes the call return
|
||||
// http.ErrNotSupported, or, if it has a Flush of its own that cannot
|
||||
// reach further in, makes the flush silently do nothing; either way
|
||||
// the handler that trips over it is far from the cause.
|
||||
//
|
||||
// It runs once with the defaults and once with metrics and Sentry on,
|
||||
// because those two add middleware to the chain, and through both the
|
||||
// global middleware and an admin page route group, which adds its own.
|
||||
func TestResponseControllerThroughProductionRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Without /metrics credentials, metricsConfig is the default
|
||||
// Config.
|
||||
cases := []struct {
|
||||
name string
|
||||
username, password string
|
||||
sentryEnabled bool
|
||||
}{
|
||||
{name: "defaults"},
|
||||
{
|
||||
name: "metrics and Sentry on",
|
||||
username: metricsUser, password: metricsAuthValue,
|
||||
sentryEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// The probe answers with what each call returned.
|
||||
probe := func(w http.ResponseWriter, _ *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
|
||||
deadlineErr := rc.SetWriteDeadline(time.Now().Add(time.Minute))
|
||||
flushErr := rc.Flush()
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
w, "deadline: %v, flush: %v", deadlineErr, flushErr,
|
||||
)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithConfig(
|
||||
t, metricsConfig(t, tc.username, tc.password),
|
||||
)
|
||||
|
||||
routers := map[string]http.Handler{
|
||||
server.ProbePattern: server.NewRouterWithProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
tc.sentryEnabled, probe,
|
||||
),
|
||||
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
tc.sentryEnabled, probe,
|
||||
),
|
||||
}
|
||||
|
||||
for path, router := range routers {
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, srv.URL+path, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := srv.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.Equal(
|
||||
t, "deadline: <nil>, flush: <nil>", string(body), path,
|
||||
)
|
||||
|
||||
// A response the server holds until the handler returns
|
||||
// goes out with a Content-Length; one flushed while the
|
||||
// handler is still running goes out in chunks.
|
||||
assert.Equal(
|
||||
t, []string{"chunked"}, resp.TransferEncoding,
|
||||
"%s: the flush must reach the client", path,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+20
-71
@@ -7,6 +7,7 @@ import (
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
@@ -14,11 +15,9 @@ import (
|
||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||
// submission while preventing abuse from oversized payloads.
|
||||
//
|
||||
// The five admin page route groups below (/pages, /user/{username},
|
||||
// /settings, /hooks and /hook/{sourceID}) install
|
||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
||||
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
||||
// deliberate.
|
||||
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||
// orderings are deliberate.
|
||||
//
|
||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||
// be installed before anything reads the body, or the parse runs
|
||||
@@ -47,14 +46,6 @@ const requestTimeout = 60 * time.Second
|
||||
// server's router.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// An unknown path gets the error page. Registered before the
|
||||
// global middleware, because chi wraps a not-found handler in the
|
||||
// middleware already on its router, which would then run twice.
|
||||
// The route groups below wrap it in their own middleware the same
|
||||
// way; running theirs twice is harmless.
|
||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||
|
||||
s.setupGlobalMiddleware()
|
||||
s.setupRoutes()
|
||||
}
|
||||
@@ -78,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
// Panic recovery, deliberately here rather than first. It has to
|
||||
// run inside every middleware that observes the response, so the
|
||||
// 500 it writes is the status the access log records and the
|
||||
// metrics count, and outside the sentryhttp handler, whose
|
||||
// metrics count, and outside the sentryhttp handler below, whose
|
||||
// Repanic option needs something further out to catch what it
|
||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||
// until it was measured: on a current Go release it crashes
|
||||
// inside its stack pretty-printer instead of recovering, so the
|
||||
// connection dropped and the original panic was never reported.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||
s.recoverPanics(s.router, nil)
|
||||
}
|
||||
|
||||
// recoverPanics installs on r the recoverer, answering a panic with
|
||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
||||
// bubble up to the recoverer.
|
||||
//
|
||||
// Each admin page route group installs its own, with the error page,
|
||||
// as its first middleware. A panic there is logged, reported and
|
||||
// answered inside the group and never reaches the global recoverer,
|
||||
// which keeps the plain 500 for every other route.
|
||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
||||
r.Use(s.mw.Recoverer(page))
|
||||
s.router.Use(s.mw.Recoverer())
|
||||
|
||||
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the Recoverer middleware
|
||||
// registered immediately above.
|
||||
if s.sentryEnabled.Load() {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
r.Use(sentryHandler.Handle)
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,26 +130,27 @@ func (s *Server) setupRoutes() {
|
||||
if s.params.Config.MetricsAuthEnabled() {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get("/metrics", s.h.HandleMetrics())
|
||||
r.Get(
|
||||
"/metrics",
|
||||
http.HandlerFunc(
|
||||
promhttp.Handler().ServeHTTP,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSettingsRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupPageRoutes() {
|
||||
s.router.Route("/pages", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
@@ -187,13 +169,10 @@ func (s *Server) setupPageRoutes() {
|
||||
|
||||
func (s *Server) setupUserRoutes() {
|
||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
@@ -203,33 +182,12 @@ func (s *Server) setupUserRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||
// configuration comes from the environment and nothing here changes
|
||||
// it.
|
||||
func (s *Server) setupSettingsRoutes() {
|
||||
s.router.Route("/settings", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/hooks", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceList())
|
||||
@@ -238,13 +196,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
})
|
||||
|
||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceDetail())
|
||||
@@ -324,12 +279,6 @@ func (s *Server) setupSourceRoutes() {
|
||||
}
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
// No MaxBodySize here, unlike the page groups. The receiver's 1 MB
|
||||
// body cap is in Handlers.readWebhookBody, because the handler
|
||||
// owns the response a sender gets for an oversized body and
|
||||
// MaxBodySize would change it. That cap is the only bound on this
|
||||
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
||||
// pins it.
|
||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||
"/h/{uuid}",
|
||||
s.h.HandleWebhook(),
|
||||
|
||||
+40
-223
@@ -24,7 +24,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
@@ -47,18 +46,12 @@ type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
// noopArchives satisfies handlers.New's delivery.Archives
|
||||
// dependency. No test here checks what gets evicted or renamed, so
|
||||
// it records nothing.
|
||||
type noopArchives struct{}
|
||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||
// dependency. No test here checks what gets evicted, so it records
|
||||
// nothing.
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (e *noopArchives) EvictWebhook(string) {}
|
||||
|
||||
func (e *noopArchives) EvictTarget(string) {}
|
||||
|
||||
func (e *noopArchives) Rename(_, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (e *noopEvictor) EvictWebhook(string) {}
|
||||
|
||||
// testEnv is the real router from routes.go plus the collaborators
|
||||
// tests need to seed users and forge sessions.
|
||||
@@ -110,10 +103,6 @@ func newTestEnvWithConfig(
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||
// running after a start or stop timeout would write there after
|
||||
// the test has returned.
|
||||
fx.NopLogger,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
@@ -123,9 +112,7 @@ func newTestEnvWithConfig(
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
func() delivery.Archives { return &noopArchives{} },
|
||||
metrics.NewRegistry,
|
||||
metrics.New,
|
||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||
middleware.New,
|
||||
delivery.NewGuard,
|
||||
handlers.New,
|
||||
@@ -273,24 +260,6 @@ func (e *testEnv) urlFrom(
|
||||
return html.UnescapeString(match[1])
|
||||
}
|
||||
|
||||
// requireNotice requires w to redirect to dest carrying the notice
|
||||
// code, then renders that page and requires it to show text.
|
||||
func (e *testEnv) requireNotice(
|
||||
t *testing.T,
|
||||
w *httptest.ResponseRecorder,
|
||||
dest, code, text string,
|
||||
cookies []*http.Cookie,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
|
||||
|
||||
page := e.get(w.Header().Get("Location"), cookies)
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
assert.Contains(t, page.Body.String(), text)
|
||||
}
|
||||
|
||||
// authCookies forges an authenticated session for the given user.
|
||||
func (e *testEnv) authCookies(
|
||||
t *testing.T,
|
||||
@@ -769,31 +738,6 @@ func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
||||
assert.Equal(t, asked, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
|
||||
// lands on the sign-in page, which says so.
|
||||
func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "leaver", "somepassword")
|
||||
token, cookies := env.csrfFrom(
|
||||
t, "/hooks", env.authCookies(t, userID, "leaver"),
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
w := env.post(
|
||||
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
|
||||
// The sign-in page is requested without the session cookie, which
|
||||
// the logout told the browser to delete.
|
||||
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
||||
}
|
||||
|
||||
// --- /user/{username} group ---
|
||||
|
||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||
@@ -911,9 +855,9 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
require.NoError(t,
|
||||
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
||||
)
|
||||
env.requireNotice(
|
||||
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
|
||||
cookies,
|
||||
assert.Equal(
|
||||
t, "/hook/"+created.ID, w.Header().Get("Location"),
|
||||
"creating a webhook should redirect to its page",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -944,7 +888,8 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
||||
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, page, w.Header().Get("Location"))
|
||||
|
||||
var edited database.Webhook
|
||||
|
||||
@@ -958,17 +903,16 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
||||
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
env.requireNotice(
|
||||
t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
assert.Equal(
|
||||
t, http.StatusNotFound, env.get(page, cookies).Code,
|
||||
"a deleted webhook's page should be gone",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHook_EntrypointActions adds, deactivates, activates and deletes
|
||||
// an entrypoint with the forms on the webhook page, each submitted to
|
||||
// TestHook_EntrypointActions adds, deactivates and deletes an
|
||||
// entrypoint with the forms on the webhook page, each submitted to
|
||||
// the action and with the token the page rendered.
|
||||
func TestHook_EntrypointActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -986,19 +930,16 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
// submit posts the webhook page's form whose action pattern
|
||||
// captures, and requires the redirect back to that page with the
|
||||
// notice code, and the page to show text.
|
||||
submit := func(pattern, code, text string) {
|
||||
// captures, and requires the redirect back to that page.
|
||||
submit := func(pattern string) {
|
||||
t.Helper()
|
||||
|
||||
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
||||
env.requireNotice(t, w, page, code, text, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints)"`,
|
||||
"entrypoint-added", "Entrypoint added.")
|
||||
submit(`action="(/hook/[^/"]+/entrypoints)"`)
|
||||
|
||||
var added database.Entrypoint
|
||||
|
||||
@@ -1007,7 +948,7 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
)
|
||||
require.True(t, added.Active)
|
||||
|
||||
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
|
||||
|
||||
var toggled database.Entrypoint
|
||||
|
||||
@@ -1016,10 +957,7 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
)
|
||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||
|
||||
submit(toggle, "entrypoint-activated", "Entrypoint activated.")
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
|
||||
"entrypoint-deleted", "Entrypoint deleted.")
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
|
||||
|
||||
var left int64
|
||||
|
||||
@@ -1030,8 +968,8 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
|
||||
// TestHook_TargetActions adds a target with the form on the webhook
|
||||
// page, follows its Edit link to the target edit form and submits
|
||||
// it, then deactivates, activates and deletes it, every URL and token
|
||||
// taken from the rendered pages.
|
||||
// it, then deactivates and deletes it, every URL and token taken from
|
||||
// the rendered pages.
|
||||
func TestHook_TargetActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1046,29 +984,27 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
|
||||
// submit posts form, with the token, to the action pattern
|
||||
// captures on the page at from, and requires the redirect back to
|
||||
// the webhook page with the notice code, and that page to show
|
||||
// text.
|
||||
submit := func(from, pattern string, form url.Values, code, text string) {
|
||||
// the webhook page.
|
||||
submit := func(from, pattern string, form url.Values) {
|
||||
t.Helper()
|
||||
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
||||
env.requireNotice(t, w, page, code, text, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
||||
"name": {"added"},
|
||||
"type": {string(database.TargetTypeLog)},
|
||||
}, "target-added", "Target added.")
|
||||
})
|
||||
|
||||
editPage := env.urlFrom(
|
||||
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
||||
)
|
||||
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
||||
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
|
||||
url.Values{"name": {"renamed"}})
|
||||
|
||||
var edited database.Target
|
||||
|
||||
@@ -1078,8 +1014,8 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
assert.Equal(t, "renamed", edited.Name)
|
||||
require.True(t, edited.Active)
|
||||
|
||||
submit(page, toggle, url.Values{},
|
||||
"target-deactivated", "Target deactivated.")
|
||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
|
||||
url.Values{})
|
||||
|
||||
var toggled database.Target
|
||||
|
||||
@@ -1088,11 +1024,8 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
)
|
||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||
|
||||
submit(page, toggle, url.Values{},
|
||||
"target-activated", "Target activated.")
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
||||
url.Values{}, "target-deleted", "Target deleted.")
|
||||
url.Values{})
|
||||
|
||||
var left int64
|
||||
|
||||
@@ -1128,9 +1061,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
env.requireNotice(
|
||||
t, w, logsPath, "resubmit-no-targets",
|
||||
"this source has no active targets", cookies,
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||
@@ -1366,8 +1299,10 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
html.UnescapeString(action[1]), form, cookies,
|
||||
)
|
||||
|
||||
env.requireNotice(
|
||||
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, logsPath+"?replay=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(2), env.countDeliveries(t, wh.ID),
|
||||
@@ -1428,53 +1363,6 @@ func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceiver_OversizeBodyRefused pins the receiver's 1 MB body
|
||||
// cap, which lives in the handler rather than in a MaxBodySize
|
||||
// middleware. A body exactly at the cap is accepted; one byte over is
|
||||
// refused with the handler's own 413.
|
||||
func TestReceiver_OversizeBodyRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const bodyCap = 1 << 20 // 1 MB
|
||||
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: 10,
|
||||
})
|
||||
|
||||
userID, _ := env.seedUser(t, "receiver", "somepassword")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||
&database.Entrypoint{
|
||||
WebhookID: wh.ID,
|
||||
Path: "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35",
|
||||
Active: true,
|
||||
},
|
||||
).Error)
|
||||
|
||||
send := func(size int) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost,
|
||||
"/h/0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35",
|
||||
strings.NewReader(strings.Repeat("a", size)),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusOK, send(bodyCap).Code,
|
||||
"a body exactly at the cap must be accepted",
|
||||
)
|
||||
|
||||
w := send(bodyCap + 1)
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||||
assert.Equal(t, "Request body too large\n", w.Body.String())
|
||||
}
|
||||
|
||||
// metricsConfig is a Config differing from the routing default only
|
||||
// in the two /metrics credentials.
|
||||
func metricsConfig(
|
||||
@@ -1590,74 +1478,3 @@ func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoMetricsRoutersInOneProcess pins
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
|
||||
// metrics-enabled router in one process used to panic, because the
|
||||
// HTTP metrics registered on Prometheus's global default registry.
|
||||
// Two routers are built over separate dependency graphs and a third
|
||||
// over the first graph again, and each must still serve the HTTP,
|
||||
// delivery, Go runtime and process series, and the series counting
|
||||
// scrapes of /metrics itself.
|
||||
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := newTestEnvWithConfig(
|
||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
||||
)
|
||||
second := newTestEnvWithConfig(
|
||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
||||
)
|
||||
third := &testEnv{
|
||||
router: server.NewRouterForTest(
|
||||
first.log.Get(), first.cfg, first.mw, first.hnd,
|
||||
),
|
||||
}
|
||||
|
||||
for _, env := range []*testEnv{first, second, third} {
|
||||
env.get("/", nil)
|
||||
|
||||
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
||||
require.Equal(t, http.StatusOK, scrape.Code)
|
||||
|
||||
for _, series := range []string{
|
||||
"http_request_duration_seconds",
|
||||
"http_response_size_bytes",
|
||||
"http_requests_inflight",
|
||||
"webhooker_events_received_total",
|
||||
"go_goroutines",
|
||||
"process_start_time_seconds",
|
||||
"promhttp_metric_handler_requests_total",
|
||||
} {
|
||||
assert.Contains(t, scrape.Body.String(), series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsScrapeBeforeAnyDelivery pins
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/267: an instance that
|
||||
// has delivered nothing must still serve the delivery duration
|
||||
// histogram, at zero, for every target type.
|
||||
func TestMetricsScrapeBeforeAnyDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithConfig(
|
||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
||||
)
|
||||
|
||||
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
||||
require.Equal(t, http.StatusOK, scrape.Code)
|
||||
|
||||
for _, targetType := range []database.TargetType{
|
||||
database.TargetTypeHTTP,
|
||||
database.TargetTypeDatabase,
|
||||
database.TargetTypeLog,
|
||||
database.TargetTypeSlack,
|
||||
} {
|
||||
assert.Contains(
|
||||
t, scrape.Body.String(),
|
||||
`webhooker_delivery_duration_seconds_count{target_type="`+
|
||||
string(targetType)+`"} 0`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,12 @@ func (s *Server) Run() {
|
||||
s.serve()
|
||||
}
|
||||
|
||||
// MaintenanceMode returns whether the server is in maintenance
|
||||
// mode.
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
// enableSentry initialises the Sentry SDK when error reporting is
|
||||
// configured, and reports the failure when it is configured and cannot
|
||||
// be initialised. A DSN that is not set is not a failure: reporting
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user