1 Commits
Author SHA1 Message Date
sneak 3ee7acbe32 Logging in returns to the page that was asked for (closes #384)
check / check (push) Successful in 5m21s
RequireAuth now sends a logged-out GET to /pages/login with its path
and query in a `next` parameter. The login form carries it as a hidden
field, and a successful login redirects there when it is a path on
this site; anything else, plain or percent-encoded, goes to `/`, which
leads to the webhook list. A browser already logged in that opens the
login page goes to the same place. The navigation bar on the login
page no longer links to the login page.

Model: opus-5-5
2026-10-01 21:45:40 +00:00
248 changed files with 7187 additions and 26889 deletions
+4 -13
View File
@@ -1,23 +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 # .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 # that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile. # stage of the Dockerfile.
.git/
bin/ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
# The js-deps stage installs ESLint and prettier; a host copy would overwrite *.md
# them at the `COPY . .` of the stages built on it. LICENSE
node_modules/ .editorconfig
.env .env
.env.* .env.*
*.db *.db
+14 -8
View File
@@ -12,8 +12,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with: with:
# The superseded-status step needs history to walk ancestors (it # The fingerprint step below needs history to find the last commit
# aborts on a shallow clone). # 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 fetch-depth: 0
- name: Mark superseded run statuses - name: Mark superseded run statuses
@@ -27,11 +28,16 @@ jobs:
run: script/ci-mark-superseded run: script/ci-mark-superseded
- name: Fingerprint the build context - name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which # `.dockerignore` keeps docs out of the build context, so a docs-only
# invalidates the `COPY . .` layer of every check stage: a commit # commit legitimately replays the whole image from cache and stays
# that was never linted, format-checked, stylesheet-checked, tested # cheap. Every other commit writes a new fingerprint into the context,
# and built cannot report success from cache. # which invalidates the `COPY . .` layer of both check stages: a
run: git rev-parse HEAD > .ci-fingerprint # 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 the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild
-3
View File
@@ -15,9 +15,6 @@ bin/
# Go vendor directory # Go vendor directory
vendor/ vendor/
# ESLint, prettier and their dependencies, installed from yarn.lock
node_modules/
# IDE specific files # IDE specific files
.idea/ .idea/
*.swp *.swp
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
Binary file not shown.
Binary file not shown.
+19 -107
View File
@@ -4,100 +4,29 @@
# compile on Alpine musl (off64_t is a glibc type). # compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Copy go mod files first for better layer caching
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, which # Copy source code. In CI the context also carries .ci-fingerprint, whose
# holds the hash of the commit being checked (see # value changes with every commit that touches the build context (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks # .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 # below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore. # .dockerignore.
COPY . . COPY . .
# Run the Go formatting check and the linter. gofmt and golangci-lint are # Run formatting check and linter. golangci-lint is invoked directly rather
# invoked directly rather than through `make fmt-check` and `make lint`: this # than through `make lint`: this stage is already the pinned linter image, and
# stage is already the pinned linter image, and both scripts build docker # script/lint is a wrapper that builds Dockerfile.lint, so calling it here
# stages, so calling them here would need a docker daemon inside the build. # would need a docker daemon inside the build. Keep these steps in step with
# The Markdown half of `make fmt-check` is the markdown-check stage below. # Dockerfile.lint, including --network=none (see its header for why).
# Keep the golangci-lint steps in step with Dockerfile.lint, including RUN make fmt-check
# --network=none (see its header for why).
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml 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 ./...
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
# tailwindcss, from static/css/input.css and the files its @source lines
# name. `make css` (script/css) writes it out from the css-output stage.
# The css-check stage fails when the committed file differs from what is
# generated; `make check` runs it, and so does the build stage below.
#
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
# TARGETARCH, set by docker, is the architecture being built for.
FROM tailwind-${TARGETARCH} AS css
WORKDIR /src
COPY . .
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
FROM scratch AS css-output
COPY --from=css /out/tailwind.css /
# Both files are split after each "}", one rule per line, so that when they
# differ the diff shows the rules that differ.
FROM css AS css-check
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
exit 1; \
}
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# prettier for the Markdown stages below, and stays cached until those two
# files change. script/lint forces only js-lint to re-run, and the build stage
# below runs it too. COPY . . brings in the CI cache barrier described in the
# lint stage above.
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --ignore-scripts
FROM js-deps AS js-lint
COPY . .
RUN --network=none node_modules/.bin/eslint static/js
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
# formats every Markdown file in the tree with the settings in .prettierrc.
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
# markdown-check fails on any file prettier would change; `make fmt-check`
# runs it, and so does the build stage below.
FROM js-deps AS markdown
COPY . .
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
&& mkdir /out \
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
FROM scratch AS markdown-output
COPY --from=markdown /out /
FROM js-deps AS markdown-check
COPY . .
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
@@ -105,21 +34,12 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Depend on the lint, stylesheet check, JavaScript lint and Markdown check # Depend on lint stage passing
# stages passing
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=js-lint /src/yarn.lock /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with. # suite executes.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && 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
WORKDIR /build WORKDIR /build
@@ -135,22 +55,14 @@ COPY . .
# from its tarball in 3p/. # from its tarball in 3p/.
RUN make test RUN make test
# Version stamped into the binary: the VERSION build arg when one is # Version stamped into the binary. .dockerignore excludes .git/, so
# given, otherwise what script/version derives from the .git the build # nothing in this stage can derive it: script/docker resolves it on the
# context carries, so any `docker build .` of a clone stamps its commit. # host and passes it in. The default is what a bare `docker build .`
# With neither, as from a source tarball, it is "unknown". # 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 # Declared here, below the test step, so a changed version does not
# invalidate its cached layer. # invalidate its cached layer.
ARG VERSION ARG VERSION=unknown
# 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
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
-29
View File
@@ -1,29 +0,0 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+1 -7
View File
@@ -31,13 +31,7 @@ FROM deps AS lint
COPY . . COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# `run` silently ignores config keys it does not recognize, so a typo would # `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. # disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
# --build-tags browser also lints the browser test, which is built only with RUN --network=none golangci-lint run --config .golangci.yml ./...
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+6 -12
View File
@@ -1,15 +1,15 @@
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version .PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version; # 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 # override it (`make build VERSION=v1.2.3`) where git metadata is
# how the Dockerfile passes its build arg in. # unavailable, which is how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version) VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or the Dockerfile's `make build # An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
# VERSION="$VERSION"` when no VERSION build arg was given) means unset, # landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary # exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override` # reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it # is required: a plain assignment loses to the command-line definition it
@@ -33,9 +33,6 @@ assets:
test: test:
@script/test @script/test
test-browser:
@script/test-browser
lint: lint:
@script/lint @script/lint
@@ -74,7 +71,4 @@ hooks:
@script/install-precommit @script/install-precommit
css: css:
@script/css tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
css-check:
@script/css-check
+2232 -2450
View File
File diff suppressed because it is too large Load Diff
+355 -313
View File
@@ -2,367 +2,409 @@
One issue per unit of work, one branch and one PR per issue: One issue per unit of work, one branch and one PR per issue:
- ensure a tracked issue exists with a definition of done * ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`) * branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `main`) * do the work; open a PR based on `next` (never on `main`)
- pass an independent review, then the manager squash-merges into `next` * pass an independent review, then the manager squash-merges into `next`
- push; nothing stays local-only * push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and mergeable to `next` is the branch for the next milestone and must stay green and
`main` without notice. One `next` -> `main` PR accumulates the milestone; mergeable to `main` without notice. One `next` -> `main` PR accumulates
releases are cut from `main` separately. the milestone; releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on `next`. Issue branches do NOT touch this file — the manager maintains it on
Every branch editing `TODO.md` conflicts with every other (#112). `next`. Every branch editing `TODO.md` conflicts with every other
(#112).
# Status # Status
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
authoritative list, and the only place to read a count or a state of play from. authoritative list, and the only place to read a count or a state of
This file records where the project is, not what is in flight: a sentence whose play from. This file records where the project is, not what is in
truth depends on a branch being unmerged is wrong the moment it merges, and this flight: a sentence whose truth depends on a branch being unmerged is
file has been wrong that way before. wrong the moment it merges, and this file has been wrong that way
before.
The durability defect that held the tag has landed The durability defect that held the tag has landed
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite (https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
handle opens with WAL journaling and a busy timeout, a bookkeeping write that Every SQLite handle opens with WAL journaling and a busy timeout, a
fails leaves its delivery in a recoverable state rather than a lying one, and bookkeeping write that fails leaves its delivery in a recoverable
recovery skips a delivery that already has a successful result row. Final state rather than a lying one, and recovery skips a delivery that
pre-tag verification exercised it and confirmed it holds. Whatever the milestone already has a successful result row. Final pre-tag verification
still shows open is what remains before `v1.0.0`. exercised it and confirmed it holds. Whatever the milestone still
shows open is what remains before `v1.0.0`.
Delivery is at-least-once by design, not by accident: a send whose result row Delivery is at-least-once by design, not by accident: a send whose
does not land is attempted again, so a receiver can see a duplicate. That is result row does not land is attempted again, so a receiver can see a
deliberate — the alternative is a silent lost delivery — and the README says so duplicate. That is deliberate — the alternative is a silent lost
under Rationale. It is not a defect to re-file. 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 # Next Step
Clear the rest of the open 1.0.0 milestone Clear the rest of the open 1.0.0 milestone
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging (https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
`next` into `main` is a separate act from tagging and waits on neither of those: Merging `next` into `main` is a separate act from tagging and waits on
`next` is kept mergeable at all times, which is the point of the branch. neither of those: `next` is kept mergeable at all times, which is the
point of the branch.
# Completed Steps # Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS` - 2026-08-24 Bind the plaintext HTTP listener deliberately, via
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A `BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
hostname, an empty value or a value carrying a port is a startup error, and reverse-proxy deployment. A hostname, an empty value or a value
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is carrying a port is a startup error, and the `Dockerfile` sets
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit `0.0.0.0` because a loopback bind inside a container is unreachable
removed the shutdown race: `httpServer` is built in the constructor rather (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
than assigned from the serving goroutine, which orders the write before every removed the shutdown race: `httpServer` is built in the constructor
fx hook and rules out the nil dereference a SIGTERM arriving first would have rather than assigned from the serving goroutine, which orders the
caused, and `sentryEnabled` is an `atomic.Bool` write before every fx hook and rules out the nil dereference a
(https://git.eeqj.de/sneak/webhooker/issues/226) SIGTERM arriving first would have caused, and `sentryEnabled` is an
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID `atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226)
is the authentication secret, so the per-entrypoint shared secret, the - 2026-08-24 Remove inbound request signature verification. The
`internal/signature` package, the receiver check, the model fields and the entrypoint UUID is the authentication secret, so the per-entrypoint
forms are all gone. This reverses the feature that landed earlier in the same shared secret, the `internal/signature` package, the receiver check,
milestone (https://git.eeqj.de/sneak/webhooker/issues/67, the model fields and the forms are all gone. This reverses the
feature that landed earlier in the same milestone
(https://git.eeqj.de/sneak/webhooker/issues/67,
https://git.eeqj.de/sneak/webhooker/issues/279) https://git.eeqj.de/sneak/webhooker/issues/279)
- 2026-08-24 Stamp the build version into the binary and render it in the UI - 2026-08-24 Stamp the build version into the binary and render it in
footer. `script/version` is the single source — `$VERSION`, else the UI footer. `script/version` is the single source — `$VERSION`,
`git describe --tags --always --dirty`, else `unknown` — so a `make build` else `git describe --tags --always --dirty`, else `unknown` — so a
binary and a `make docker` image from one checkout report the same thing, and `make build` binary and a `make docker` image from one checkout
nothing in it varies between two builds of the same commit, which the release report the same thing, and nothing in it varies between two builds
gate's byte-identical assertion would catch of the same commit, which the release gate's byte-identical
assertion would catch
(https://git.eeqj.de/sneak/webhooker/issues/253) (https://git.eeqj.de/sneak/webhooker/issues/253)
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request - 2026-08-24 Derive cookie `Secure` and CSRF strictness from the
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a
with the environment left at its `dev` default, the session cookie silently real TLS proxy with the environment left at its `dev` default, the
lost `Secure` while the CSRF cookie on the same response kept it. session cookie silently lost `Secure` while the CSRF cookie on the
`X-Forwarded-Proto` is now matched case-insensitively on its first same response kept it. `X-Forwarded-Proto` is now matched
comma-separated element, so `HTTPS` and `https, http` no longer fall to the case-insensitively on its first comma-separated element, so `HTTPS`
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269) and `https, http` no longer fall to the relaxed CSRF path
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A (https://git.eeqj.de/sneak/webhooker/issues/269)
failing delete committed whatever had already succeeded, hard-deleted the - 2026-08-24 Roll back a failed webhook deletion instead of committing
per-webhook event database anyway, and redirected as though it had worked — it. A failing delete committed whatever had already succeeded,
orphaned config plus permanently destroyed history, reported as success. All hard-deleted the per-webhook event database anyway, and redirected as
three delete positions now roll back with the event database intact though it had worked — orphaned config plus permanently destroyed
history, reported as success. All three delete positions now roll
back with the event database intact
(https://git.eeqj.de/sneak/webhooker/issues/262) (https://git.eeqj.de/sneak/webhooker/issues/262)
- 2026-08-24 Name a deleted target on its historical deliveries, marked - 2026-08-24 Name a deleted target on its historical deliveries, marked
`(deleted)`, rather than leaving the event log unable to say where a delivery `(deleted)`, rather than leaving the event log unable to say where a
went. A deleted target's credentials stay masked exactly as a live one's, and delivery went. A deleted target's credentials stay masked exactly as
it cannot become deliverable again through the receiver, resubmit, replay, the a live one's, and it cannot become deliverable again through the
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211) receiver, resubmit, replay, the edit form or the toggle
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the (https://git.eeqj.de/sneak/webhooker/issues/211)
unauthenticated receiver is no longer a memory-exhaustion vector: `handler` - 2026-08-24 Bound both request-controlled `/metrics` label dimensions,
carries the chi route pattern, and `method` folds anything chi cannot route so the unauthenticated receiver is no longer a memory-exhaustion
onto a single `(unmatched)` sentinel. Both were reproduced before the fix — vector: `handler` carries the chi route pattern, and `method` folds
300 random method tokens took the series count from 106 to 7,631, and path anything chi cannot route onto a single `(unmatched)` sentinel. Both
flooding reached 62,532 — and a label audit across a live scrape found no were reproduced before the fix — 300 random method tokens took the
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254, series count from 106 to 7,631, and path flooding reached 62,532 —
and a label audit across a live scrape found no third unbounded
dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
https://git.eeqj.de/sneak/webhooker/issues/261) https://git.eeqj.de/sneak/webhooker/issues/261)
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5` - 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7`
silently became 0 — fire-and-forget — including on the edit path, where it and `-5` silently became 0 — fire-and-forget — including on the edit
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20 path, where it destroyed a working value, and `999999999` stored
is the `max` both templates already declared verbatim. The ceiling of 20 is the `max` both templates already
(https://git.eeqj.de/sneak/webhooker/issues/221) declared (https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend - 2026-08-24 Resubmit a stored event as a new undelivered event, so a
under development can be tested against real captured traffic. Per-delivery backend under development can be tested against real captured
replay cannot serve that: it re-sends one finished delivery to its own traffic. Per-delivery replay cannot serve that: it re-sends one
original target, and a target created for a dev backend has no prior delivery finished delivery to its own original target, and a target created
to replay. Resubmit re-injects the stored event at the top of the receiver for a dev backend has no prior delivery to replay. Resubmit
path and fans it out to whatever targets are active now re-injects the stored event at the top of the receiver path and fans
it out to whatever targets are active now
(https://git.eeqj.de/sneak/webhooker/issues/250) (https://git.eeqj.de/sneak/webhooker/issues/250)
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances - 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two
on one directory cannot both deliver instances on one directory cannot both deliver
(https://git.eeqj.de/sneak/webhooker/issues/201) (https://git.eeqj.de/sneak/webhooker/issues/201)
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook - 2026-08-20 Shut down the app when the HTTP listener fails. The
returned as soon as the serving goroutine was spawned, so a failed listen left `OnStart` hook returned as soon as the serving goroutine was
fx reporting RUNNING and a live process with nothing bound — invisible to spawned, so a failed listen left fx reporting RUNNING and a live
systemd and Docker restart policies process with nothing bound — invisible to systemd and Docker restart
(https://git.eeqj.de/sneak/webhooker/issues/200) policies (https://git.eeqj.de/sneak/webhooker/issues/200)
- 2026-08-20 Stop target credentials leaking into the per-webhook event - 2026-08-20 Stop target credentials leaking into the per-webhook event
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL
placeholders rather than bound values with placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205) half-set metrics auth credentials
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own (https://git.eeqj.de/sneak/webhooker/issues/205)
trace recorder in for the logging adapter, and that recorder does not - 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps
implement `gorm.ParamsFilter`, so those statements logged their bound values GORM's own trace recorder in for the logging adapter, and that
interpolated and bypassed the suppression above. The two units gated green recorder does not implement `gorm.ParamsFilter`, so those statements
against a `next` that lacked the other, and `next` went red when both landed logged their bound values interpolated and bypassed the suppression
above. The two units gated green against a `next` that lacked the
other, and `next` went red when both landed
(https://git.eeqj.de/sneak/webhooker/issues/234) (https://git.eeqj.de/sneak/webhooker/issues/234)
- 2026-08-20 Render per-attempt delivery detail in the event log - 2026-08-20 Render per-attempt delivery detail in the event log
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203) terminally failed delivery
(https://git.eeqj.de/sneak/webhooker/issues/203)
- 2026-08-20 Expose delivery metrics on `/metrics` - 2026-08-20 Expose delivery metrics on `/metrics`
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup, (https://git.eeqj.de/sneak/webhooker/issues/209) and document the
restore and upgrade procedures backup, restore and upgrade procedures
(https://git.eeqj.de/sneak/webhooker/issues/210) (https://git.eeqj.de/sneak/webhooker/issues/210)
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The - 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap
admin bootstrap password was printed once among roughly 45 fx lines, and under banner. The admin bootstrap password was printed once among roughly
`docker run -d` went to container logs subject to rotation; there was no reset 45 fx lines, and under `docker run -d` went to container logs subject
path at all, so recovery meant hand-deleting the users row, documented to rotation; there was no reset path at all, so recovery meant
nowhere. The password is read from stdin or generated, never from argv where hand-deleting the users row, documented nowhere. The password is read
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208) from stdin or generated, never from argv where `/proc` would publish
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the it (https://git.eeqj.de/sneak/webhooker/issues/208)
SSRF guard, so a self-hosted proxy can forward into the operator's own - 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch
network. The guard's always-blocked set cannot be reopened by configuration for the SSRF guard, so a self-hosted proxy can forward into the
operator's own network. The guard's always-blocked set cannot be
reopened by configuration
(https://git.eeqj.de/sneak/webhooker/issues/204) (https://git.eeqj.de/sneak/webhooker/issues/204)
- 2026-08-20 Harden operator-set target headers, which were carried unsafely - 2026-08-20 Harden operator-set target headers, which were carried
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233) unsafely across a redirect
(https://git.eeqj.de/sneak/webhooker/issues/233)
- 2026-08-20 Add a target edit form with headers and timeout fields - 2026-08-20 Add a target edit form with headers and timeout fields
(https://git.eeqj.de/sneak/webhooker/issues/127) (https://git.eeqj.de/sneak/webhooker/issues/127)
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching - 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
the org-wide backstop. `go test` applies `-timeout` per package, and matching the org-wide backstop. `go test` applies `-timeout` per
`internal/handlers` had grown past the old budget: a cache-defeated build package, and `internal/handlers` had grown past the old budget: a
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load cache-defeated build failed outright at `GOMAXPROCS=4`, and every run
breached 30s. The measurement table lives in the script (#194) under deliberate host load breached 30s. The measurement table lives
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale in the script (#194)
and still mandated a 20s test target with a 30s timeout, which the org - 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy
replaced with a 60s cap and a 90s backstop. A synced copy is not a source; was stale and still mandated a 20s test target with a 30s timeout,
reading it as one nearly produced a PR against `prompts` proposing a change which the org replaced with a 60s cap and a 90s backstop. A synced
already merged there (#196) copy is not a source; reading it as one nearly produced a PR against
- 2026-08-18 Report handler panics through the logger and answer 500. chi `prompts` proposing a change already merged there (#196)
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits, - 2026-08-18 Report handler panics through the logger and answer 500.
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no
writing a byte: the recovery never ran, the client got a dropped connection longer emits, then indexes `pkg[-1:]`, so it panicked inside its own
instead of a 500, and the original panic was lost. A local middleware replaces stack printer before writing a byte: the recovery never ran, the
it, bounded by `MaxPanicLogLineBytes` (#187) client got a dropped connection instead of a 500, and the original
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open` panic was lost. A local middleware replaces it, bounded by
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError` `MaxPanicLogLineBytes` (#187)
false, so **every record-not-found printed the fully interpolated SQL to - 2026-08-18 Route GORM's logger through `slog` and bound it. Every
stdout** — including the client-chosen path on `/webhook/{uuid}` and the `gorm.Open` left `logger.Default` in place at `Warn` with
submitted username on the login form, at no level the operator set and outside `IgnoreRecordNotFoundError` false, so **every record-not-found
`internal/logger` entirely. Three call sites, not the two the issue named printed the fully interpolated SQL to stdout** — including the
(#178) client-chosen path on `/webhook/{uuid}` and the submitted username on
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites the login form, at no level the operator set and outside
reachable unauthenticated, found by reading every `slog` call in the tree `internal/logger` entirely. Three call sites, not the two the issue
rather than only the one reported; the budget moved to a shared named (#178)
`internal/logfield` so no second truncation exists. `DEBUG` being off by - 2026-08-18 Bound every `slog` line against client-chosen text. Eight
default is not a bound and is not treated as one (#176) sites reachable unauthenticated, found by reading every `slog` call in
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A the tree rather than only the one reported; the budget moved to a
non-fatal `assert` on an acquire result was dereferenced on the next line, so shared `internal/logfield` so no second truncation exists. `DEBUG`
one timing miss killed the whole `internal/middleware` binary and reddened CI being off by default is not a bound and is not treated as one (#176)
for unrelated PRs. The fix also removed a real production race — `acquire` - 2026-08-18 Stop a slow host turning a login-guard test into a
could shed a request with a slot standing free, because Go picks uniformly segfault. A non-fatal `assert` on an acquire result was dereferenced
among ready `select` cases (#186) on the next line, so one timing miss killed the whole
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path. `internal/middleware` binary and reddened CI for unrelated PRs. The
The receiver's path carries the entrypoint capability token, so every Sentry fix also removed a real production race — `acquire` could shed a
event from `/webhook/{uuid}` shipped a live credential to a third party. request with a slot standing free, because Go picks uniformly among
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers ready `select` cases (#186)
reduced to an allowlist (#179) - 2026-08-18 Send the chi route pattern to Sentry rather than the
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the concrete path. The receiver's path carries the entrypoint capability
query string, so a login could be driven by URL parameters — putting the token, so every Sentry event from `/webhook/{uuid}` shipped a live
password somewhere that lands in access logs, proxy logs and browser history credential to a third party. Request `Data`, `QueryString`, `Cookies`
(#160) and `Env` are dropped and headers reduced to an allowlist (#179)
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a - 2026-08-18 Read form fields from the POST body only. `r.FormValue`
flood of wrong passwords cannot lock out the account it is guessing at. The merges the query string, so a login could be driven by URL parameters
manager took this decision rather than stall the queue; it is flagged on the — putting the password somewhere that lands in access logs, proxy
issue for reversal (#150) logs and browser history (#160)
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was - 2026-08-18 Verify login credentials before spending rate-limit
wrong in both directions from version skew and shared caches. `script/lint` budget, so a flood of wrong passwords cannot lock out the account it
asserts the summary line, because `--no-cache-filter` silently ignores a stage is guessing at. The manager took this decision rather than stall the
name it does not match — the flag that makes the gate meaningful fails open queue; it is flagged on the issue for reversal (#150)
(#109) - 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint
- 2026-08-18 Serve an event's full stored body over HTTP. The list query was wrong in both directions from version skew and shared caches.
truncates for rendering, and that truncated value was the only way to read a `script/lint` asserts the summary line, because `--no-cache-filter`
body, so the full payload was unreachable (#157) silently ignores a stage name it does not match — the flag that makes
the gate meaningful fails open (#109)
- 2026-08-18 Serve an event's full stored body over HTTP. The list
query truncates for rendering, and that truncated value was the only
way to read a body, so the full payload was unreachable (#157)
- 2026-08-18 Bound the access log line against client-chosen text. - 2026-08-18 Bound the access log line against client-chosen text.
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON `internal/logfield` budgets by *encoded* bytes, not runes, so a
escaping cannot multiply a field past its allowance (#146) handler's JSON escaping cannot multiply a field past its allowance
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A (#146)
skipped run rolls up green, so a commit that was never tested reported success - 2026-08-18 Mark superseded CI commits `failure` rather than
(#152) `skipped`. A skipped run rolls up green, so a commit that was never
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown tested reported success (#152)
hooks are bounded by a deadline the orchestrator will actually honour rather - 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so
than being killed mid-flush (#134) shutdown hooks are bounded by a deadline the orchestrator will
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out actually honour rather than being killed mid-flush (#134)
2^64 addresses, so per-address keying let one client mint unlimited buckets. - 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation
Manager decision, recorded on the issue (#125) hands out 2^64 addresses, so per-address keying let one client mint
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies, unlimited buckets. Manager decision, recorded on the issue (#125)
including claims about behaviour the code does not have (#151) - 2026-08-17 Correct release-blocking README and startup-warning
inaccuracies, including claims about behaviour the code does not have
(#151)
- 2026-08-17 Fetch and verify Alpine.js at build time against - 2026-08-17 Fetch and verify Alpine.js at build time against
`static/vendor.sha256` instead of committing the minified blob, so the `static/vendor.sha256` instead of committing the minified blob, so
dependency is pinned by hash rather than by trust (#145) the dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a - 2026-08-17 Bound the event log's rendered bodies in the query itself,
large stored payload cannot be read into memory just to be truncated for so a large stored payload cannot be read into memory just to be
display (#135) truncated for display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a - 2026-08-17 Mask the `http` target's destination URL in the UI: it can
bearer credential in its path or query, and was rendered verbatim. Manager carry a bearer credential in its path or query, and was rendered
decision to mask unconditionally (#115) verbatim. Manager decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs - 2026-08-14 Bound shutdown hooks by their stop context, so a hook that
cannot hold the process past its grace period (#102) hangs cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so - 2026-08-14 Render templates via a buffer rather than the
a template error part-way through cannot commit a 200 and then fail — the `ResponseWriter`, so a template error part-way through cannot commit
response is written only once it is whole (#123) a 200 and then fail — the response is written only once it is whole
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The (#123)
codec accepted cookies the session layer considered expired, so the cap was - 2026-08-14 Align the session codec's max-age with the 7-day absolute
enforced in one place and not the other (#108) cap. The codec accepted cookies the session layer considered expired,
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe so the cap was enforced in one place and not the other (#108)
default silently discards forwarded headers and every client rate-limits as - 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where
the proxy's address (#149) the safe default silently discards forwarded headers and every client
- 2026-08-12 Bound the receiver rate limit per client IP across the whole rate-limits as the proxy's address (#149)
`/webhook/*` route. The existing limiter keyed on the request path and - 2026-08-12 Bound the receiver rate limit per client IP across the
`/webhook/{uuid}` matches any single segment, so a client that invented a whole `/webhook/*` route. The existing limiter keyed on the request
fresh path per request minted a fresh bucket per request: the limit on the path and `/webhook/{uuid}` matches any single segment, so a client
only unauthenticated endpoint bounded nothing in aggregate, and every request that invented a fresh path per request minted a fresh bucket per
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the request: the limit on the only unauthenticated endpoint bounded
client address alone now bounds that, chained in front of the unchanged nothing in aggregate, and every request still cost an entrypoint
lookup before it 404ed. An outer limiter keyed on the client address
alone now bounds that, chained in front of the unchanged
per-entrypoint limiter (#139) per-entrypoint limiter (#139)
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README - 2026-08-12 Correct release-blocking documentation inaccuracies: the
promised manual redelivery in the present tense in three places when nothing README promised manual redelivery in the present tense in three
implements it (the same false claim also sat in the doc comment that was its places when nothing implements it (the same false claim also sat in
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md` the doc comment that was its source text), the env table omitted
itself omitted five landed units (#141) `RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now units (#141)
writes a build-context fingerprint before calling `script/cibuild`, so a code - 2026-08-12 Make the CI gate execute the checks it reports on. The
commit invalidates the `COPY` layer of the lint and builder stages while a workflow now writes a build-context fingerprint before calling
docs-only commit still replays from cache; a superseding run also rewrites the `script/cibuild`, so a code commit invalidates the `COPY` layer of
`failure` status Gitea leaves on commits it cancelled and never tested. the lint and builder stages while a docs-only commit still replays
Verified by pushing a deliberately broken test and watching CI go red (#119) from cache; a superseding run also rewrites the `failure` status
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value Gitea leaves on commits it cancelled and never tested. Verified by
reached `time.NewTicker` in both the retention reaper and the archive sweeper, pushing a deliberately broken test and watching CI go red (#119)
panicking two goroutines with no recover after startup had already reported - 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
success (#140) non-positive value reached `time.NewTicker` in both the retention
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the reaper and the archive sweeper, panicking two goroutines with no
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and recover after startup had already reported success (#140)
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request - 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
on the unauthenticated receiver. Semantics proven unchanged by differential cap: the reverse walk cuts entries with `strings.LastIndexByte`
testing against the previous implementation (#133) instead of joining and splitting, so a 1 MB header allocates 16 bytes
rather than 1.6 MB per request on the unauthenticated receiver.
Semantics proven unchanged by differential testing against the
previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key attacker-supplied chain cannot burn unbounded CPU in the rate-limit
function; running off the end falls back to the peer address (#124) key function; running off the end falls back to the peer address
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list: (#124)
all three rate limiters key on the connection's own address unless the direct - 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to list: all three rate limiters key on the connection's own address
left for the first non-proxy hop. Default trusts nothing, and a unless the direct peer is a configured proxy, in which case
set-but-unparseable value aborts startup. Before this, any client could mint a `X-Forwarded-For` is walked right to left for the first non-proxy hop.
fresh bucket or drain another's by rotating a spoofed header (#88) Default trusts nothing, and a set-but-unparseable value aborts
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile startup. Before this, any client could mint a fresh bucket or drain
settings placeholder removed, a progressive-enhancement copy button for the another's by rotating a spoofed header (#88)
entrypoint URL, and retention form copy that states the actual policy - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
(deletion by the reaper, 0 retains forever) (#57) Profile settings placeholder removed, a progressive-enhancement copy
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds button for the entrypoint URL, and retention form copy that states the
the request URL in `*url.Error`, so every transport failure persisted the full actual policy (deletion by the reaper, 0 retains forever) (#57)
Slack webhook URL into the per-webhook event database via - 2026-08-11 Mask the webhook credential in delivery errors and logs:
`DeliveryResult.Error`, a field a future REST API would have served. Go embeds the request URL in `*url.Error`, so every transport failure
`maskURLError` drops path, query and userinfo while preserving the wrapped persisted the full Slack webhook URL into the per-webhook event
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout database via `DeliveryResult.Error`, a field a future REST API would
failures still read differently (#118) have served. `maskURLError` drops path, query and userinfo while
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
still work and DNS, TLS and timeout failures still read differently
(#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint - 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
path so one entrypoint cannot exhaust another's budget; over-limit requests entrypoint path so one entrypoint cannot exhaust another's budget;
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing over-limit requests get 429 with `Retry-After`. It was the one
endpoint with no limit at all (#64) unauthenticated, internet-facing endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form: - 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so an `MaxBodySize` is now first in all four form-parsing route groups, so
oversized request is rejected with 413 instead of being read in full by the an oversized request is rejected with 413 instead of being read in
CSRF middleware before any cap applied (#90) full by the CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which rendered the - 2026-08-11 Mask target config on the source detail page, which
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer rendered the stored blob verbatim and so exposed the Slack
credential that cannot be revoked per-holder. Config reaches the template only incoming-webhook URL — a bearer credential that cannot be revoked
as a `TargetView` of labelled fields, and header values are rendered as a per-holder. Config reaches the template only as a `TargetView` of
count (#113) labelled fields, and header values are rendered as a count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel - 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
written in `BeforeSave` so the GORM column default cannot win the race. Also sentinel written in `BeforeSave` so the GORM column default cannot
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed win the race. Also bounds the reaper's cutoff arithmetic: day counts
`time.Duration` and wrapped the cutoff into the future, where every row above 106751 overflowed `time.Duration` and wrapped the cutoff into
matched and the sweep deleted everything (#79) the future, where every row matched and the sweep deleted everything
(#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests, (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
with the 7-day absolute cap kept as an independent backstop that activity requests, with the 7-day absolute cap kept as an independent
never extends (#66) backstop that activity never extends (#66)
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
orphaned `retrying` delivery whose target type no longer supports retries, orphaned `retrying` delivery whose target type no longer supports
recording a `DeliveryResult` with the reason instead of leaving the delivery retries, recording a `DeliveryResult` with the reason instead of
stuck forever (#82) leaving the delivery stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's - 2026-08-09 Root the delivery engine's worker pool and the retention
sweep loop at `context.Background()` rather than the fx `OnStart` hook context reaper's sweep loop at `context.Background()` rather than the fx
(#97), which carries fx's 15s start timeout and killed both roughly fifteen `OnStart` hook context (#97), which carries fx's 15s start timeout and
seconds after boot: the proxy silently stopped delivering webhooks entirely, killed both roughly fifteen seconds after boot: the proxy silently
and the reaper never ran a single sweep under its default one-hour interval stopped delivering webhooks entirely, and the reaper never ran a
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last single sweep under its default one-hour interval
`database` target) evicts the cached archive writer and closes its handle - 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
while deliberately leaving `archive-{webhookID}.db` on disk, and a new last `database` target) evicts the cached archive writer and closes
`ArchiveSweeper` prunes idle archives on the existing its handle while deliberately leaving `archive-{webhookID}.db` on
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file `RETENTION_SWEEP_INTERVAL` without ever creating an archive file
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
environment values: `envInt` removed in favour of `envPositiveInt` plus a environment values: `envInt` removed in favour of `envPositiveInt`
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and plus a `PORT` range check, `envBool` now parses with
defaults apply only to unset variables (#80) `strconv.ParseBool`, and defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on `retention_days`, - 2026-08-07 Automatic event retention cleanup based on
deleting expired events, deliveries, and delivery results from each `retention_days`, deleting expired events, deliveries, and delivery
per-webhook event database (#63) results from each per-webhook event database (#63)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the `Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
canonical `.golangci.yml` (v2 `linters.settings` layout so adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
surfaced lint findings all newly surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55) - 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain - 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
HTTP and behind reverse proxies (#54) plain HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52) - 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS - 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
rebinding defense, and per-IP login rate limiting (#42) with DNS rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47) - 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev - 2026-03-17 Dockerfile absolute paths and static linking (#49);
DATA_DIR default and clarified env docs (#46) absolute dev DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on login, request - 2026-03-05 security headers middleware, session regeneration on
body size limits (#41) login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed - 2026-03-04 tests for delivery, middleware, and session packages
the build-architecture global (#31) (#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
engine with bounded worker pool and circuit breaker, parallel fan-out, delivery engine with bounded worker pool and circuit breaker,
per-webhook event databases, management UI (#16) parallel fan-out, per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README - 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
(#6) into README (#6)
# Future Steps # Future Steps
- Delivery status and retry management UI. Replay of a terminally failed - Delivery status and retry management UI. Replay of a terminally
delivery and per-attempt detail already landed failed delivery and per-attempt detail already landed
(https://git.eeqj.de/sneak/webhooker/issues/203, (https://git.eeqj.de/sneak/webhooker/issues/203,
https://git.eeqj.de/sneak/webhooker/issues/202) https://git.eeqj.de/sneak/webhooker/issues/202)
- Per-webhook rate limiting in the receiver handler (per-webhook config plus - Per-webhook rate limiting in the receiver handler (per-webhook config
handler enforcement; global limits must not apply to receiver endpoints) plus handler enforcement; global limits must not apply to receiver
- API key authentication for programmatic access (APIKey model exists; Bearer endpoints)
token middleware does not) - API key authentication for programmatic access (APIKey model exists;
Bearer token middleware does not)
- REST API v1 - REST API v1
- CRUD for webhooks, entrypoints, targets - CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints - event viewing and filtering endpoints
@@ -370,9 +412,9 @@ Clear the rest of the open 1.0.0 milestone
- OpenAPI specification - OpenAPI specification
- Analytics dashboard: success rates, response times, volume - Analytics dashboard: success rates, response times, volume
- A remember-me option at login - A remember-me option at login
- Password reset flow for a forgotten password over the web. The authenticated - Password reset flow for a forgotten password over the web. The
password _change_ flow already landed, and a lost password is recoverable from authenticated password *change* flow already landed, and a lost
the console with `webhooker resetpw` password is recoverable from the console with `webhooker resetpw`
(https://git.eeqj.de/sneak/webhooker/issues/208) (https://git.eeqj.de/sneak/webhooker/issues/208)
- Later, nice to have - Later, nice to have
- email delivery target type - email delivery target type
+12 -36
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -17,7 +16,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -38,19 +36,17 @@ import (
// hook that used the whole budget would exhaust it at that instant, // hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery // and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database // engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the HTTP drain plus the Sentry flush that // close. That hook is the 3s HTTP drain plus the Sentry flush that
// follows it in the same hook, and each is clamped to the stop // follows it in the same hook, so the flush is clamped to the stop
// context's remaining time less server.TailHookReserve rather than // context's remaining time less server.TailHookReserve rather than
// running for its own fixed 3s and 2s; the reserve is what the tail // running for its own fixed 2s; the reserve is what the tail hooks
// hooks live on, and they are microsecond-scale in normal operation. // live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic // TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length and every amount of budget the hooks // across every drain length.
// before the server may already have spent.
// //
// This does not make the database close unconditional: the // This does not make the database close unconditional: the
// ArchiveSweeper and RetentionReaper hooks run before the server. // ArchiveSweeper and RetentionReaper hooks run before the server
// What they spend comes out of the drain first, but past 3s it comes // and can still consume the whole budget on their own.
// out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make // exitUsage is the status for a command line this binary cannot make
@@ -171,19 +167,6 @@ func run(stderr io.Writer) int {
func newApp() *fx.App { func newApp() *fx.App {
return fx.New( return fx.New(
fx.StopTimeout(stopTimeout), fx.StopTimeout(stopTimeout),
// fx's own events go through the service's logger, not fx's
// console logger on standard error. The exception is a failure
// before this logger is built, such as an invalid configuration
// value, which fx's console logger still prints there. fx holds
// its events back until this logger is built and then replays
// them, so it takes the configuration, which sets the level
// DEBUG=true asks for: without it the replay would run at INFO
// and drop every record of how the graph was built.
fx.WithLogger(
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
return logger.NewFxLogger(l.Get())
},
),
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -194,10 +177,6 @@ func newApp() *fx.App {
healthcheck.New, healthcheck.New,
session.New, session.New,
handlers.New, handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
// The one SSRF guard both target-creation validation // The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot // and the delivery dialer consult, so they cannot
@@ -208,14 +187,11 @@ func newApp() *fx.App {
// Wire *delivery.Engine as delivery.Notifier so the // Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries. // webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e }, func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting // Wire *delivery.Engine as delivery.WebhookEvictor so
// or renaming a webhook or target reaches its archive // deleting a webhook releases its archive writer.
// files. func(e *delivery.Engine) delivery.WebhookEvictor {
func(e *delivery.Engine) delivery.Archives { return e }, return e
// Wire *delivery.Engine as delivery.CircuitBreakers so },
// the pages can show a target whose deliveries are
// paused.
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
server.New, server.New,
), ),
fx.Invoke( fx.Invoke(
+9 -129
View File
@@ -2,19 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -37,7 +30,6 @@ const dockerStopGrace = 10 * time.Second
// fx.New applies options before it executes invokes, so the timeout // fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here. // is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) { func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout() got := newApp().StopTimeout()
@@ -46,100 +38,6 @@ func TestNewApp_StopTimeout(t *testing.T) {
require.Less(t, got, dockerStopGrace) require.Less(t, got, dockerStopGrace)
} }
// freePort returns a loopback TCP port that was free a moment ago, by
// taking one and releasing it.
func freePort(t *testing.T) int {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok, "listener is not TCP")
require.NoError(t, l.Close())
return addr.Port
}
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
// runs, with DEBUG=true, and reads back what reached the service's
// logger. fx's own events must arrive there as structured records:
// the start at INFO, and at DEBUG the records of how the graph was
// built.
//
// fx holds its events back until its logger is built and then replays
// them all at once, so the earliest of them arriving shows the replay
// ran at DEBUG: that globals.New was provided, which fx records before
// anything is built, and the run of logger.New, which happens before
// the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true")
// internal/logger writes to whatever os.Stdout is when it builds
// its handler. A file is not a terminal, so that handler is the
// JSON one the service uses in production.
out, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stdout := os.Stdout
os.Stdout = out
t.Cleanup(func() {
os.Stdout = stdout
_ = out.Close()
})
app := newApp()
require.NoError(t, app.Start(t.Context()))
require.NoError(t, app.Stop(t.Context()))
_, err = out.Seek(0, io.SeekStart)
require.NoError(t, err)
written, err := io.ReadAll(out)
require.NoError(t, err)
type record struct {
Level string `json:"level"`
Msg string `json:"msg"`
Name string `json:"name"`
Constructor string `json:"constructor"`
}
var records []record
for line := range strings.Lines(string(written)) {
var r record
// The first-boot banner is plain text, not a record.
if json.Unmarshal([]byte(line), &r) == nil {
records = append(records, r)
}
}
const pkg = "sneak.berlin/go/webhooker/internal/"
info := slog.LevelInfo.String()
debug := slog.LevelDebug.String()
assert.Contains(t, records, record{Level: info, Msg: "started"})
assert.Contains(t, records, record{
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
})
assert.Contains(t, records, record{
Level: debug, Msg: "run", Name: pkg + "logger.New()",
})
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
assert.Contains(t, records, record{
Level: debug, Msg: "initialized custom fxevent.Logger",
})
}
// TestRunRefusesLockedDataDir pins what an operator's second start // TestRunRefusesLockedDataDir pins what an operator's second start
// does. The entry point must refuse before it builds the fx graph — // does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds — // nothing may open a database in a DATA_DIR another process holds —
@@ -252,40 +150,22 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and // can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme. // the worst case is not necessarily at either extreme.
// //
// Nor does the hook start on a full budget: the ArchiveSweeper and // Shrinking either budget, or unbounding the flush again, must fail
// RetentionReaper hooks run before it, and whatever they spent is // here rather than silently recreating a hook that swallows the
// gone. The outer sweep walks every amount they can spend. Once they // whole sequence.
// have eaten into the headroom themselves, the hook must spend
// nothing of what is left. A drain that starts on the full budget
// must still get all of ShutdownTimeout, so a smaller stopTimeout
// cannot silently shorten every drain.
//
// Shrinking either budget, or unbounding the drain or the flush
// again, must fail here rather than silently recreating a hook that
// swallows the whole sequence.
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) { func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel() t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout) require.Less(t, server.ShutdownTimeout, stopTimeout)
require.Equal(
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
"a drain that starts on the full stop budget is cut short",
)
const step = 10 * time.Millisecond const step = 10 * time.Millisecond
for spent := time.Duration(0); spent <= stopTimeout; spent += step { for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
remaining := stopTimeout - spent hook := drain + server.SentryFlushBudget(stopTimeout-drain)
longest := max(server.DrainBudget(remaining), 0)
for drain := time.Duration(0); drain <= longest; drain += step { require.LessOrEqual(
hook := drain + server.SentryFlushBudget(remaining-drain) t, hook+tailHeadroom, stopTimeout,
"a %s drain leaves the tail hooks short", drain,
require.GreaterOrEqual( )
t, remaining-hook, min(remaining, tailHeadroom),
"a %s drain after %s of earlier hooks leaves "+
"the tail hooks short", drain, spent,
)
}
} }
} }
-18
View File
@@ -1,18 +0,0 @@
// ESLint configuration for static/js/. script/lint and the image build run
// ESLint in the Dockerfile's js-lint stage, never on the host.
//
// The rules are the ones the JavaScript styleguide linked from
// REPO_POLICIES.md states that a linter can check: const for everything,
// let only for a variable that is reassigned, never var.
export default [
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
{ ignores: ["static/js/alpine.min.js"] },
{
// The pages load static/js/app.js as a classic script, not a module.
languageOptions: { sourceType: "script" },
rules: {
"no-var": "error",
"prefer-const": "error",
},
},
];
+8 -13
View File
@@ -4,9 +4,6 @@ go 1.26.1
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 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/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
@@ -20,7 +17,7 @@ require (
github.com/prometheus/client_model v0.5.0 github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0 github.com/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0 go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
@@ -31,17 +28,14 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect github.com/dustin/go-humanize v1.0.1 // 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/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
@@ -50,12 +44,13 @@ require (
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/dig v1.17.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.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/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
+22 -30
View File
@@ -1,15 +1,14 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -24,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-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 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 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 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= 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= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -64,20 +55,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 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 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 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 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
@@ -94,24 +82,28 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
@@ -119,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+84 -48
View File
@@ -75,12 +75,8 @@ const (
// internet-exposed endpoint. // internet-exposed endpoint.
defaultReceiverRateLimit = 120 defaultReceiverRateLimit = 120
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the // maxPort is the highest valid TCP port number. The lower
// RFC 1918 private ranges, which a reverse proxy reaching the // bound (at least 1) is enforced by envPositiveInt.
// process over a Docker network or a private LAN connects from.
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// maxPort is the highest valid TCP port number.
maxPort = 65535 maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped // mappedV4Offset is the number of leading bits an IPv4-mapped
@@ -104,7 +100,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
var ErrNonPositiveValue = errors.New("value must be positive") var ErrNonPositiveValue = errors.New("value must be positive")
// ErrInvalidPort is returned when an environment variable holding a // ErrInvalidPort is returned when an environment variable holding a
// TCP port number is set to a number outside 1 to 65535. // TCP port number is set above the valid port range.
var ErrInvalidPort = errors.New("invalid port") var ErrInvalidPort = errors.New("invalid port")
// ErrInvalidCIDR is returned when an environment variable holding a // ErrInvalidCIDR is returned when an environment variable holding a
@@ -148,6 +144,7 @@ type ConfigParams struct {
type Config struct { type Config struct {
DataDir string DataDir string
Debug bool Debug bool
MaintenanceMode bool
Environment string Environment string
MetricsPassword string MetricsPassword string
MetricsUsername string MetricsUsername string
@@ -175,14 +172,13 @@ type Config struct {
// TrustedProxies is the set of networks whose members are // TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the // allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it // only forwarded header read. It is empty unless
// is the RFC 1918 private ranges (defaultTrustedProxies); a set // TRUSTED_PROXIES is set, and empty means no peer is
// value replaces them. If any client can reach the process, or // trusted: forwarded headers are then ignored entirely and
// the proxy in front of it, from an RFC 1918 source address // clients are identified by the connection's own address.
// (directly, or through anything that can rewrite source // Members can choose their own rate-limit key, so this must
// addresses, such as NAT or a published container port), it // name proxy hosts only, never a block that also covers
// must be set to the proxy's address alone, or every rate limit // clients.
// can be bypassed by those clients.
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target // AllowedEgressCIDRs is the set of networks a delivery target
@@ -194,13 +190,12 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no // otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's // setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed // alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local, the unspecified addresses // here. That set is link-local plus the cloud metadata
// 0.0.0.0 and ::, and the cloud metadata endpoints outside // endpoints outside it that disclose credentials or user data
// link-local that disclose credentials or user data at a // at a provider-fixed, non-public address; it is not
// provider-fixed, non-public address; it is not exhaustive of // exhaustive of every cloud's metadata address. See
// every cloud's metadata address. See // alwaysBlockedNetworks for the authoritative list and the
// alwaysBlockedNetworks for the authoritative list and why // criterion it is built from.
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix AllowedEgressCIDRs []netip.Prefix
params *ConfigParams params *ConfigParams
@@ -362,27 +357,17 @@ func envPositiveInt(
// envPort returns the value of the named environment variable parsed // envPort returns the value of the named environment variable parsed
// as a TCP port number. Returns defaultValue if not set. A set value // as a TCP port number. Returns defaultValue if not set. A set value
// that is unparseable, below 1, or above maxPort is a hard error // that is unparseable, below 1, or above maxPort is a hard error
// naming the key and the bad value; every out-of-range value wraps // naming the key and the bad value.
// ErrInvalidPort, including one too large or too small for an int.
func envPort(key string, defaultValue int) (int, error) { func envPort(key string, defaultValue int) (int, error) {
v := os.Getenv(key) port, err := envPositiveInt(key, defaultValue)
if v == "" { if err != nil {
return defaultValue, nil return 0, err
} }
// strconv.ErrRange means a number too large or too small for an if port > maxPort {
// int, which is outside the port range as well.
port, err := strconv.Atoi(v)
if err != nil && !errors.Is(err, strconv.ErrRange) {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"invalid integer for %s: %q: %w", key, v, err, "%w: %s must be at most %d, got %d",
) ErrInvalidPort, key, maxPort, port,
}
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf(
"%w: %s must be from 1 to %d, got %q",
ErrInvalidPort, key, maxPort, v,
) )
} }
@@ -475,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
// envPrefixList returns the value of the named environment variable // envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses // parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value is read as defaultValue // allowed). An unset, empty, or blank value yields an empty list. A
// instead. A set value containing an unparseable entry is a hard // set value containing an unparseable entry is a hard error naming
// error naming the key and the bad entry, so startup fails loudly // the key and the bad entry, so startup fails loudly rather than
// rather than silently running with a list the operator did not // silently running with a list the operator did not intend.
// intend. func envPrefixList(key string) ([]netip.Prefix, error) {
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
v = defaultValue return nil, nil
} }
var prefixes []netip.Prefix var prefixes []netip.Prefix
@@ -666,6 +650,11 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration( retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL", "RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval, defaultRetentionSweepInterval,
@@ -692,12 +681,12 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies) trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "") allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -715,6 +704,7 @@ func loadFromEnv() (*Config, error) {
return &Config{ return &Config{
DataDir: DataDir(), DataDir: DataDir(),
Debug: debug, Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment, Environment: environment,
MetricsUsername: metricsUsername, MetricsUsername: metricsUsername,
MetricsPassword: metricsPassword, MetricsPassword: metricsPassword,
@@ -770,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
) )
} }
// warnSharedRateLimitBucket logs a startup warning whenever
// TRUSTED_PROXIES is empty, in any environment.
//
// With no trusted proxies every rate limiter keys on the connecting
// peer's address. Whether that is harmless or dangerous depends on
// what is in front of the process, which this code cannot observe:
// with nothing in front, the peer is the client and the limits are
// per-client as intended; behind a reverse proxy the peer is the proxy
// for every request, so all clients share one bucket per limiter.
//
// The login endpoint no longer spends budget on arrival — it verifies
// credentials first and charges only failures — so a shared bucket
// cannot deny the operator a correct password. What it does collapse
// is the failure counting: one client's wrong passwords throttle
// everyone else's wrong passwords, and the receiver's limits become
// service-wide ceilings.
//
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
// behind a proxy every client shares one bucket in dev and prod alike.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if len(c.TrustedProxies) > 0 {
return
}
log.Warn(
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
"connecting peer's address. With nothing proxying to "+
"this process that is the client itself and the limits "+
"are per-client as intended. Behind a reverse proxy the "+
"peer is the proxy on every request, so all clients "+
"share one bucket per limit: the receiver limits become "+
"service-wide ceilings, and one client's failed logins "+
"throttle every other client's failed logins — a "+
"correct password still gets in. If anything proxies to "+
"this process, set TRUSTED_PROXIES to its address.",
"environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
)
}
// New creates a Config by reading environment variables. // New creates a Config by reading environment variables.
// //
//nolint:revive // lc parameter is required by fx even if unused. //nolint:revive // lc parameter is required by fx even if unused.
@@ -801,6 +835,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// host can reach the admin UI. // host can reach the admin UI.
"bindAddress", s.BindAddress, "bindAddress", s.BindAddress,
"debug", s.Debug, "debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(), "retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here // Logged because a perfectly valid non-positive value here
@@ -814,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"hasMetricsAuth", s.MetricsAuthEnabled(), "hasMetricsAuth", s.MetricsAuthEnabled(),
) )
s.warnSharedRateLimitBucket(log)
s.warnEgressAllowlist(log) s.warnEgressAllowlist(log)
return s, nil return s, nil
+146 -42
View File
@@ -3,6 +3,7 @@ package config_test
import ( import (
"bytes" "bytes"
"log/slog" "log/slog"
"os"
"testing" "testing"
"time" "time"
@@ -70,12 +71,14 @@ func TestEnvironmentConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.envValue != "" { if tt.envValue != "" {
t.Setenv( t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue, "WEBHOOKER_ENVIRONMENT", tt.envValue,
) )
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
for k, v := range tt.envVars { for k, v := range tt.envVars {
@@ -121,11 +124,6 @@ func testEnvironmentConfigSuccess(
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -196,11 +194,14 @@ func TestRetentionSweepInterval(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value) t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RETENTION_SWEEP_INTERVAL",
))
} }
if tt.expectError { if tt.expectError {
@@ -271,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -335,11 +335,14 @@ func TestSessionIdleTimeout(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value) t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"SESSION_IDLE_TIMEOUT",
))
} }
if tt.expectError { if tt.expectError {
@@ -361,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -388,17 +390,20 @@ func TestDefaultDataDir(t *testing.T) {
t.Run("env="+name, func(t *testing.T) { t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if env != "" { if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env) t.Setenv("WEBHOOKER_ENVIRONMENT", env)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
require.NoError(t, os.Unsetenv("DATA_DIR"))
var cfg *config.Config var cfg *config.Config
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -433,9 +438,9 @@ func TestDataDirHelper(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t) if set == "" {
require.NoError(t, os.Unsetenv("DATA_DIR"))
if set != "" { } else {
t.Setenv("DATA_DIR", set) t.Setenv("DATA_DIR", set)
} }
@@ -498,11 +503,14 @@ func TestReceiverRateLimit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value) t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
} }
if tt.expectError { if tt.expectError {
@@ -526,7 +534,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -544,11 +551,6 @@ func testReceiverRateLimitSuccess(
} }
func TestTrustedProxies(t *testing.T) { func TestTrustedProxies(t *testing.T) {
// Unset, the RFC 1918 private ranges are trusted, so a reverse
// proxy on a Docker network or a private LAN is covered without
// configuration.
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
tests := []struct { tests := []struct {
name string name string
set bool set bool
@@ -557,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
expected []string expected []string
}{ }{
{ {
// The default must be "trust nobody": an empty list
// means forwarded headers are ignored, never that
// every peer may speak for the client.
name: caseUnsetUsesDefault, name: caseUnsetUsesDefault,
set: false, set: false,
expected: defaultProxies, expected: []string{},
}, },
{ {
name: "blank value uses default", name: "blank value trusts nothing",
set: true, set: true,
value: " ", value: " ",
expected: defaultProxies, expected: []string{},
},
{
name: "set value replaces the default entirely",
set: true,
value: "203.0.113.7",
expected: []string{"203.0.113.7/32"},
}, },
{ {
name: caseValidValueParsed, name: caseValidValueParsed,
@@ -614,11 +613,12 @@ func TestTrustedProxies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("TRUSTED_PROXIES", tt.value) t.Setenv("TRUSTED_PROXIES", tt.value)
} else {
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
} }
if tt.expectError { if tt.expectError {
@@ -642,7 +642,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -725,11 +724,14 @@ func TestAllowedEgressCIDRs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value) t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
} else {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} }
if tt.expectError { if tt.expectError {
@@ -753,7 +755,6 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -797,10 +798,13 @@ func TestEgressAllowlistWarning(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed != "" { if tt.allowed == "" {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} else {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed) t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
} }
@@ -841,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
} }
} }
// TestSharedRateLimitBucketWarning covers the startup warning that
// tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver
// limits into service-wide ceilings and collapses login failure
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
// environment, because behind a proxy every client shares one bucket
// in dev and prod alike. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct {
name string
environment string
trustedProxies string
expectWarning bool
}{
{
name: "prod without trusted proxies warns",
environment: config.EnvironmentProd,
expectWarning: true,
},
{
name: "prod with trusted proxies is quiet",
environment: config.EnvironmentProd,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
{
name: "dev without trusted proxies warns",
environment: config.EnvironmentDev,
expectWarning: true,
},
{
name: "dev with trusted proxies is quiet",
environment: config.EnvironmentDev,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
if tt.trustedProxies == "" {
require.NoError(
t, os.Unsetenv("TRUSTED_PROXIES"),
)
} else {
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
},
))
require.NoError(
t,
config.WarnSharedRateLimitBucketForTest(log),
)
if !tt.expectWarning {
assert.Empty(t, buf.String())
return
}
logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "TRUSTED_PROXIES")
assert.Contains(t, logged, "share one bucket")
assert.Contains(
t, logged, "throttle every other client's failed logins",
)
// The warning must not claim a lockout the login
// endpoint no longer permits: credentials are verified
// before any budget is spent.
assert.Contains(
t, logged, "a correct password still gets in",
)
// The text must stay accurate for a developer with
// nothing in front of the process, where an empty
// list costs nothing.
assert.Contains(
t, logged, "nothing proxying to this process",
)
})
}
}
// metricsEnv describes what one subtest below puts in the // metricsEnv describes what one subtest below puts in the
// environment for a single METRICS_ variable. A variable that is // environment for a single METRICS_ variable. A variable that is
// set to the empty string and one that is not set at all are // set to the empty string and one that is not set at all are
@@ -933,14 +1032,20 @@ func TestMetricsAuthConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.username.set { if tt.username.set {
t.Setenv("METRICS_USERNAME", tt.username.value) t.Setenv("METRICS_USERNAME", tt.username.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_USERNAME"),
)
} }
if tt.password.set { if tt.password.set {
t.Setenv("METRICS_PASSWORD", tt.password.value) t.Setenv("METRICS_PASSWORD", tt.password.value)
} else {
require.NoError(
t, os.Unsetenv("METRICS_PASSWORD"),
)
} }
if tt.expectError { if tt.expectError {
@@ -988,7 +1093,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New), fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg), fx.Populate(&cfg),
) )
+18 -7
View File
@@ -22,6 +22,17 @@ const malformedDotEnv = "PORT 19615\n" +
"this is not = valid ! syntax\n" + "this is not = valid ! syntax\n" +
"\"unclosed\n" "\"unclosed\n"
// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of
// the test and restores it afterwards. t.Setenv registers the restore;
// the Unsetenv that follows is what the test actually needs, because a
// variable set to the empty string is still present in os.Environ and
// godotenv would refuse to overwrite it.
func unsetDotEnvKey(t *testing.T) {
t.Helper()
t.Setenv(dotEnvKey, "placeholder")
require.NoError(t, os.Unsetenv(dotEnvKey))
}
// writeDotEnv writes contents to a .env file in a fresh temporary // writeDotEnv writes contents to a .env file in a fresh temporary
// directory and returns its path. // directory and returns its path.
func writeDotEnv(t *testing.T, contents string) string { func writeDotEnv(t *testing.T, contents string) string {
@@ -39,9 +50,9 @@ func writeDotEnv(t *testing.T, contents string) string {
// normally rather than be refused for a file it was never meant to // normally rather than be refused for a file it was never meant to
// have. // have.
// //
//nolint:paralleltest // ClearEnvForTest uses t.Setenv. //nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
config.ClearEnvForTest(t) unsetDotEnvKey(t)
absent := filepath.Join(t.TempDir(), config.DotEnvPath) absent := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, config.LoadDotEnvFileForTest(absent)) require.NoError(t, config.LoadDotEnvFileForTest(absent))
@@ -54,9 +65,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
// reaches the environment, which is the whole reason the file is read // reaches the environment, which is the whole reason the file is read
// at all. // at all.
// //
//nolint:paralleltest // ClearEnvForTest uses t.Setenv. //nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) { func TestLoadDotEnv_AppliesValues(t *testing.T) {
config.ClearEnvForTest(t) unsetDotEnvKey(t)
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n") path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
@@ -82,9 +93,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
// reverts to its default; the process used to start that way with no // reverts to its default; the process used to start that way with no
// log line naming the file at all. // log line naming the file at all.
// //
//nolint:paralleltest // ClearEnvForTest uses t.Setenv. //nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
config.ClearEnvForTest(t) unsetDotEnvKey(t)
path := writeDotEnv( path := writeDotEnv(
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
@@ -132,7 +143,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
// //
//nolint:paralleltest // t.Chdir moves the whole process. //nolint:paralleltest // t.Chdir moves the whole process.
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) { func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
config.ClearEnvForTest(t) unsetDotEnvKey(t)
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
+103 -81
View File
@@ -1,6 +1,7 @@
package config_test package config_test
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -17,9 +18,10 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
// Real configuration variables exercised by the config.New tests. // Real configuration variables exercised by the config.New tests.
const ( const (
envKeyPort = "PORT" envKeyPort = "PORT"
envKeyDebug = "DEBUG" envKeyDebug = "DEBUG"
envKeyBindAddress = "BIND_ADDRESS" envKeyMaintenanceMode = "MAINTENANCE_MODE"
envKeyBindAddress = "BIND_ADDRESS"
) )
// Sample BIND_ADDRESS values used by the tables below. // Sample BIND_ADDRESS values used by the tables below.
@@ -120,10 +122,10 @@ func TestEnvBool(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBoolForTest( got, err := config.EnvBoolForTest(
@@ -144,62 +146,17 @@ func TestEnvBool(t *testing.T) {
} }
} }
// envIntCase is one row of the envPositiveInt and envPort tables.
type envIntCase struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}
// runEnvIntCases runs each row through parse, which is
// envPositiveInt or envPort, with testEnvKey set to the row's value
// or left unset.
func runEnvIntCases(
t *testing.T,
parse func(key string, defaultValue int) (int, error),
defaultValue int,
tests []envIntCase,
) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := parse(testEnvKey, defaultValue)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPositiveInt(t *testing.T) { func TestEnvPositiveInt(t *testing.T) {
const defaultValue = 7 const defaultValue = 7
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{ tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{ {
name: "unset returns the default integer", name: "unset returns the default integer",
expected: defaultValue, expected: defaultValue,
@@ -236,14 +193,51 @@ func TestEnvPositiveInt(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrNonPositiveValue, errIs: config.ErrNonPositiveValue,
}, },
}) }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPositiveIntForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
} }
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPort(t *testing.T) { func TestEnvPort(t *testing.T) {
const defaultValue = 8080 const defaultValue = 8080
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{ tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{ {
name: "unset returns the default port", name: "unset returns the default port",
expected: defaultValue, expected: defaultValue,
@@ -271,14 +265,7 @@ func TestEnvPort(t *testing.T) {
set: true, set: true,
value: "0", value: "0",
expectError: true, expectError: true,
errIs: config.ErrInvalidPort, errIs: config.ErrNonPositiveValue,
},
{
name: "negative is rejected",
set: true,
value: "-1",
expectError: true,
errIs: config.ErrInvalidPort,
}, },
{ {
name: "above the port range is rejected", name: "above the port range is rejected",
@@ -287,14 +274,37 @@ func TestEnvPort(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrInvalidPort, errIs: config.ErrInvalidPort,
}, },
{ }
name: "too large for an int is rejected",
set: true, for _, tt := range tests {
value: "99999999999999999999", t.Run(tt.name, func(t *testing.T) {
expectError: true, // Cannot use t.Parallel() here because t.Setenv
errIs: config.ErrInvalidPort, // is incompatible with parallel subtests.
}, if tt.set {
}) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPortForTest(
testEnvKey, defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
} }
// TestEnvBindAddress covers BIND_ADDRESS parsing. // TestEnvBindAddress covers BIND_ADDRESS parsing.
@@ -310,10 +320,10 @@ func TestEnvBindAddress(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBindAddressForTest( got, err := config.EnvBindAddressForTest(
@@ -476,7 +486,6 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
t.Setenv(tt.key, tt.value) t.Setenv(tt.key, tt.value)
@@ -595,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
value: "ture", value: "ture",
expectError: true, expectError: true,
}, },
{
name: "unparseable MAINTENANCE_MODE aborts startup",
key: envKeyMaintenanceMode,
value: "sometimes",
expectError: true,
},
} }
} }
@@ -638,15 +653,22 @@ func sentryEnvValueCases() []badEnvValueCase {
// break the legitimate unset case: absent variables still get their // break the legitimate unset case: absent variables still get their
// documented defaults. // documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) { func TestNewUsesDefaultsWhenUnset(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
envKeyBindAddress, envKeySentryDSN,
} {
require.NoError(t, os.Unsetenv(key))
}
cfg, err := buildConfig(t) cfg, err := buildConfig(t)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.Equal(t, 8080, cfg.Port) assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug) assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
// Loopback, not the wildcard: the default must not publish the // Loopback, not the wildcard: the default must not publish the
// cleartext admin UI and the unauthenticated receiver on every // cleartext admin UI and the unauthenticated receiver on every
+15
View File
@@ -6,6 +6,21 @@ import "log/slog"
// the external config_test package so each helper can be covered by // the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API. // its own table-driven test without weakening the package API.
// WarnSharedRateLimitBucketForTest loads a Config from the current
// environment and emits its startup warnings to log. The real logger
// writes to stdout, so this lets the warning's firing condition be
// asserted against a handler the test controls.
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnSharedRateLimitBucket(log)
return nil
}
// WarnEgressAllowlistForTest loads a Config from the current // WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to // environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when // log, so a test can assert both that the warning fires only when
+3 -2
View File
@@ -1,6 +1,7 @@
package config_test package config_test
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -100,10 +101,10 @@ func TestEnvSentryDSN(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(envKeySentryDSN, tt.value) t.Setenv(envKeySentryDSN, tt.value)
} else {
require.NoError(t, os.Unsetenv(envKeySentryDSN))
} }
got, err := config.EnvSentryDSNForTest(envKeySentryDSN) got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
-50
View File
@@ -1,50 +0,0 @@
package config
import (
"os"
"strings"
"testing"
)
// ClearEnvForTest unsets every variable in the process environment
// for the rest of the test, so a test sees only the variables it sets
// itself, not whatever the developer's shell exports. When the test
// ends it leaves the environment exactly as it found it: each variable
// it unset is put back, and any variable added since is removed.
func ClearEnvForTest(t *testing.T) {
t.Helper()
present := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
present[key] = true
// t.Setenv registers the restore; the Unsetenv after it is
// what makes the key absent, since a key set to the empty
// string is still present, and godotenv will not overwrite a
// present key.
t.Setenv(key, "")
err := os.Unsetenv(key)
if err != nil {
t.Fatalf("unsetting %s: %v", key, err)
}
}
// A variable the test adds other than through t.Setenv, as loading
// a .env file does, has no restore of its own.
t.Cleanup(func() {
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if present[key] {
continue
}
err := os.Unsetenv(key)
if err != nil {
t.Errorf("unsetting %s: %v", key, err)
}
}
})
}
-36
View File
@@ -1,36 +0,0 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
// after the clear other than through t.Setenv, as a test's .env file
// sets one, is gone once the test ends, so it cannot reach the tests
// that run after it.
//
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
// The outer clear keeps a value of the key exported in the shell
// from making it a variable the inner clear has to put back.
config.ClearEnvForTest(t)
t.Run("loads a .env file after the clear", func(t *testing.T) {
config.ClearEnvForTest(t)
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
})
_, present := os.LookupEnv(dotEnvKey)
assert.False(
t, present,
"a variable set after the clear must not outlive the test",
)
}
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -120,26 +119,3 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t, second, created, "an existing database is not new", t, second, created, "an existing database is not new",
) )
} }
// TestZeroLengthDatabase_IsLoggedAsNew covers what
// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a
// zero-length file as an empty database, so a start on one is a first
// start, and it must say so exactly as a start with no file does.
func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm))
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
assert.Contains(
t, out.String(),
`level=WARN msg="created a new, empty database" path=`+path,
)
}
+7 -12
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -202,7 +203,8 @@ func (d *Database) connectTo(dataDir string) error {
// Checked before opening, which creates the file. A DATA_DIR that // Checked before opening, which creates the file. A DATA_DIR that
// is unexpectedly empty -- its volume not mounted, say -- looks // is unexpectedly empty -- its volume not mounted, say -- looks
// exactly like a first start, so a new database is a warning. // exactly like a first start, so a new database is a warning.
created := missingOrEmpty(dbPath) _, statErr := os.Stat(dbPath)
created := errors.Is(statErr, fs.ErrNotExist)
// Opened through OpenSQLite so this handle carries the same WAL // Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool // journaling, busy timeout, immediate-transaction locking, and pool
@@ -211,15 +213,13 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to open database", "failed to open database",
"path", dbPath,
"error", err, "error", err,
) )
return err return err
} }
// Then use it with GORM. Its errors are SQLite's alone and name no // Then use it with GORM
// file, so the path is added to them here.
db, err := gorm.Open(sqlite.Dialector{ db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB, Conn: sqlDB,
}, &gorm.Config{ }, &gorm.Config{
@@ -229,11 +229,10 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to connect to database", "failed to connect to database",
"path", dbPath,
"error", err, "error", err,
) )
return fmt.Errorf("connecting to %s: %w", dbPath, err) return err
} }
d.db = db d.db = db
@@ -244,12 +243,8 @@ func (d *Database) connectTo(dataDir string) error {
d.log.Info("connected to database", "path", dbPath) d.log.Info("connected to database", "path", dbPath)
} }
err = d.migrate() // Run migrations
if err != nil { return d.migrate()
return fmt.Errorf("migrating %s: %w", dbPath, err)
}
return nil
} }
func (d *Database) migrate() error { func (d *Database) migrate() error {
-25
View File
@@ -1,15 +1,9 @@
package database_test package database_test
import ( import (
"bytes"
"context" "context"
"log/slog"
"os"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -106,22 +100,3 @@ func TestDatabaseConnection(t *testing.T) {
) )
} }
} }
// TestOpen_UnreadableDatabaseIsNamed pins
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
// read webhooker.db, the error that stops the server and `webhooker
// resetpw` names the file, not only SQLite's own message.
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
))
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.Contains(t, err.Error(), path)
assert.Contains(t, err.Error(), "file is not a database")
}
+17 -199
View File
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
deliveries []database.Delivery deliveries []database.Delivery
results []database.DeliveryResult results []database.DeliveryResult
depths []struct{ Depth int } depths []struct{ Depth int }
removed []database.TargetTotals
) )
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
// The delivery engine: recovery and the retry sweep, the sweep for // The delivery engine: recovery and the retry sweep, the sweep for
// stranded pending deliveries, and the queue depth count. // stranded pending deliveries, and the queue depth count.
@@ -123,207 +123,25 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Order("attempt_num ASC").Find(&results), Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)") "idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention (reapExpired, deleteEvents): one batch of expired // Retention's three deletes (reapExpired), whose subqueries are built
// events, then their attempts, deliveries and the events. // afresh for each statement as it builds them.
var expired []string expiredEventIDs := func() *gorm.DB {
return dry.Model(&database.Event{}).Select("id").
Where("created_at < ?", cutoff)
}
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
Where("created_at < ?", cutoff).
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
"idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where( assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}). "delivery_id IN (?)", dry.Model(&database.Delivery{}).
Select("id").Where("event_id IN ?", ids), Select("id").Where("event_id IN (?)", expiredEventIDs()),
).Delete(&database.DeliveryResult{}), ).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)", "idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
"idx_deliveries_event_id (event_id=?)") assertPlanUses(t, db, dry.Unscoped().Where(
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}). "event_id IN (?)", expiredEventIDs(),
Select("target_id, count(*) AS deliveries_removed, "+ ).Delete(&database.Delivery{}),
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed", "idx_deliveries_event_id (event_id=?)", byAge)
database.DeliveryStatusFailed). assertPlanUses(t, db, dry.Unscoped().Where(
Where("event_id IN ?", ids).Group("target_id").Find(&removed), "created_at < ?", cutoff,
"idx_deliveries_event_id (event_id=?)") ).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
}
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the newest events with a delivery in
// given statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers). The lists must also reach
// the events table only by ID: from the matching deliveries, then from
// the newest of those events.
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
rows []struct{ ID string }
count int64
)
matching := dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending)
newest := dry.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(50).
Select("events.id AS event_id")
// Each step of the plan is printed in braces, so these name the
// lookup that follows each scan.
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id").
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
// come from the index alone, and events received since a time.
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
since := time.Now()
var (
count int64
byTarget []struct{ TargetID string }
)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).Count(&count),
"idx_deliveries_status (status=? AND deleted_at=?)")
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").Find(&byTarget),
"COVERING INDEX idx_deliveries_status "+
"(status=? AND deleted_at=? AND finished_at>?)")
assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("created_at >= ?", since).Count(&count),
"idx_events_deleted_at_created_at "+
"(deleted_at=? AND created_at>?)")
}
// TestResubmitCountUsesItsIndex does the same for the event log's count
// of the events resubmitted from each of a page's events (resubmitCounts
// in the handlers). It passes a full page of 25 ids: with an index on
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
// the deleted_at index from five.
func TestResubmitCountUsesItsIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
page := make([]string, 25)
for i := range page {
page[i] = uuid.New().String()
}
var counts []struct{ Total int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", page).
Group("resubmitted_from_id").Find(&counts),
"idx_events_resubmitted_from_id "+
"(resubmitted_from_id=? AND deleted_at=?)")
}
// TestEntrypointEventsUseTheirIndex does the same for the webhook
// page's count, for each entrypoint, of the events that arrived on its
// URL since the retention cutoff (addEntrypointEvents in the
// handlers), which must come from the index alone. It passes 25
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
entrypoints := make([]string, 25)
for i := range entrypoints {
entrypoints[i] = uuid.New().String()
}
var rows []struct{ Events int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
entrypoints).
Where("created_at >= ?", time.Now()).
Group("entrypoint_id").Find(&rows),
"COVERING INDEX idx_events_entrypoint_id "+
"(entrypoint_id=? AND deleted_at=? AND "+
"resubmitted_from_id=? AND created_at>?)")
} }
// assertPlanUses asserts that SQLite's plan for a statement GORM built // assertPlanUses asserts that SQLite's plan for a statement GORM built
-16
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"testing"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -29,10 +28,6 @@ func NewTestRetentionReaper(
} }
} }
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests. // ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) { func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx) r.sweep(ctx)
@@ -84,14 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
func DummyPasswordHashForTest() string { func DummyPasswordHashForTest() string {
return dummyPasswordHash() return dummyPasswordHash()
} }
// HashAtShippedCostForTest makes HashPassword hash at the shipped
// memory cost until t ends. t must not run in parallel with other
// tests, which would hash at that cost alongside it.
func HashAtShippedCostForTest(t *testing.T) {
t.Helper()
hashAtShippedCostInTest = true
t.Cleanup(func() { hashAtShippedCostInTest = false })
}
+2 -3
View File
@@ -15,7 +15,6 @@ type APIKey struct {
Description string `json:"description"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations
// User.APIKeys leads back here, and the JSON could loop. User User `json:"user,omitzero"`
User User `json:"-"`
} }
+5 -22
View File
@@ -1,10 +1,6 @@
package database package database
import ( import "gorm.io/gorm"
"time"
"gorm.io/gorm"
)
// DeliveryStatus represents the status of a delivery // DeliveryStatus represents the status of a delivery
type DeliveryStatus string type DeliveryStatus string
@@ -41,7 +37,7 @@ type Delivery struct {
BaseModel BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"` TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column // DeletedAt repeats the BaseModel field only to be the second column
@@ -49,21 +45,8 @@ type Delivery struct {
// gives. // gives.
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"` DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// FinishedAt is when the delivery became delivered or failed, and // Relations
// nil while it is pending or retrying. It and then TargetID end the Event Event `json:"event,omitzero"`
// status index, so the webhook page counts each target's deliveries Target Target `json:"target,omitzero"`
// that finished in a recent window by reading just that window from
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Replay is set on a delivery created by the event log's Replay
// action, so the pages can tell it from the delivery it repeats.
Replay bool `gorm:"not null;default:false" json:"replay"`
// 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:"-"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
} }
+2 -3
View File
@@ -23,7 +23,6 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations. No model marshals the record it belongs to: // Relations
// Delivery.DeliveryResults leads back here, and the JSON could loop. Delivery Delivery `json:"delivery,omitzero"`
Delivery Delivery `json:"-"`
} }
+2 -3
View File
@@ -15,7 +15,6 @@ type Entrypoint struct {
Description string `json:"description"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Relations. No model marshals the record it belongs to: // Relations
// Webhook.Entrypoints leads back here, and the JSON could loop. Webhook Webhook `json:"webhook,omitzero"`
Webhook Webhook `json:"-"`
} }
+8 -19
View File
@@ -19,16 +19,11 @@ type Event struct {
// narrows by a < only on the last column it uses. Its final delete // narrows by a < only on the last column it uses. Its final delete
// has no deleted_at condition and uses the index on created_at // has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at. // alone. The other tables keep the unindexed BaseModel created_at.
// DeletedAt is also the second column of the resubmitted_from_id CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
// index, for the reason DeliveryResult gives. The entrypoint_id DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
// index, for the webhook page's entrypoint list, has it second too,
// resubmitted_from_id third, and created_at last, which the list
// compares with a range.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
// Request data // Request data
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
@@ -36,22 +31,16 @@ type Event struct {
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored, so that the recent events list, which reads only the
// start of each body, knows the whole body's size.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// ResubmittedFromID names the event this one was copied from by // ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on // an operator resubmit. It is nil for an event that arrived on
// the receiver, which is every event created before the column // the receiver, which is every event created before the column
// existed. It is not a foreign key: the source event can be // existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is // reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so // Relations
// Webhook and Entrypoint are left out of the JSON. Webhook Webhook `json:"webhook,omitzero"`
Webhook Webhook `json:"-"` Entrypoint Entrypoint `json:"entrypoint,omitzero"`
Entrypoint Entrypoint `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
-126
View File
@@ -1,126 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
// Each child holds the parent's id as its webhookId, so the parent
// is looked for by its own id field.
parentIDField := `"id":"` + stored.ID + `"`
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, parentIDField)
}
// TestModelsMarshalWithoutTheirParent covers the other references to a
// parent: each model is built with its parent set, and the parent's id
// must not appear in the JSON.
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
parent := database.BaseModel{ID: uuid.New().String()}
cases := []struct {
name string
model any
}{
{
name: "Webhook.User",
model: database.Webhook{User: database.User{BaseModel: parent}},
},
{
name: "APIKey.User",
model: database.APIKey{User: database.User{BaseModel: parent}},
},
{
name: "Delivery.Event",
model: database.Delivery{Event: database.Event{BaseModel: parent}},
},
{
name: "Delivery.Target",
model: database.Delivery{Target: database.Target{BaseModel: parent}},
},
{
name: "DeliveryResult.Delivery",
model: database.DeliveryResult{
Delivery: database.Delivery{BaseModel: parent},
},
},
{
name: "Event.Webhook",
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
},
{
name: "Event.Entrypoint",
model: database.Event{
Entrypoint: database.Entrypoint{BaseModel: parent},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
})
}
}
+4 -4
View File
@@ -31,10 +31,10 @@ type Target struct {
// For HTTP targets (max_retries=0 means fire-and-forget, // For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff) // >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations
// Webhook.Targets leads back here, and the JSON could loop. Webhook Webhook `json:"webhook,omitzero"`
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
-136
View File
@@ -1,136 +0,0 @@
package database
import (
"fmt"
"time"
"gorm.io/gorm"
)
// The running totals in a webhook's event database keep the webhook
// page's lifetime figures right after retention has removed the rows
// they count, and let the page show them without counting every row.
// Each total changes in the transaction that writes or deletes the
// rows it counts.
// EventTotals is the single row counting a webhook's events: every
// event ever stored, how many of them retention has deleted, and when
// the newest arrived, which retention leaves as it is.
type EventTotals struct {
ID int64 `gorm:"primaryKey"`
Events int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
// LastEventAt is when the newest event arrived, or nil before the
// first.
LastEventAt *time.Time
}
// TableName names the table AddEventTotals updates.
func (EventTotals) TableName() string {
return "event_totals"
}
// TargetTotals is one row per target counting its deliveries: every
// delivery ever created, how many became delivered and how many
// failed, and how many deliveries and failed deliveries retention has
// deleted. The webhook's delivery figures are these rows summed.
type TargetTotals struct {
TargetID string `gorm:"type:uuid;primaryKey"`
Deliveries int64 `gorm:"not null"`
Delivered int64 `gorm:"not null"`
Failed int64 `gorm:"not null"`
DeliveriesRemoved int64 `gorm:"not null"`
FailedRemoved int64 `gorm:"not null"`
}
// TableName names the table AddTargetTotals updates.
func (TargetTotals) TableName() string {
return "target_totals"
}
// EntrypointTotals is one row per entrypoint, created by the first
// event that arrives on its URL: when the newest such event arrived,
// which retention leaves as it is. A resubmitted copy did not arrive
// on the URL and does not change it.
type EntrypointTotals struct {
EntrypointID string `gorm:"type:uuid;primaryKey"`
LastEventAt time.Time `gorm:"not null"`
}
// TableName names the table AddEntrypointTotals updates.
func (EntrypointTotals) TableName() string {
return "entrypoint_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it
// counts.
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
err := tx.Exec(
`UPDATE event_totals SET
events = events + ?,
events_removed = events_removed + ?,
last_event_at = coalesce(?, last_event_at)`,
add.Events, add.EventsRemoved, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf("adding to event totals: %w", err)
}
return nil
}
// AddTargetTotals adds each count in add to the totals of the target
// add.TargetID names, creating its row the first time. Call it on the
// transaction that writes or deletes the deliveries it counts.
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
err := tx.Exec(
`INSERT INTO target_totals (target_id, deliveries, delivered,
failed, deliveries_removed, failed_removed)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (target_id) DO UPDATE SET
deliveries = deliveries + excluded.deliveries,
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
deliveries_removed =
deliveries_removed + excluded.deliveries_removed,
failed_removed = failed_removed + excluded.failed_removed`,
add.TargetID, add.Deliveries, add.Delivered,
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of target %s: %w", add.TargetID, err,
)
}
return nil
}
// AddEntrypointTotals records add.LastEventAt as when the newest event
// arrived on the URL of the entrypoint add.EntrypointID names, creating
// its row the first time. Call it on the transaction that stores the
// event.
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
err := tx.Exec(
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
VALUES (?, ?)
ON CONFLICT (entrypoint_id) DO UPDATE SET
last_event_at = excluded.last_event_at`,
add.EntrypointID, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of entrypoint %s: %w",
add.EntrypointID, err,
)
}
return nil
}
+2 -10
View File
@@ -66,9 +66,8 @@ type Webhook struct {
// must equal DefaultRetentionDays. // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"` RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations. No model marshals the record it belongs to: // Relations
// User.Webhooks leads back here, and the JSON could loop. User User `json:"user,omitzero"`
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
@@ -111,13 +110,6 @@ func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays) return retainsForever(w.RetentionDays)
} }
// RetentionCutoff returns the time before which this webhook's events
// have expired, as the reaper computes it, and false when the webhook
// retains them forever.
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
return retentionCutoff(now, w.RetentionDays)
}
// RetentionLabel returns the webhook's retention policy as display // RetentionLabel returns the webhook's retention policy as display
// text, so that no template has to know about the sentinel value. // text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
+1 -2
View File
@@ -2,8 +2,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult) live in
// TargetTotals, EntrypointTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+1 -22
View File
@@ -9,7 +9,6 @@ import (
"math/big" "math/big"
"strings" "strings"
"sync" "sync"
"testing"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
} }
} }
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test // HashPassword generates an Argon2id hash of the password
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig() config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt // Generate a salt
salt := make([]byte, config.SaltLen) salt := make([]byte, config.SaltLen)
-33
View File
@@ -192,39 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
} }
} }
// TestHashPassword_ShippedParameters hashes and verifies through
// HashPassword at the shipped Argon2id parameters. Every other test
// hashes at the lower memory cost a test binary uses, so this is the
// one that keeps production hashing covered. One hash and one
// verification: each costs 64 MB.
//
//nolint:paralleltest // changes the hashing cost for the whole binary
func TestHashPassword_ShippedParameters(t *testing.T) {
database.HashAtShippedCostForTest(t)
password := "correct horse battery staple"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("hashing with the shipped parameters: %v", err)
}
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
if !strings.HasPrefix(hash, shipped) {
t.Errorf("hash = %q, want prefix %q", hash, shipped)
}
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration // TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a // path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in // dummy hash so that a nonexistent account is not answered in
+63 -120
View File
@@ -18,19 +18,6 @@ import (
// computation. // computation.
const hoursPerDay = 24 const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// reapBatchPause is how long retention waits after one batch before
// starting the next. A writer waiting for the write lock checks for it
// again after at most 100 ms, so a longer pause lets it in between two
// batches instead of only after the whole prune.
const reapBatchPause = 200 * time.Millisecond
// RetentionReaperParams holds the fx dependencies for the // RetentionReaperParams holds the fx dependencies for the
// RetentionReaper. // RetentionReaper.
type RetentionReaperParams struct { type RetentionReaperParams struct {
@@ -184,31 +171,32 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// A missing database has nothing to reap. Restart recovery // Skip retain-forever webhooks before building any query.
// reports a lost one (see WebhookDBManager.GetDB). // 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) { if !r.dbManager.DBExists(wh.ID) {
continue continue
} }
r.reapWebhook(ctx, wh.ID, wh.RetentionDays) r.reapWebhook(wh.ID, wh.RetentionDays)
} }
} }
// reapWebhook removes every expired event (and its dependents) from a // reapWebhook removes every expired event (and its dependents) from a
// single webhook's database, or as many as it reaches before ctx is // single webhook's database.
// cancelled.
func (r *RetentionReaper) reapWebhook( func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string, webhookID string,
retentionDays int, 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) db, err := r.dbManager.GetDB(webhookID)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
@@ -220,7 +208,12 @@ func (r *RetentionReaper) reapWebhook(
return return
} }
deleted, err := reapExpired(ctx, db, cutoff) cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
deleted, err := reapExpired(db, cutoff)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
"retention sweep: failed to reap expired events", "retention sweep: failed to reap expired events",
@@ -272,107 +265,57 @@ func retentionCutoff(
), true ), true
} }
// reapExpired hard-deletes the events older than cutoff, with their // reapExpired hard-deletes, in foreign-key-safe order, the delivery
// deliveries and delivery results, reapBatchSize events per // results, deliveries, and events associated with events older than
// transaction with reapBatchPause between transactions, until none is // cutoff. Deletes are unscoped so rows are physically removed rather
// left. Once ctx is cancelled it returns after the batch in hand, // than soft-deleted, reclaiming disk. It returns the number of events
// leaving the rest to the next sweep, so stopping the app does not // deleted.
// wait for a long prune. It returns the number of events deleted. func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
func reapExpired( // Fresh subqueries are built per statement to avoid reusing a
ctx context.Context, db *gorm.DB, cutoff time.Time, // mutated builder across executions.
) (int64, error) { expiredEventIDs := func() *gorm.DB {
var total int64 return db.Model(&Event{}).
for {
var eventIDs []string
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Unscoped().Model(&Event{}).
Where("created_at < ?", cutoff).
Limit(reapBatchSize).
Pluck("id", &eventIDs).Error
if err != nil {
return fmt.Errorf("selecting expired events: %w", err)
}
if len(eventIDs) == 0 {
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
select {
case <-ctx.Done():
return total, nil
case <-time.After(reapBatchPause):
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
Select("id"). Select("id").
Where("event_id IN ?", eventIDs)). Where("created_at < ?", cutoff)
Delete(&DeliveryResult{}).Error }
if err != nil { expiredDeliveryIDs := func() *gorm.DB {
return fmt.Errorf("deleting expired delivery results: %w", err) return db.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
} }
// 2. The events' deliveries, after counting them, and the failed // 1. Delivery results whose delivery belongs to an expired event.
// ones among them, per target. The status is tested in the select res := db.Unscoped().
// list rather than the WHERE clause: there, SQLite would read every Where("delivery_id IN (?)", expiredDeliveryIDs()).
// failed delivery the webhook has through the status index, Delete(&DeliveryResult{})
// instead of only these through the event_id index. if res.Error != nil {
var removed []TargetTotals return 0, fmt.Errorf(
"deleting expired delivery results: %w",
err = tx.Unscoped().Model(&Delivery{}). res.Error,
Select("target_id, count(*) AS deliveries_removed, "+ )
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
} }
err = tx.Unscoped(). // 2. Deliveries belonging to an expired event.
Where("event_id IN ?", eventIDs). del := db.Unscoped().
Delete(&Delivery{}).Error Where("event_id IN (?)", expiredEventIDs()).
if err != nil { Delete(&Delivery{})
return fmt.Errorf("deleting expired deliveries: %w", err) if del.Error != nil {
return 0, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
} }
// 3. The events themselves. // 3. The expired events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{}) ev := db.Unscoped().
Where("created_at < ?", cutoff).
Delete(&Event{})
if ev.Error != nil { if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error) return 0, fmt.Errorf(
"deleting expired events: %w",
ev.Error,
)
} }
for i := range removed { return ev.RowsAffected, nil
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
} }
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t, t,
overflowingRetentionDays, overflowingRetentionDays,
database.RetentionForeverDays, 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( webhookID := createWebhook(
-23
View File
@@ -182,29 +182,6 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
requireDatabaseSetOwnerOnly(t, path) requireDatabaseSetOwnerOnly(t, path)
} }
// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place
// of -wal or -shm. Beside a -shm directory SQLite opens the database
// read-only without a word, and every write then fails naming no file,
// so the open must stop instead, naming the directory.
func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) {
t.Parallel()
for _, suffix := range []string{"-wal", "-shm"} {
t.Run(suffix, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), database.MainDBFileName)
require.NoError(t, os.Mkdir(path+suffix, 0o700))
_, err := database.OpenSQLite(
path, database.SQLiteModeCreate,
)
require.Error(t, err)
assert.Contains(t, err.Error(), path+suffix)
})
}
}
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism // TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
// the fix uses: OpenSQLite now creates the database file itself, and // the fix uses: OpenSQLite now creates the database file itself, and
// must not do so for a caller that asked for an existing database. An // must not do so for a caller that asked for an existing database. An
+2 -26
View File
@@ -7,7 +7,6 @@ import (
"io/fs" "io/fs"
"net/url" "net/url"
"os" "os"
"syscall"
"time" "time"
_ "modernc.org/sqlite" // Pure Go SQLite driver _ "modernc.org/sqlite" // Pure Go SQLite driver
@@ -94,8 +93,7 @@ const (
const SQLiteFilePerm fs.FileMode = 0o600 const SQLiteFilePerm fs.FileMode = 0o600
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever // reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
// touches it, and tightens any sidecar already on disk. A directory in // touches it, and tightens any sidecar already on disk.
// place of any of them is an error naming it.
// //
// The mode has to be settled here rather than by a chmod after opening, // The mode has to be settled here rather than by a chmod after opening,
// because SQLite picks it: robust_open substitutes // because SQLite picks it: robust_open substitutes
@@ -145,15 +143,7 @@ func reserveSQLiteFile(path string, create bool) error {
for _, p := range append( for _, p := range append(
[]string{path}, sqliteSidecarPaths(path)..., []string{path}, sqliteSidecarPaths(path)...,
) { ) {
// Chmod accepts a directory, and SQLite opens a database whose err := os.Chmod(p, SQLiteFilePerm)
// -shm is one read-only, without a word: every write then
// fails naming no file.
info, err := os.Stat(p)
if err == nil && info.IsDir() {
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
}
err = os.Chmod(p, SQLiteFilePerm)
if err != nil && !errors.Is(err, fs.ErrNotExist) { if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("securing %s: %w", p, err) return fmt.Errorf("securing %s: %w", p, err)
} }
@@ -162,20 +152,6 @@ func reserveSQLiteFile(path string, create bool) error {
return nil return nil
} }
// missingOrEmpty reports whether opening path in SQLiteModeCreate
// would start a new, empty database: the file is not there, or it is
// zero-length, which SQLite opens as an empty database. A file left at
// zero length by an interrupted first start or a truncated copy holds
// as little as a missing one, and must be reported the same way.
func missingOrEmpty(path string) bool {
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return true
}
return err == nil && info.Size() == 0
}
// sqliteSidecarPaths returns the files SQLite maintains beside a // sqliteSidecarPaths returns the files SQLite maintains beside a
// database under WAL. They carry the same rows as the database itself, // database under WAL. They carry the same rows as the database itself,
// so a fix that tightens only the main file has fixed nothing. // so a fix that tightens only the main file has fixed nothing.
-417
View File
@@ -1,417 +0,0 @@
package database_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// readEventTotals reads a webhook database's row of event totals,
// asserting that it has exactly one.
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var rows []database.EventTotals
require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1)
return rows[0]
}
// readTargetTotals reads a webhook database's target totals, keyed by
// target.
func readTargetTotals(
t *testing.T, db *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
// database starts with one row of zero event totals and no target
// totals, that adding to a target twice adds to the one row, and that
// opening the database again keeps everything added.
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
fresh := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
assert.Empty(t, readTargetTotals(t, db))
first, second := uuid.New().String(), uuid.New().String()
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 2,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Deliveries: 2, Delivered: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Failed: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: second, Deliveries: 1,
}))
// Drop the cached connection so the next open reopens the file,
// as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
readEventTotals(t, db))
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
},
second: {TargetID: second, Deliveries: 1},
}, readTargetTotals(t, db))
}
// seedExpiredEvents stores count events created at the given time,
// each with a delivered delivery to one target and a failed delivery
// to the other, and one attempt for each delivery.
//
// 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,
webhookID string,
count int,
createdAt time.Time,
delivered, failed string,
) {
t.Helper()
events := make([]database.Event, count)
deliveries := make([]database.Delivery, 0, 2*count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].ID = uuid.New().String()
events[i].CreatedAt = createdAt
deliveries = append(deliveries,
database.Delivery{
EventID: events[i].ID,
TargetID: delivered,
Status: database.DeliveryStatusDelivered,
},
database.Delivery{
EventID: events[i].ID,
TargetID: failed,
Status: database.DeliveryStatusFailed,
},
)
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries {
results[i] = database.DeliveryResult{
DeliveryID: deliveries[i].ID, AttemptNum: 1,
}
}
require.NoError(t, db.CreateInBatches(results, 50).Error)
}
// seedBareEvents stores count events created at the given time, with
// no deliveries.
func seedBareEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
) {
t.Helper()
events := make([]database.Event, count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 50).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
// larger than one transaction's batch removes every expired event with
// its deliveries and delivery results, keeps the recent event, and
// adds what it removed to the event and target totals, so the totals
// within retention match the rows still stored.
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
expired := database.ExportReapBatchSize + 1
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour), delivered, failed)
// One recent event, delivered to the first target.
recent := seedEventChain(t, db, webhookID, time.Now())
require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", recent.deliveryID).
Update("target_id", delivered).Error)
// The totals storing those rows would have left.
n := int64(expired)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: n, Failed: n,
}))
env.reaper.ExportSweep(context.Background())
// Only the recent event's rows are left.
for _, model := range []any{
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
} {
var count int64
require.NoError(t, db.Model(model).Count(&count).Error)
assert.Equal(t, int64(1), count, "%T rows left", model)
}
assertChainPresent(t, db, recent)
eventTotals := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
}, eventTotals)
targetTotals := readTargetTotals(t, db)
assert.Equal(t, map[string]database.TargetTotals{
delivered: {
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
DeliveriesRemoved: n,
},
failed: {
TargetID: failed, Deliveries: n, Failed: n,
DeliveriesRemoved: n, FailedRemoved: n,
},
}, targetTotals)
// A sweep with nothing left to remove changes nothing.
env.reaper.ExportSweep(context.Background())
assert.Equal(t, eventTotals, readEventTotals(t, db))
assert.Equal(t, targetTotals, readTargetTotals(t, db))
}
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
// of several batches lets other writers in between its batches: an
// event stored once the first batch is deleted is stored while expired
// events are still left, not only after the prune has finished.
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Three batches of expired events, with nothing else stored: only
// the number of batches matters here.
expired := 3 * database.ExportReapBatchSize
seedBareEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour))
cutoff := time.Now().Add(-30 * 24 * time.Hour)
countExpired := func() int64 {
var count int64
require.NoError(t, db.Model(&database.Event{}).
Where("created_at < ?", cutoff).
Count(&count).Error)
return count
}
pruned := make(chan struct{})
go func() {
defer close(pruned)
env.reaper.ExportSweep(context.Background())
}()
t.Cleanup(func() { <-pruned })
// Every stored event is expired until the write below.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < int64(expired)
}, 10*time.Second, 10*time.Millisecond)
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
require.NoError(t, db.Create(event).Error)
assert.Positive(t, countExpired(),
"the event was stored only after the whole prune")
<-pruned
assert.Zero(t, countExpired())
var stored database.Event
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
}
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
// stopping the reaper during a prune of several batches returns
// between two batches, well inside the stop timeout, leaving the
// remaining expired events for the next sweep, and that the totals
// match the rows left.
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Two batches and one more of expired events, a few of them with a
// delivered and a failed delivery for the target totals to count.
// Most carry nothing else, to keep the test quick.
const withDeliveries = 10
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
delivered, failed)
seedBareEvents(t, db, webhookID,
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
n := int64(2*database.ExportReapBatchSize + 1)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: withDeliveries,
Delivered: withDeliveries,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: withDeliveries,
Failed: withDeliveries,
}))
env.reaper.ExportSetInterval(time.Millisecond)
env.reaper.ExportStart()
// Stop once the first batch is deleted. The stop lands in the pause
// after it, or at worst during the second batch, so at least the
// last event is left.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < n
}, 10*time.Second, 10*time.Millisecond)
// The app's stop timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, env.reaper.ExportStop(ctx))
var events int64
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
assert.Positive(t, events, "the stop waited for the whole prune")
eventTotals := readEventTotals(t, db)
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
targetTotals := readTargetTotals(t, db)
require.Len(t, targetTotals, 2)
for target, totals := range targetTotals {
var deliveries, failures int64
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ?", target).
Count(&deliveries).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ? AND status = ?",
target, database.DeliveryStatusFailed).
Count(&failures).Error)
assert.Equal(t, deliveries,
totals.Deliveries-totals.DeliveriesRemoved, target)
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
target)
}
}
+32 -131
View File
@@ -33,23 +33,9 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type", "invalid cached database type",
) )
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, and DeliveryResults.
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -98,37 +84,34 @@ func NewWebhookDBManager(
return m, nil return m, nil
} }
// GetDB returns the database connection for a webhook, opening it on // GetDB returns the database connection for a webhook,
// first use. // creating the database file lazily if it doesn't exist.
//
// The file is made by CreateDB when the webhook is created. One that is
// missing or zero-length here means the webhook's events and pending
// deliveries are gone: an empty database is created in its place so
// the webhook keeps receiving, and that is logged as a warning naming
// the file, as a new main database is.
func (m *WebhookDBManager) GetDB( func (m *WebhookDBManager) GetDB(
webhookID string, webhookID string,
) (*gorm.DB, error) { ) (*gorm.DB, error) {
return m.getDB(webhookID, false) // Fast path: already open
} if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// GetDBIf is GetDB, done only when check reports true. check runs under // Slow path: open the database under the lock, looking in the
// the lock DeleteDB holds while it removes the files, so a caller can // cache again first. A caller that raced another one here then
// confirm the webhook still exists and open its database with no delete // waits for its handle instead of opening a second one.
// in between. The handle is nil when check reports false. check must
// not call the manager.
func (m *WebhookDBManager) GetDBIf(
webhookID string, check func() (bool, error),
) (*gorm.DB, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
ok, err := check() if val, ok := m.dbs.Load(webhookID); ok {
if err != nil || !ok { return asGormDB(val, webhookID)
}
db, err := m.openDB(webhookID)
if err != nil {
return nil, err return nil, err
} }
return m.getDBLocked(webhookID, false) m.dbs.Store(webhookID, db)
return db, nil
} }
// asGormDB returns a value read from the cache as the database // asGormDB returns a value read from the cache as the database
@@ -146,12 +129,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
return db, nil return db, nil
} }
// CreateDB creates a new webhook's database file and runs // CreateDB explicitly creates a new per-webhook database file
// migrations. // and runs migrations.
func (m *WebhookDBManager) CreateDB( func (m *WebhookDBManager) CreateDB(
webhookID string, webhookID string,
) error { ) error {
_, err := m.getDB(webhookID, true) _, err := m.GetDB(webhookID)
return err return err
} }
@@ -167,10 +150,7 @@ func (m *WebhookDBManager) DBExists(
} }
// DeleteDB closes the connection and deletes the database file // DeleteDB closes the connection and deletes the database file
// for a webhook, with its -wal and -shm sidecars. The files are // for a webhook. The file is permanently removed.
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
func (m *WebhookDBManager) DeleteDB( func (m *WebhookDBManager) DeleteDB(
webhookID string, webhookID string,
) error { ) error {
@@ -189,23 +169,16 @@ func (m *WebhookDBManager) DeleteDB(
} }
} }
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID) path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
dbErr := removeFile(path) err := os.Remove(path + suffix)
sidecarErr := errors.Join( if err != nil && !os.IsNotExist(err) {
removeFile(path+"-wal"), return fmt.Errorf(
removeFile(path+"-shm"), "deleting webhook database file %s%s: %w",
) path, suffix, err,
)
if dbErr != nil { }
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
} }
m.log.Info( m.log.Info(
@@ -216,17 +189,6 @@ func (m *WebhookDBManager) DeleteDB(
return nil return nil
} }
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections. // CloseAll closes all open per-webhook database connections.
// Called during application shutdown. // Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error { func (m *WebhookDBManager) CloseAll() error {
@@ -269,54 +231,6 @@ func (m *WebhookDBManager) DBPath(
return m.dbPath(webhookID) return m.dbPath(webhookID)
} }
// getDB is GetDB, and CreateDB when isNew is true: the webhook has just
// been created, so a missing file is expected rather than lost.
func (m *WebhookDBManager) getDB(
webhookID string, isNew bool,
) (*gorm.DB, error) {
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
m.mu.Lock()
defer m.mu.Unlock()
return m.getDBLocked(webhookID, isNew)
}
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
// cache again first: a caller that raced another one to the lock then
// gets its handle instead of opening a second one.
func (m *WebhookDBManager) getDBLocked(
webhookID string, isNew bool,
) (*gorm.DB, error) {
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// Checked before opening, which creates the file. See GetDB.
path := m.dbPath(webhookID)
replaced := !isNew && missingOrEmpty(path)
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
if replaced {
m.log.Warn(
"created a new, empty database",
"webhook_id", webhookID,
"path", path,
)
}
m.dbs.Store(webhookID, db)
return db, nil
}
func (m *WebhookDBManager) dbPath( func (m *WebhookDBManager) dbPath(
webhookID string, webhookID string,
) string { ) string {
@@ -381,7 +295,6 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -392,18 +305,6 @@ func (m *WebhookDBManager) openDB(
) )
} }
// A new database gets its row of event totals, all zero. Target
// totals rows are created by the first delivery to each target.
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w",
webhookID, err,
)
}
m.log.Info( m.log.Info(
"opened per-webhook database", "opened per-webhook database",
"webhook_id", webhookID, "webhook_id", webhookID,
+3 -146
View File
@@ -182,91 +182,17 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
} }
require.NoError(t, db.Create(event).Error) require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB // Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID)) require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist // File should no longer exist
assert.False(t, mgr.DBExists(webhookID)) assert.False(t, mgr.DBExists(webhookID))
// Verify the files are actually gone from disk // Verify the file is actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID) dbPath := mgr.DBPath(webhookID)
blockRemoval(t, dbPath) _, err = os.Stat(dbPath)
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600)) assert.True(t, os.IsNotExist(err))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
} }
func TestWebhookDBManager_LazyCreation(t *testing.T) { func TestWebhookDBManager_LazyCreation(t *testing.T) {
@@ -289,75 +215,6 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
assert.True(t, mgr.DBExists(webhookID)) assert.True(t, mgr.DBExists(webhookID))
} }
// A webhook's database is made by CreateDB along with the webhook. One
// that GetDB finds missing or zero-length has lost the webhook's events
// and pending deliveries, so the empty database made in its place is
// logged as a warning naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
// reopening a database that is there, log no such warning.
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
t.Parallel()
const created = `level=WARN msg="created a new, empty database"`
open := func(
t *testing.T, prepare func(*database.WebhookDBManager, string),
) (string, string) {
t.Helper()
var logs bytes.Buffer
mgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)),
)
webhookID := uuid.New().String()
prepare(mgr, webhookID)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
return logs.String(),
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
}
t.Run("missing", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(*database.WebhookDBManager, string) {},
)
assert.Contains(t, logs, created+fields)
})
t.Run("zero-length", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, os.WriteFile(
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
))
},
)
assert.Contains(t, logs, created+fields)
})
t.Run("created with the webhook, then reopened", func(t *testing.T) {
t.Parallel()
logs, _ := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, mgr.CreateDB(webhookID))
require.NoError(t, mgr.CloseAll())
},
)
assert.NotContains(t, logs, created)
})
}
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) { func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
t.Parallel() t.Parallel()
+14 -29
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
Logger *logger.Logger Logger *logger.Logger
} }
// ArchiveSweeper periodically prunes expired rows from the // ArchiveSweeper periodically prunes expired rows from
// archive databases of database targets that carry a positive // per-webhook archive databases whose database target carries a
// expiry. // positive expiry.
// //
// Without it, pruning happens only when an archive is // Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an // (re)opened, and archives are only ever reopened by writes: an
// archive whose target has stopped receiving events would keep // archive belonging to a webhook that has stopped receiving
// its expired rows forever. The sweep closes // events would keep its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry // that gap without changing anything for archives whose expiry
// is unset or "never". // is unset or "never".
// //
@@ -45,13 +44,8 @@ type ArchiveSweeper struct {
eng *Engine eng *Engine
log *slog.Logger log *slog.Logger
interval time.Duration interval time.Duration
cancel context.CancelFunc
// cancel needs no lock: fx calls the stop hook only after the wg sync.WaitGroup
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc
wg sync.WaitGroup
} }
// NewArchiveSweeper creates the archive sweeper and registers // 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 // soft-deleted along with it, so GORM's default scope already
// excludes them. // 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 // matching how the write path already treats a prune error as
// non-fatal. // non-fatal.
func (s *ArchiveSweeper) sweep(ctx context.Context) { func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target var targets []database.Target
err := s.db.DB(). err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}). Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase). Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error Find(&targets).Error
if err != nil { 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( s.log.Error(
"archive sweep: failed to list database targets", "archive sweep: failed to list database targets",
"error", err, "error", err,
@@ -224,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
return return
} }
err = s.eng.dbTarget.sweepArchive(target.ID, expiry) err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
if err == nil { if err == nil {
return return
} }
// A writer evicted, or a target row gone, underneath the sweep // A writer evicted underneath the sweep means the operator
// means the operator deleted the target or its webhook while // deleted the webhook (or its last database target) while the
// the sweep was walking the target list. That is an ordinary // sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an // interleaving, not a failure, so it must not produce an
// error line. // error line.
if errors.Is(err, errArchiveWriterEvicted) || if errors.Is(err, errArchiveWriterEvicted) {
errors.Is(err, gorm.ErrRecordNotFound) {
s.log.Debug( s.log.Debug(
"archive sweep: target deleted mid-sweep", "archive sweep: writer evicted mid-sweep",
"webhook_id", target.WebhookID, "webhook_id", target.WebhookID,
"target_id", target.ID, "target_id", target.ID,
) )
+137 -220
View File
@@ -1,11 +1,9 @@
package delivery_test package delivery_test
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -22,7 +20,6 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
const ( const (
@@ -37,23 +34,18 @@ const (
sweepConcurrentWrites = 20 sweepConcurrentWrites = 20
) )
// archiveTestWebhookName is the name of every webhook // sweeperEnv bundles the pieces an archive sweep test drives:
// seedDatabaseTarget creates. It is not safe in a file name as it // a main configuration database holding webhooks and targets, a
// stands, so every archive test goes through archiveNamePart. // delivery engine owning the archive writer registry, and the
const archiveTestWebhookName = "Sweep Test!" // data directory the archive files live in.
type sweeperEnv struct {
// 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 {
sweeper *delivery.ArchiveSweeper sweeper *delivery.ArchiveSweeper
eng *delivery.Engine eng *delivery.Engine
mainDB *database.Database mainDB *database.Database
dataDir string dataDir string
} }
func setupArchiveTest(t *testing.T) *archiveEnv { func setupSweeperTest(t *testing.T) *sweeperEnv {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
@@ -71,8 +63,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -87,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
1, 1,
) )
return &archiveEnv{ return &sweeperEnv{
sweeper: delivery.NewTestArchiveSweeper( sweeper: delivery.NewTestArchiveSweeper(
mainDB, eng, log, mainDB, eng, log,
), ),
@@ -97,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
} }
} }
// archivePath returns where the engine keeps a database target's // archivePath returns where the engine keeps a webhook's
// archive file, for the names seedDatabaseTarget gave it. // archive file.
func (env *archiveEnv) archivePath(tgt *database.Target) string { func (env *sweeperEnv) archivePath(webhookID string) string {
return filepath.Join( return filepath.Join(
env.dataDir, env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID,
),
) )
} }
// seedDatabaseTarget creates a webhook with one database target // seedDatabaseTarget creates a webhook with one database target
// carrying the given target config JSON, and returns the target. // carrying the given target config JSON, and returns the
func (env *archiveEnv) seedDatabaseTarget( // webhook id.
func (env *sweeperEnv) seedDatabaseTarget(
t *testing.T, configJSON string, t *testing.T, configJSON string,
) *database.Target { ) string {
t.Helper() t.Helper()
wh := &database.Webhook{ wh := &database.Webhook{
UserID: uuid.New().String(), UserID: uuid.New().String(),
Name: archiveTestWebhookName, Name: "sweep-test",
} }
require.NoError( require.NoError(
t, t,
@@ -126,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error, 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{ tgt := &database.Target{
WebhookID: webhookID, WebhookID: wh.ID,
Name: "Archive", Name: "archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Active: true, Active: true,
Config: configJSON, Config: configJSON,
@@ -150,30 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
Create(tgt).Error, 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 // inserts one row per supplied archived-at timestamp, returning
// the archive path. The handle is closed before returning, so // the archive path. The handle is closed before returning, so
// the archive is idle exactly as it would be with no traffic. // the archive is idle exactly as it would be with no traffic.
func (env *archiveEnv) seedArchiveRows( func (env *sweeperEnv) seedArchiveRows(
t *testing.T, tgt *database.Target, archivedAt ...time.Time, t *testing.T, webhookID string, archivedAt ...time.Time,
) string { ) string {
t.Helper() t.Helper()
path := env.archivePath(tgt) path := env.archivePath(webhookID)
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
return path
}
// seedArchiveFile creates the archive file at path and inserts one row
// per supplied archived-at timestamp, as seedArchiveRows does.
func seedArchiveFile(
t *testing.T, path, webhookID string, archivedAt ...time.Time,
) {
t.Helper()
sqlDB, err := sql.Open( sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path), "sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -181,8 +149,7 @@ func seedArchiveFile(
require.NoError(t, err) require.NoError(t, err)
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -202,6 +169,8 @@ func seedArchiveFile(
} }
require.NoError(t, sqlDB.Close()) require.NoError(t, sqlDB.Close())
return path
} }
// archivedEventIDs returns the event ids currently stored in an // archivedEventIDs returns the event ids currently stored in an
@@ -239,8 +208,7 @@ func countArchivedRows(path string) (int64, error) {
defer func() { _ = sqlDB.Close() }() defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -275,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -319,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
} }
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the // TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
// interleaving where a sweep tick has already listed a target // interleaving where a sweep tick has already listed a webhook's
// when the target is deleted and its writer evicted. The sweep // target when the webhook is deleted and its writer evicted. The
// must not put a writer back into the registry: nothing would // sweep must not put a writer back into the registry: nothing
// ever evict it again, which is precisely the leak this change // would ever evict it again, which is precisely the leak this
// exists to close. // change exists to close.
func TestArchiveSweep_DoesNotResurrectEvictedWriter( func TestArchiveSweep_DoesNotResurrectEvictedWriter(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( 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 // Prime the registry the way a delivery would, then evict as
// the deletion path does. The target row is deliberately left // 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. // the deletion committed.
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) _, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err) require.NoError(t, err)
env.eng.EvictTarget(tgt.ID) env.eng.EvictWebhook(webhookID)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must never re-register a writer for a target "+ "a sweep must never re-register a writer for a webhook "+
"whose registry entry has already been released", "whose registry entry has already been released",
) )
} }
// TestArchiveSweep_LeavesNoRegistryEntry states the same // TestArchiveSweep_LeavesNoRegistryEntry states the same
// invariant in its general form: sweeping an archive whose // 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 // registry keeps holding only writers a delivery created and an
// eviction can reach. // eviction can reach.
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), 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()) env.sweeper.ExportSweep(context.Background())
@@ -381,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive", "the sweep must still prune an idle archive",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the sweep must release the registry entry it created", "the sweep must release the registry entry it created",
) )
} }
@@ -396,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) 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()) 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) env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a delivery's writer must stay registered", "a delivery's writer must stay registered",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must not drop a writer a delivery owns", "a sweep must not drop a writer a delivery owns",
) )
} }
@@ -452,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
sweepWriter, created, err := env.eng.ExportSweepWriterFor( sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID, webhookID,
) )
require.NoError(t, err) require.NoError(t, err)
require.True( require.True(
@@ -471,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// The delivery lands mid-sweep and adopts the entry. // The delivery lands mid-sweep and adopts the entry.
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) 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) env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.eng.ExportArchiveWriterFor(tgt.ID) adopted := env.eng.ExportArchiveWriterFor(webhookID)
require.NotNil(t, adopted) require.NotNil(t, adopted)
require.True( require.True(
t, sweepWriter.Same(adopted), t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer", "the delivery must have adopted the sweep's writer",
) )
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery leaves the archive handle open", "the delivery leaves the archive handle open",
) )
// The sweep finishes. // The sweep finishes.
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter) env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a writer adopted by a delivery during a sweep must "+ "a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable", "stay registered, or its open handle is unreachable",
) )
env.eng.EvictTarget(tgt.ID) env.eng.EvictWebhook(webhookID)
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the adopted writer must still be evictable", "the adopted writer must still be evictable",
) )
assert.False( assert.False(
@@ -507,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) )
} }
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a // TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
// failure for one target does not abort the sweep for the // failure for one webhook does not abort the sweep for the
// others: an unparseable expiry and an unreadable archive both // others: an unparseable expiry and an unreadable archive both
// have to be logged and stepped over. // have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure( func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
// Seeded first so the sweep reaches them before the healthy // Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order. // webhook: targets come back in insertion order.
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows( 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( require.NoError(t, os.WriteFile(
env.archivePath(corrupt), env.archivePath(corruptID),
[]byte("this is not a sqlite database"), []byte("this is not a sqlite database"),
0o600, 0o600,
)) ))
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows( healthyPath := env.seedArchiveRows(
t, healthy, t, healthyID,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
@@ -544,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
assert.Equal( assert.Equal(
t, []string{sweepRowNew}, t, []string{sweepRowNew},
archivedEventIDs(t, healthyPath), 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", "sweep from pruning the ones after it",
) )
} }
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second // TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
// of the two no-create guards. The first is the stat in // 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 // protects the window between that stat and the open. Flipping
// the sweep's mode to create-if-missing makes this fail. // the sweep's mode to create-if-missing makes this fail.
func TestArchiveSweep_OpenExistingDoesNotCreateFile( func TestArchiveSweep_OpenExistingDoesNotCreateFile(
@@ -587,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -626,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
w := delivery.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
@@ -666,94 +640,39 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) 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) env.eng.ExportDeliverDatabase(webhookDB, d)
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery must leave the archive handle open", "the delivery must leave the archive handle open",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the delivery's registry entry must survive the sweep", "the delivery's registry entry must survive the sweep",
) )
assert.False( assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the sweep must leave the archive closed", "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 // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so archives with no // no-op for the default retention policy, so archives with no
// expiry (or the literal "never") behave exactly as before. // expiry (or the literal "never") behave exactly as before.
@@ -765,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
`{"expiry":""}`, `{"expiry":""}`,
"", "",
} { } {
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, configJSON) webhookID := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
time.Now().Add(-10000*time.Hour), time.Now().Add(-10000*time.Hour),
) )
@@ -780,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
"config %q must keep rows forever", configJSON, "config %q must keep rows forever", configJSON,
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"config %q must leave no registry entry behind", "config %q must leave no registry entry behind",
configJSON, configJSON,
) )
@@ -803,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt) path := env.archivePath(webhookID)
seedUnmigratedArchive(t, path) seedUnmigratedArchive(t, path)
require.False(t, archiveTableExists(t, path)) require.False(t, archiveTableExists(t, path))
@@ -849,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
} }
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
// never conjures an archive: a database target that has never // never conjures an archive: a webhook with a database target
// received an event must still have no archive file (nor SQLite // that has never received an event must still have no archive
// sidecar) after a sweep, and no registry entry either. // file (nor SQLite sidecar) after a sweep.
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt) path := env.archivePath(webhookID)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -870,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
"the sweep must not create an archive file", "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 // TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
@@ -886,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
) { ) {
t.Parallel() 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.NoError(t, err)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -905,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
require.NoError( require.NoError(
t, t,
env.mainDB.DB(). env.mainDB.DB().
Where("webhook_id = ?", tgt.WebhookID). Where("webhook_id = ?", webhookID).
Delete(&database.Target{}).Error, Delete(&database.Target{}).Error,
) )
@@ -928,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
} }
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises // TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// against writes through the target's writer mutex. Run under // against writes through the per-webhook writer mutex. Run
// -race, an unsynchronised sweep would be caught here. // under -race, an unsynchronised sweep would be caught here.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) { func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
@@ -948,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
for range sweepConcurrentWrites { for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID
deliveries = append( deliveries = append(
deliveries, deliveries,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt), seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
),
) )
} }
@@ -977,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
wg.Wait() wg.Wait()
assert.FileExists(t, env.archivePath(tgt)) assert.FileExists(t, env.archivePath(webhookID))
} }
// TestArchiveSweeper_StopsCleanly proves the background loop // TestArchiveSweeper_StopsCleanly proves the background loop
@@ -985,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
func TestArchiveSweeper_StopsCleanly(t *testing.T) { func TestArchiveSweeper_StopsCleanly(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
env.sweeper.ExportSetInterval(time.Millisecond) env.sweeper.ExportSetInterval(time.Millisecond)
@@ -1013,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
lc := &recordingLifecycle{} lc := &recordingLifecycle{}
env.sweeper.ExportRegisterHooks(lc) env.sweeper.ExportRegisterHooks(lc)
-14
View File
@@ -102,20 +102,6 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
return remaining return remaining
} }
// StateAndCooldown returns the circuit state and, while the circuit is
// open, what is left of the cooldown, or zero once that has passed.
// Both are read under one lock, so they always agree.
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state != CircuitOpen {
return cb.state, 0
}
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
}
// RecordSuccess records a successful delivery and resets // RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state. // the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() { func (cb *CircuitBreaker) RecordSuccess() {
-177
View File
@@ -1,177 +0,0 @@
package delivery_test
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// targetTotals reads one target's totals from a webhook database, all
// zero when it has no row.
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Where("target_id = ?", targetID).
Find(&rows).Error)
if len(rows) == 0 {
return database.TargetTotals{TargetID: targetID}
}
return rows[0]
}
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
tests := []struct {
status database.DeliveryStatus
finished bool
delivered int64
failed int64
}{
{database.DeliveryStatusRetrying, false, 0, 0},
{database.DeliveryStatusDelivered, true, 1, 0},
{database.DeliveryStatusFailed, true, 0, 1},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
before := time.Now()
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, tt.status,
))
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, tt.status, stored.Status)
if tt.finished {
require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before))
} else {
assert.Nil(t, stored.FinishedAt)
}
assert.Equal(t, database.TargetTotals{
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
})
}
}
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
// delivery retention deleted while the engine still held it. Failing
// it afterwards writes no row, so it adds no failure either: retention
// has already counted what it removed.
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
require.NoError(t, db.Unscoped().
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, database.DeliveryStatusFailed,
))
assert.Equal(t, database.TargetTotals{TargetID: targetID},
targetTotals(t, db, targetID))
}
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
// delivery settled a second time, as recovery can do when a worker has
// settled it since recovery read it. Neither status writes over the
// first, and the totals do not move.
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
t *testing.T,
) {
t.Parallel()
finished := []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}
for _, first := range finished {
t.Run(string(first), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
// The delivery as recovery read it, before the worker
// settled it.
readBefore := d
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, first,
))
var settled database.Delivery
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
require.NotNil(t, settled.FinishedAt)
totals := targetTotals(t, db, targetID)
for _, again := range finished {
stale := readBefore
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &stale, again,
))
}
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, first, stored.Status)
require.NotNil(t, stored.FinishedAt)
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
assert.Equal(t, totals, targetTotals(t, db, targetID))
})
}
}
+37 -178
View File
@@ -14,7 +14,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
@@ -123,33 +122,21 @@ type Notifier interface {
Notify(tasks []Task) Notify(tasks []Task)
} }
// Archives is how the handlers keep the database targets' archive // WebhookEvictor releases the delivery engine's per-webhook
// files in step with the configuration. Deleting a webhook or a // state for a webhook that no longer needs it — currently the
// target releases the cached archive writers, whose open file // cached archive writer of the database target, whose open
// handles would otherwise outlive them; renaming one renames the // file handle would otherwise outlive the webhook.
// archive files, which are named for the webhook and the target
// (see ArchiveFileName).
// //
// It is deliberately separate from Notifier: archiving lifecycle // It is deliberately separate from Notifier and deliberately
// is not notification, and a small interface keeps the handlers // one method wide: archiving lifecycle is not notification, and
// package free of any dependency on the engine's internals while // a single-method interface keeps the handlers package free of
// staying trivially fakeable in tests. // any dependency on the engine's internals while staying
// trivially fakeable in tests.
// //
// Neither eviction deletes an archive file. Both are idempotent // EvictWebhook never deletes an archive file. It is idempotent
// and are no-ops for a webhook or target with no engine state. // and is a no-op for a webhook with no engine state.
type Archives interface { type WebhookEvictor interface {
EvictWebhook(webhookID string) EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// CircuitBreakers is how the handlers read a target's circuit
// breaker, so the webhook page and the event log can say that
// deliveries to the target are paused and until when. Like Archives,
// it keeps the handlers free of the engine's internals and is
// trivially faked in tests.
type CircuitBreakers interface {
StateAndCooldown(targetID string) (CircuitState, time.Duration)
} }
// EngineParams are the fx dependencies for the delivery // EngineParams are the fx dependencies for the delivery
@@ -159,10 +146,8 @@ type EngineParams struct {
DB *database.Database DB *database.Database
DBManager *database.WebhookDBManager DBManager *database.WebhookDBManager
Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
SSRFGuard *Guard SSRFGuard *Guard
Metrics *metrics.Set
} }
// Engine processes queued deliveries in the background // Engine processes queued deliveries in the background
@@ -182,27 +167,21 @@ type Engine struct {
retryCh chan Task retryCh chan Task
workers int workers int
// version is the running build's version, the one the web UI // mtr is the delivery metric set. Production wires the
// footer shows. userAgent puts it on every outbound request. // process-wide one; a test can substitute a set registered on
version string // a private registry so its assertions are not disturbed by
// deliveries other tests are making at the same time.
// 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 *metrics.Set mtr *metrics.Set
// targets maps each target type to its implementation. // targets maps each target type to its implementation.
targets map[database.TargetType]Target targets map[database.TargetType]Target
// httpTarget and slackTarget are retained so StateAndCooldown // httpTarget is retained so tests can reach the HTTP
// can read their circuit breakers, and so tests can reach the // target's shared client and circuit breakers.
// HTTP target's shared client. httpTarget *httpTarget
httpTarget *httpTarget
slackTarget *slackTarget
// dbTarget is retained so the engine can reach the archive // 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 dbTarget *databaseTarget
// inflight is the set of deliveries this engine currently owns. // inflight is the set of deliveries this engine currently owns.
@@ -225,8 +204,7 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers, workers: defaultWorkers,
version: params.Globals.Version, mtr: metrics.Default(),
mtr: params.Metrics,
} }
e.initTargets(&http.Client{ e.initTargets(&http.Client{
@@ -271,67 +249,17 @@ func (e *Engine) Notify(tasks []Task) {
} }
} }
// EvictWebhook implements Archives. The cached archive writer of // EvictWebhook implements WebhookEvictor. It releases the
// every database target of the webhook is dropped from the // engine's per-webhook archiving state: the database target's
// registry and its file handle closed. The archive files // cached archive writer is dropped from the registry and its
// themselves are left on disk — they are long-term storage the // file handle closed. The archive file itself is left on disk
// operator owns. // — it is long-term storage the operator owns.
func (e *Engine) EvictWebhook(webhookID string) { func (e *Engine) EvictWebhook(webhookID string) {
if e.dbTarget == nil { if e.dbTarget == nil {
return return
} }
e.dbTarget.evictWebhook(webhookID) e.dbTarget.evict(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 every one of a database
// target's archive files to ArchiveFileName(webhookName, targetName,
// targetID), each keeping the period in its name, under the lock the
// target's archive writes and the idle sweep take. It never replaces
// a file: if one already has a 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)
}
// StateAndCooldown implements CircuitBreakers. It returns the state of
// the target's circuit breaker and, while the breaker is open, what is
// left of its cooldown; the cooldown is zero once that has passed and
// in any other state. A target with no breaker reads as closed with no
// cooldown, and reading never creates one.
func (e *Engine) StateAndCooldown(
targetID string,
) (CircuitState, time.Duration) {
for _, core := range []*httpCore{
e.httpTarget.httpCore, e.slackTarget.httpCore,
} {
val, ok := core.circuitBreakers.Load(targetID)
if ok {
cb, _ := val.(*CircuitBreaker)
return cb.StateAndCooldown()
}
}
return CircuitClosed, 0
} }
// ScheduleRetry schedules a task to be re-enqueued onto the // ScheduleRetry schedules a task to be re-enqueued onto the
@@ -372,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 // registerHooks wires the engine's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored // lifecycle. The start hook's context is deliberately ignored
// (see start for why the worker pool must not inherit it); the // (see start for why the worker pool must not inherit it); the
@@ -445,8 +366,7 @@ func (e *Engine) start() {
// Once the pool has drained it closes the archive writers, so a // Once the pool has drained it closes the archive writers, so a
// clean stop leaves no archive -wal behind. Nothing else holds a // clean stop leaves no archive -wal behind. Nothing else holds a
// writer for long by then: the archive sweeper stops before the // writer for long by then: the archive sweeper stops before the
// engine, and deleting or renaming a webhook or target only closes // engine, and deleting a webhook only closes one. If the pool did
// or moves one. If the pool did
// not drain in time, the writers are left open, as a kill would // not drain in time, the writers are left open, as a kill would
// leave them. Closing them would wait for any write in progress, // leave them. Closing them would wait for any write in progress,
// and a worker still running would then open new writers that // and a worker still running would then open new writers that
@@ -611,11 +531,6 @@ func (e *Engine) processRetryTask(
return return
} }
// Set before anything below can fail the delivery: the failure is
// added to this target's totals.
d.EventID = task.EventID
d.TargetID = task.TargetID
if d.Status != database.DeliveryStatusRetrying { if d.Status != database.DeliveryStatusRetrying {
e.log.Debug( e.log.Debug(
"skipping retry for delivery "+ "skipping retry for delivery "+
@@ -647,6 +562,8 @@ func (e *Engine) processRetryTask(
} }
target := buildTargetFromTask(task) target := buildTargetFromTask(task)
d.EventID = task.EventID
d.TargetID = task.TargetID
d.Event = event d.Event = event
d.Target = target d.Target = target
@@ -733,9 +650,10 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
default: default:
} }
// Opened even when its file is missing, so that a lost if !e.dbManager.DBExists(webhookID) {
// database is reported at start, not when the webhook next continue
// receives an event, which for a quiet webhook may be never. }
e.recoverWebhookDeliveries(ctx, webhookID) e.recoverWebhookDeliveries(ctx, webhookID)
} }
} }
@@ -743,24 +661,7 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
func (e *Engine) recoverWebhookDeliveries( func (e *Engine) recoverWebhookDeliveries(
ctx context.Context, webhookID string, ctx context.Context, webhookID string,
) { ) {
// The web interface is already serving, so the webhook may have webhookDB, err := e.dbManager.GetDB(webhookID)
// been deleted since the list was read. Opening its database then
// would create the file again after the delete removed it.
stillExists := func() (bool, error) {
var count int64
err := e.database.DB().
Model(&database.Webhook{}).
Where("id = ?", webhookID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("confirming webhook exists: %w", err)
}
return count > 0, nil
}
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
if err != nil { if err != nil {
e.log.Error( e.log.Error(
"failed to get webhook database for recovery", "failed to get webhook database for recovery",
@@ -771,10 +672,6 @@ func (e *Engine) recoverWebhookDeliveries(
return return
} }
if webhookDB == nil {
return
}
e.recoverPendingDeliveries( e.recoverPendingDeliveries(
ctx, webhookDB, webhookID, ctx, webhookDB, webhookID,
) )
@@ -1657,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
targetType database.TargetType, targetType database.TargetType,
status database.DeliveryStatus, status database.DeliveryStatus,
) error { ) error {
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Model(d).
return writeDeliveryStatus(tx, d, status) Update("status", status).Error
})
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"updating delivery %s to status %s: %w", "updating delivery %s to status %s: %w",
@@ -1678,43 +1574,6 @@ func (e *Engine) updateDeliveryStatus(
return nil return nil
} }
// writeDeliveryStatus writes a delivery's new status. A delivery that
// becomes delivered or failed also gets the time it finished, and is
// added to its target's delivered or failed total. That write changes
// only a delivery not yet delivered or failed, and the total moves
// only when it changed a row: retention may have deleted the delivery
// while the engine was working on it, and a recovery path may settle
// a delivery that a worker has already settled.
func writeDeliveryStatus(
tx *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
if !status.Terminal() {
return tx.Model(d).Update("status", status).Error
}
res := tx.Model(d).
Where("status NOT IN ?", []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}).
Updates(map[string]any{
"status": status,
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
return database.AddTargetTotals(tx, add)
}
// settleStatus moves a delivery to its outcome status and reports a // settleStatus moves a delivery to its outcome status and reports a
// failed write through bookkeepingFailed, which leaves the row // failed write through bookkeepingFailed, which leaves the row
// recoverable. It exists so the target call sites read as one // recoverable. It exists so the target call sites read as one
+3 -125
View File
@@ -1,7 +1,6 @@
package delivery_test package delivery_test
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -24,7 +23,6 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
// iSetup holds common integration test dependencies. // iSetup holds common integration test dependencies.
@@ -82,8 +80,7 @@ func iMainDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open( db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -358,14 +355,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -405,8 +397,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -453,14 +443,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -497,8 +482,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -1137,85 +1120,6 @@ func TestRecoverInFlight_WithPendingDeliveries(
} }
} }
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Restart
// recovery opens every webhook's database, so the empty one made in its
// place is reported at start, naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290).
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "lost-database")
var logs bytes.Buffer
dbMgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
assert.Contains(
t, logs.String(),
`level=WARN msg="created a new, empty database" webhook_id=`+
webhookID+" path="+dbMgr.DBPath(webhookID),
)
}
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while restart recovery runs.
// Its database file is gone, and recovery must not create it again.
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T,
) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
// The first query to return is recovery's read of the list of
// webhooks. Deleting the webhook right after it puts the delete
// between that read and the opening of the webhook's database.
deleted := false
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) {
if deleted {
return
}
deleted = true
require.NoError(t, mainDB.Delete(
&database.Webhook{}, "id = ?", webhookID,
).Error)
}))
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID))
}
// --- HTTP Config with custom headers --- // --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
@@ -1507,32 +1411,6 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
) )
} }
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching --- // --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
+10 -23
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -9,7 +10,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -272,35 +272,22 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
} }
// deliverToArchive gives the setup's webhook a database target, // deliverToArchive runs one delivery to a database target through
// runs one delivery to it through the running engine, and returns // the running engine and returns the webhook's archive file path.
// the target's ID and archive file path. The archive writer holds // The archive writer holds the file open afterwards.
// the file open afterwards. func deliverToArchive(t *testing.T, s iSetup) string {
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
t.Helper() 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) deliveryID, task := seedLogTask(t, s)
task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase task.TargetType = database.TargetTypeDatabase
s.Engine.Notify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
return tgt.ID, filepath.Join( return filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), 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) lc := startEngineViaHook(t, s.Engine)
_, path := deliverToArchive(t, s) path := deliverToArchive(t, s)
require.FileExists( require.FileExists(
t, path+"-wal", t, path+"-wal",
"an open archive should have a -wal for the stop to remove", "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) lc := startEngineViaHook(t, s.Engine)
targetID, _ := deliverToArchive(t, s) deliverToArchive(t, s)
release := make(chan struct{}) release := make(chan struct{})
@@ -365,7 +352,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
require.True( require.True(
t, s.Engine.ExportArchiveHandleOpen(targetID), t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
"a stop that timed out must not close archive writers", "a stop that timed out must not close archive writers",
) )
} }
+33 -148
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -26,7 +25,6 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
) )
@@ -51,8 +49,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open( db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -60,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{}, &database.Event{},
&database.Delivery{}, &database.Delivery{},
&database.DeliveryResult{}, &database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
)) ))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db return db
} }
@@ -354,15 +348,23 @@ func TestDeliverDatabase_ImmediateSuccess(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real, so the engine needs // The database target archives for real now, so the engine
// the target in the main database and a data directory. // needs a webhook DB manager to locate the data directory.
env := setupArchiveTest(t) e := delivery.NewTestEngineWithDB(
tgt := env.seedDatabaseTarget(t, "") 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"}`) 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 var updated database.Delivery
@@ -1018,62 +1020,6 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
) )
} }
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
// reads the state of an http or a slack target's circuit breaker, with
// what is left of its cooldown while it is open, and no cooldown while
// it is half-open, once it closes, or for a target with no breaker.
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
httpID := uuid.New().String()
slackID := uuid.New().String()
state, cooldown := e.StateAndCooldown(httpID)
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
assert.Zero(t, cooldown, "no breaker")
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetCircuitBreaker(httpID, httpCB)
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
httpCB.RecordFailure()
slackCB.RecordFailure()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitOpen, state)
assert.Greater(t, cooldown, 59*time.Minute)
assert.LessOrEqual(t, cooldown, time.Hour)
}
httpCB.RecordSuccess()
slackCB.RecordSuccess()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitClosed, state, "closed")
assert.Zero(t, cooldown, "closed")
}
// A breaker with no cooldown goes half-open on the first Allow
// after it trips, letting that one delivery through to test the
// target.
halfOpenID := uuid.New().String()
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
halfOpenCB.RecordFailure()
require.True(t, halfOpenCB.Allow())
state, cooldown = e.StateAndCooldown(halfOpenID)
assert.Equal(t, delivery.CircuitHalfOpen, state)
assert.Zero(t, cooldown, "half-open")
}
func TestParseHTTPConfig_Valid(t *testing.T) { func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1115,21 +1061,6 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
) )
} }
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1313,33 +1244,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType, testContentType,
receivedHeaders.Get("Content-Type"), receivedHeaders.Get("Content-Type"),
) )
}
// A response that ends before the length it announced is an error, not assert.Equal(t,
// a short body. "webhooker/1.0",
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) { receivedHeaders.Get("User-Agent"),
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
) )
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
} }
// The event's stored inbound headers carry the same Content-Type the // The event's stored inbound headers carry the same Content-Type the
@@ -1408,7 +1317,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event, ContentType: tc.event,
}, },
cfg, cfg,
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
@@ -1418,34 +1326,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
} }
} }
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
}
func TestProcessDelivery_RoutesToCorrectHandler( func TestProcessDelivery_RoutesToCorrectHandler(
t *testing.T, t *testing.T,
) { ) {
@@ -1453,27 +1333,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real, so the engine needs // The database target archives for real now, so the engine
// the target in the main database and a data directory. // needs a webhook DB manager to locate the data directory.
env := setupArchiveTest(t) e := delivery.NewTestEngineWithDB(
archive := env.seedDatabaseTarget(t, "") 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 { tests := []struct {
name string name string
targetType database.TargetType targetType database.TargetType
targetID string
wantStatus database.DeliveryStatus wantStatus database.DeliveryStatus
}{ }{
{ {
"database target", "database target",
database.TargetTypeDatabase, database.TargetTypeDatabase,
archive.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
{ {
"log target", "log target",
database.TargetTypeLog, database.TargetTypeLog,
uuid.New().String(),
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
} }
@@ -1483,7 +1368,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
t.Parallel() t.Parallel()
runRoutingSubtest( runRoutingSubtest(
t, db, env.eng, tt.targetType, tt.targetID, t, db, e, tt.targetType,
tt.wantStatus, tt.wantStatus,
) )
}) })
@@ -1495,7 +1380,6 @@ func runRoutingSubtest(
db *gorm.DB, db *gorm.DB,
e *delivery.Engine, e *delivery.Engine,
targetType database.TargetType, targetType database.TargetType,
targetID string,
wantStatus database.DeliveryStatus, wantStatus database.DeliveryStatus,
) { ) {
t.Helper() t.Helper()
@@ -1503,7 +1387,8 @@ func runRoutingSubtest(
event := seedEvent(t, db, `{"routing":"test"}`) event := seedEvent(t, db, `{"routing":"test"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID,
uuid.New().String(),
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
+36 -76
View File
@@ -9,7 +9,6 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -41,6 +40,11 @@ const (
ExportPendingSweepMinAge = pendingSweepMinAge ExportPendingSweepMinAge = pendingSweepMinAge
) )
// ExportIsBlockedIP exposes isBlockedIP for testing.
func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// NewTestGuard builds an SSRF Guard from an explicit egress // NewTestGuard builds an SSRF Guard from an explicit egress
// allowlist, without going through config. Passing no prefixes // allowlist, without going through config. Passing no prefixes
// yields the default guard, which blocks every private/reserved // yields the default guard, which blocks every private/reserved
@@ -66,11 +70,6 @@ func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks return blockedNetworks
} }
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
func ExportBlockedPublicNetworks() []*net.IPNet {
return blockedPublicNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader. // ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool { func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name) return isForwardableHeader(name)
@@ -83,9 +82,8 @@ func ExportApplyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
return applyRequestHeaders(req, event, cfg, userAgent) return applyRequestHeaders(req, event, cfg)
} }
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
@@ -152,16 +150,6 @@ func (e *Engine) ExportDeliverSlack(
) )
} }
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
// target type, so no metric moves.
func (e *Engine) ExportUpdateDeliveryStatus(
webhookDB *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
return e.updateDeliveryStatus(webhookDB, d, "", status)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
@@ -212,14 +200,6 @@ func (e *Engine) ExportSetCircuitBreaker(
e.httpTarget.circuitBreakers.Store(targetID, cb) e.httpTarget.circuitBreakers.Store(targetID, cb)
} }
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
// slack target.
func (e *Engine) ExportSetSlackCircuitBreaker(
targetID string, cb *CircuitBreaker,
) {
e.slackTarget.circuitBreakers.Store(targetID, cb)
}
// ExportParseHTTPConfig exposes parseHTTPConfig. // ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig( func (e *Engine) ExportParseHTTPConfig(
configJSON string, configJSON string,
@@ -409,7 +389,7 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.Default(),
} }
e.initTargets(client) e.initTargets(client)
@@ -424,7 +404,7 @@ func NewTestEngineSmallRetry(
e := &Engine{ e := &Engine{
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.Default(),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -447,7 +427,7 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.Default(),
} }
e.initTargets(client) e.initTargets(client)
@@ -455,7 +435,8 @@ func NewTestEngineWithDB(
} }
// ExportSetMetrics substitutes the engine's metric set, so a test can // 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) { func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr e.mtr = mtr
} }
@@ -482,7 +463,7 @@ func NewTestCircuitBreaker(
type ExportArchivedEvent = archivedEvent type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests // 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 { type ExportArchiveWriter struct {
w *archiveWriter w *archiveWriter
} }
@@ -501,38 +482,23 @@ func NewExportArchiveWriter(
return &ExportArchiveWriter{w: w} return &ExportArchiveWriter{w: w}
} }
// Write archives a row through the writer, into the file named // Write archives a row through the writer.
// without a period.
func (e *ExportArchiveWriter) Write( func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration, row ExportArchivedEvent, expiry time.Duration,
) error { ) error {
return e.w.write(row, expiry, "") return e.w.write(row, expiry)
}
// WritePeriod archives a row through the writer, into the file for
// period.
func (e *ExportArchiveWriter) WritePeriod(
row ExportArchivedEvent, expiry time.Duration, period string,
) error {
return e.w.write(row, expiry, period)
} }
// Open opens the archive file, pruning when expiry is positive. // Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error { func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry) return e.w.open(expiry)
} }
// Reopen closes and reopens the archive file. // Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen( func (e *ExportArchiveWriter) Reopen(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.reopen(e.w.path, expiry) 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. // Reopens reports how many times the file has been opened.
@@ -556,7 +522,7 @@ func (e *ExportArchiveWriter) Path() string {
func (e *ExportArchiveWriter) OpenExisting( func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry) return e.w.openMode(archiveModeExisting, expiry)
} }
// SweepExpired runs an idle sweep of the archive. // SweepExpired runs an idle sweep of the archive.
@@ -572,12 +538,6 @@ func (e *ExportArchiveWriter) Evict() {
e.w.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 // HandleOpen reports whether the writer currently holds an open
// archive handle. // archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool { func (e *ExportArchiveWriter) HandleOpen() bool {
@@ -597,16 +557,16 @@ func (e *ExportArchiveWriter) Same(
} }
// ExportArchiveWriterFor returns the archive writer the registry // ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is // currently caches for a webhook, or nil when none is cached. It
// cached. It never creates one, so a test can hold a reference to // never creates one, so a test can hold a reference to the very
// the very writer an eviction is about to detach. // writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor( func (e *Engine) ExportArchiveWriterFor(
targetID string, webhookID string,
) *ExportArchiveWriter { ) *ExportArchiveWriter {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID] w, ok := e.dbTarget.writers[webhookID]
if !ok { if !ok {
return nil return nil
} }
@@ -615,26 +575,26 @@ func (e *Engine) ExportArchiveWriterFor(
} }
// ExportHasArchiveWriter reports whether the database target // 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( func (e *Engine) ExportHasArchiveWriter(
targetID string, webhookID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID] _, ok := e.dbTarget.writers[webhookID]
return ok return ok
} }
// ExportArchiveHandleOpen reports whether the cached archive // 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. // returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen( func (e *Engine) ExportArchiveHandleOpen(
targetID string, webhookID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID] w, ok := e.dbTarget.writers[webhookID]
e.dbTarget.mu.Unlock() e.dbTarget.mu.Unlock()
if !ok { if !ok {
@@ -648,12 +608,12 @@ func (e *Engine) ExportArchiveHandleOpen(
} }
// ExportEnsureArchiveWriter creates (if needed) and returns the // 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. // test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter( func (e *Engine) ExportEnsureArchiveWriter(
targetID string, webhookID string,
) (string, error) { ) (string, error) {
w, err := e.dbTarget.writerFor(targetID) w, err := e.dbTarget.writerFor(webhookID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -661,14 +621,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
return w.path, nil 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 // as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the // create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines. // sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor( func (e *Engine) ExportSweepWriterFor(
targetID string, webhookID string,
) (*ExportArchiveWriter, bool, error) { ) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID) w, created, err := e.dbTarget.sweepWriterFor(webhookID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -679,9 +639,9 @@ func (e *Engine) ExportSweepWriterFor(
// ExportReleaseSweepWriter releases a sweep-created registry entry // ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does. // exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter( 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 // NewTestArchiveSweeper builds an ArchiveSweeper backed by the
+3 -2
View File
@@ -35,8 +35,9 @@ const (
) )
// mIsolate gives the setup's engine a metric set registered on a // mIsolate gives the setup's engine a metric set registered on a
// registry this test holds, so its exact assertions can gather from // private registry. The process-wide collectors are moved by every
// it. // other delivery test running in parallel, so exact assertions are
// only possible against a registry this test owns.
func mIsolate( func mIsolate(
t *testing.T, s iSetup, t *testing.T, s iSetup,
) *prometheus.Registry { ) *prometheus.Registry {
@@ -376,97 +376,3 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
} }
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
-1
View File
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
"Content-Type": testContentType, "Content-Type": testContentType,
}, },
}, },
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
+32 -104
View File
@@ -25,64 +25,36 @@ var (
errNoIPs = errors.New( errNoIPs = errors.New(
"hostname resolved to no IP addresses", "hostname resolved to no IP addresses",
) )
// ErrBlockedPrivateOrReservedIP reports an address in the errBlockedIP = errors.New(
// default blocklist's private and reserved ranges, "blocked private, reserved or cloud metadata address",
// blockedNetworks.
ErrBlockedPrivateOrReservedIP = errors.New(
"blocked private or reserved address",
)
// errBlockedPublicMetadata reports a public address on the
// default blocklist, one in blockedPublicNetworks.
errBlockedPublicMetadata = errors.New(
"blocked cloud metadata address",
) )
errBlockedMetadata = errors.New( errBlockedMetadata = errors.New(
"blocked link-local, cloud instance metadata or " + "blocked link-local or cloud instance metadata " +
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it", "address: ALLOWED_EGRESS_CIDRS cannot open it",
) )
errInvalidScheme = errors.New( errInvalidScheme = errors.New(
"only http and https are allowed", "only http and https are allowed",
) )
) )
// blockedNetworks and blockedPublicNetworks together are the // blockedNetworks is the default blocklist: the private and
// default blocklist: the private and reserved IP ranges, plus // reserved IP ranges, plus the public cloud metadata addresses,
// the public cloud metadata addresses, that are blocked to // that are blocked to prevent SSRF attacks. An operator can
// prevent SSRF attacks. An operator can permit specific blocks // permit specific blocks out of this set with
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard. // ALLOWED_EGRESS_CIDRS; see Guard.
//
// blockedNetworks holds the private and reserved IP ranges.
// //
//nolint:gochecknoglobals // package-level network list is appropriate here //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
// blockedPublicNetworks holds the default blocklist's public
// addresses, kept apart from blockedNetworks so that they are
// refused as cloud metadata addresses, never as private or
// reserved ones.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything; it goes
// in this list. A provider's other public addresses are not
// refused, since reaching them can be legitimate and no list of
// them could be complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can // alwaysBlockedNetworks are the ranges no configuration can
// open, so a supplied CIDR that covers one still leaves it // open: the link-local blocks and the cloud instance metadata
// blocked. An entry is here for one of two reasons: it is a // endpoints that live outside them. Reaching one is credential
// metadata endpoint (the link-local blocks and the cloud // or user-data theft rather than delivery to an internal
// instance metadata endpoints that live outside them), or it is // service, so a supplied CIDR that covers such an address still
// an unspecified address. Reaching a metadata endpoint is // leaves it blocked.
// credential or user-data theft rather than delivery to an
// internal service.
// //
// Inclusion criterion for metadata endpoints — one belongs here // Inclusion criterion — an address belongs here only if BOTH
// only if BOTH hold, and every metadata entry below satisfies // hold, and every entry below satisfies both:
// both:
// //
// 1. It is a fixed address assigned by the provider, or a // 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose. // range reserved by IANA — never one the operator chose.
@@ -93,8 +65,8 @@ var blockedPublicNetworks []*net.IPNet
// not cheaply rotated. // not cheaply rotated.
// //
// Both halves are load-bearing, so use them to refuse a // Both halves are load-bearing, so use them to refuse a
// metadata candidate and say why. An endpoint disclosing only // candidate and say why. An endpoint disclosing only the
// the operator's own inventory (instance id, region, disks, NICs) // operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own // fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to // infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either — // provide. But (2) is not "IAM credentials only" either —
@@ -109,21 +81,12 @@ var blockedPublicNetworks []*net.IPNet
// when it clears both halves. Nothing in this list can be // when it clears both halves. Nothing in this list can be
// reopened, so putting a public address here leaves the operator // reopened, so putting a public address here leaves the operator
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS // no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
// exists to remove. Default-block it in blockedPublicNetworks // exists to remove. Default-block it in blockedNetworks instead,
// instead, which an allowlist can override. // which an allowlist can override.
// //
// This is a criterion, not an enumeration of every metadata // This is a criterion, not an enumeration of every metadata
// address in existence. // 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 // Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of // what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against // 169.254.169.254 that Contains does not match against
@@ -143,49 +106,23 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() { func init() {
blockedNetworks = mustParseCIDRs([]string{ blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8", "127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8", "10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12", "172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16", "192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16", "169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8", "0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10", "100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24", "192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24", "192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15", "198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24", "198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24", "203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4", "224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4", "240.0.0.0/4",
// IPv6 loopback.
"::1/128", "::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7", "fc00::/7",
// IPv6 link-local.
"fe80::/10", "fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
})
blockedPublicNetworks = mustParseCIDRs([]string{
// Azure WireServer, a public address that serves VM credentials. // Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32", "168.63.129.16/32",
}) })
@@ -242,14 +179,6 @@ func init() {
// allowlist from opening it. // allowlist from opening it.
"192.0.0.192/32", "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. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -289,6 +218,13 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
return false return false
} }
// isBlockedIP checks whether an IP address falls within
// the default blocklist, before any operator allowlist is
// considered.
func isBlockedIP(ip net.IP) bool {
return matchesAny(blockedNetworks, ip)
}
// Guard makes every SSRF decision in the process. // Guard makes every SSRF decision in the process.
// //
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and // It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
@@ -386,12 +322,10 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy: // The order is the policy:
// //
// 1. alwaysBlockedNetworks is refused before the allowlist is // 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local, a // consulted, so no configured CIDR reaches link-local or a
// cloud metadata endpoint at a non-public address, or an // cloud metadata endpoint at a non-public address.
// unspecified address.
// 2. The allowlist is consulted next, so a listed private // 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default // network becomes reachable.
// blocklist, becomes reachable.
// 3. Everything else keeps the default blocklist's answer. // 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error { func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) { if matchesAny(alwaysBlockedNetworks, ip) {
@@ -404,15 +338,9 @@ func (g *Guard) checkIP(ip net.IP) error {
return nil return nil
} }
if matchesAny(blockedNetworks, ip) { if isBlockedIP(ip) {
return fmt.Errorf( return fmt.Errorf(
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP, "target IP %s: %w", ip, errBlockedIP,
)
}
if matchesAny(blockedPublicNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedPublicMetadata,
) )
} }
+12 -131
View File
@@ -23,10 +23,6 @@ const (
metadataIP = "169.254.169.254" metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/" metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// linkLocalIPv4 is the IPv4 link-local block, which holds
// metadataIP.
linkLocalIPv4 = "169.254.0.0/16"
// loopbackHookURL is a target on this host: blocked by // loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists // default, reachable only once an operator allowlists
// loopback. // loopback.
@@ -168,13 +164,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft // case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, and the // rather than delivery to an internal service, so no allowlist
// unspecified addresses 0.0.0.0 and :: reach this host's loopback // reaches one. Every guard below names a CIDR that covers its
// on Linux, so no allowlist reaches any of them. Every guard // target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
// below names a CIDR that covers its target — including // CGNAT blocks an operator would really list — and the address
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an // must stay refused anyway, on both the validation and the
// operator would really list — and the address must stay // delivery path.
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -220,17 +215,15 @@ type metadataAlwaysRefusedCase struct {
} }
// metadataAlwaysRefusedCases enumerates every unconditionally // metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address (link-local, the cloud metadata endpoints and // blocked address together with an allowlist entry that would
// the unspecified addresses) together with an allowlist entry // otherwise reach it. Split by family of address only to stay
// that would otherwise reach it. Split by family of address only // under the function-length limit.
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases() cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, unspecifiedRefusedCases()...) return append(cases, encodedMetadataRefusedCases()...)
} }
// linkLocalRefusedCases covers the link-local blocks, including // linkLocalRefusedCases covers the link-local blocks, including
@@ -244,7 +237,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
}, },
{ {
name: "whole link-local block", name: "whole link-local block",
allow: linkLocalIPv4, allow: "169.254.0.0/16",
target: metadataURL, target: metadataURL,
}, },
{ {
@@ -370,23 +363,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
} }
} }
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does // TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it // not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set. // existed and stay reachable, whether or not a list is set.
@@ -436,9 +412,6 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
"WireServer must be refused by the default blocklist, "+ "WireServer must be refused by the default blocklist, "+
"which an allowlist can override", "which an allowlist can override",
) )
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
"WireServer is public, not private or reserved",
)
assertDialRefused(t, defaultGuard, target) assertDialRefused(t, defaultGuard, target)
@@ -523,7 +496,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
want := []string{ want := []string{
// IPv4 link-local: the 169.254.169.254 metadata // IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others. // service on AWS, Azure and others.
linkLocalIPv4, "169.254.0.0/16",
// IPv6 link-local. // IPv6 link-local.
"fe80::/10", "fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may // AWS IPv6 IMDS, inside the ULA space an operator may
@@ -544,10 +517,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked // Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24. // 192.0.0.0/24.
"192.0.0.192/32", "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. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -557,94 +526,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
assert.Equal(t, want, got) assert.Equal(t, want, got)
} }
// TestDefaultBlocklist_PinnedSet pins each list of the default
// blocklist on its own, the private and reserved ranges in
// blockedNetworks and the public addresses in
// blockedPublicNetworks, so moving an entry from one list to the
// other fails it. For the first address of each entry it then
// checks that the default guard refuses it, and that listing the
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
// set holds that address.
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
t.Parallel()
// public marks an entry of blockedPublicNetworks; every other
// entry belongs in blockedNetworks.
tests := []struct {
cidr string
public bool
reopenable bool
}{
{cidr: "127.0.0.0/8", reopenable: true},
{cidr: "10.0.0.0/8", reopenable: true},
{cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false},
// Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
{cidr: "198.18.0.0/15", reopenable: true},
{cidr: "198.51.100.0/24", reopenable: true},
{cidr: "203.0.113.0/24", reopenable: true},
{cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true},
}
wantPrivate := make([]string, 0, len(tests))
wantPublic := make([]string, 0, len(tests))
for _, tt := range tests {
if tt.public {
wantPublic = append(wantPublic, tt.cidr)
} else {
wantPrivate = append(wantPrivate, tt.cidr)
}
}
gotPrivate := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedNetworks() {
gotPrivate = append(gotPrivate, n.String())
}
gotPublic := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedPublicNetworks() {
gotPublic = append(gotPublic, n.String())
}
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(tt.cidr)
ip := net.IP(prefix.Addr().AsSlice())
require.Error(t,
delivery.NewTestGuard().ExportCheckIP(ip),
"the default guard must refuse %s", ip,
)
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
if tt.reopenable {
assert.NoError(t, err, "listing %s must open it", tt.cidr)
} else {
assert.Error(t, err, "listing %s must not open it", tt.cidr)
}
})
}
}
// requireLoopback fails the test unless rawURL's host is a // requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop // loopback address, so the allowlist test cannot silently stop
// exercising a blocked range. // exercising a blocked range.
+4 -42
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
func TestGuardCheckIP_PrivateRanges(t *testing.T) { func TestIsBlockedIP_PrivateRanges(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -56,14 +56,12 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
"failed to parse IP %s", tt.ip, "failed to parse IP %s", tt.ip,
) )
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
assert.Equal(t, assert.Equal(t,
tt.blocked, tt.blocked,
refused, delivery.ExportIsBlockedIP(ip),
"default guard refuses %s = %v, want %v", "isBlockedIP(%s) = %v, want %v",
tt.ip, tt.ip,
refused, delivery.ExportIsBlockedIP(ip),
tt.blocked, tt.blocked,
) )
}) })
@@ -101,42 +99,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
} }
} }
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) { func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel() t.Parallel()
-1
View File
@@ -105,7 +105,6 @@ func (e *Engine) initTargets(client *http.Client) {
dbT := &databaseTarget{eng: e} dbT := &databaseTarget{eng: e}
e.httpTarget = httpT e.httpTarget = httpT
e.slackTarget = slackT
e.dbTarget = dbT e.dbTarget = dbT
e.targets = map[database.TargetType]Target{ e.targets = map[database.TargetType]Target{
+7 -20
View File
@@ -41,8 +41,6 @@ type TargetConfigForm struct {
Timeout string Timeout string
// Expiry is the database (archive) target's row expiry. // Expiry is the database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is the database (archive) target's rotation.
Rotation string
} }
// NewTargetConfigForm parses a target's stored configuration into // NewTargetConfigForm parses a target's stored configuration into
@@ -87,13 +85,11 @@ func NewTargetConfigForm(
} }
} }
// databaseConfigForm parses an archive target's optional expiry and // databaseConfigForm parses an archive target's optional expiry.
// rotation. An absent, empty or never expiry yields an empty expiry, // An absent or empty configuration is the keep-forever default and
// on which the edit form starts at never; saving it unchanged stores // yields an empty field, so re-saving the form unchanged stores the
// never, which means the same as an empty expiry. An absent rotation // same empty configuration it started with. An expiry that is set
// is empty too, and the form starts at none. An expiry that is set // but not a valid duration is an error, not a blank field.
// but not a valid duration, or a rotation that is not one of the
// four, is an error, not a blank field.
func databaseConfigForm( func databaseConfigForm(
configJSON string, configJSON string,
) (TargetConfigForm, error) { ) (TargetConfigForm, error) {
@@ -110,15 +106,8 @@ func databaseConfigForm(
) )
} }
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return TargetConfigForm{}, err
}
form := TargetConfigForm{Rotation: cfg.Rotation}
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return form, nil return TargetConfigForm{}, nil
} }
err = ValidateArchiveExpiry(cfg.Expiry) err = ValidateArchiveExpiry(cfg.Expiry)
@@ -126,7 +115,5 @@ func databaseConfigForm(
return TargetConfigForm{}, err return TargetConfigForm{}, err
} }
form.Expiry = cfg.Expiry return TargetConfigForm{Expiry: cfg.Expiry}, nil
return form, nil
} }
+51 -79
View File
@@ -1,9 +1,9 @@
package delivery package delivery
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -97,7 +97,7 @@ func targetConfigFields(
) []ConfigField { ) []ConfigField {
switch t.Type { switch t.Type {
case database.TargetTypeSlack: case database.TargetTypeSlack:
return slackConfigFields(t) return slackConfigFields(t.Config)
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return httpConfigFields(t) return httpConfigFields(t)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
@@ -119,11 +119,10 @@ func unavailableConfigFields() []ConfigField {
}} }}
} }
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target. Only the masked
// webhook URL and its retry count. Only the masked URL is // webhook URL is shown; the full URL is the credential.
// shown; the full URL is the credential. func slackConfigFields(configJSON string) []ConfigField {
func slackConfigFields(t *database.Target) []ConfigField { cfg, err := parseSlackConfig(configJSON)
cfg, err := parseSlackConfig(t.Config)
if err != nil { if err != nil {
return unavailableConfigFields() return unavailableConfigFields()
} }
@@ -131,7 +130,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(), Value: cfg.MaskedWebhookURL(),
}, maxRetriesField(t)} }}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -171,90 +170,63 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
fields = append(fields, maxRetriesField(t)) return append(fields, retryFields(t)...)
}
// retryFields describes a target's retry settings, which live
// on the target row rather than in its configuration blob.
func retryFields(t *database.Target) []ConfigField {
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
}
fields := []ConfigField{{
Label: "Max Retries",
Value: retries,
}}
if t.MaxQueueSize > 0 {
fields = append(fields, ConfigField{
Label: "Max Queue Size",
Value: strconv.Itoa(t.MaxQueueSize),
})
}
return fields return fields
} }
// maxRetriesField describes a target's retry count, which lives // databaseConfigFields describes an archive target. Its
// on the target row rather than in its configuration blob. A // configuration is optional, and an absent or empty expiry
// stored 0 makes a single attempt, so it is shown as 1. // means the archive is kept forever. An expiry that is set
func maxRetriesField(t *database.Target) ConfigField { // but not a valid duration is reported as unavailable rather
attempts := strconv.Itoa(t.MaxRetries) // than echoed back.
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{
Label: "Delivery attempts",
Value: attempts,
}
}
// databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever, and by its rotation. An expiry
// that is set but not a valid duration, or a rotation that is
// not one of the four, is reported as unavailable rather than
// echoed back.
func databaseConfigFields(configJSON string) []ConfigField { func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON) expiry := archiveExpiryNever
if err != nil {
return unavailableConfigFields()
}
rotation, err := parseArchiveRotation(configJSON) if configJSON != "" {
if err != nil { var cfg databaseTargetConfig
return unavailableConfigFields()
}
value := archiveExpiryNever err := json.Unmarshal([]byte(configJSON), &cfg)
if expiry > 0 { if err != nil {
value = plainDuration(expiry) return unavailableConfigFields()
}
if cfg.Expiry != "" {
if ValidateArchiveExpiry(cfg.Expiry) != nil {
return unavailableConfigFields()
}
expiry = cfg.Expiry
}
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive expiry", Label: "Archive Expiry",
Value: value, Value: expiry,
}, {
Label: "Archive rotation",
Value: rotation,
}} }}
} }
// plainDuration writes a positive duration as a count of the
// largest whole unit it divides into: "30 days", "12 hours",
// "1 minute". A duration with a fraction of a second is
// written as Go writes it.
func plainDuration(d time.Duration) string {
const day = 24 * time.Hour
units := []struct {
size time.Duration
name string
}{
{day, "day"},
{time.Hour, "hour"},
{time.Minute, "minute"},
{time.Second, "second"},
}
for _, unit := range units {
if d%unit.size != 0 {
continue
}
count := int64(d / unit.size)
if count == 1 {
return "1 " + unit.name
}
return fmt.Sprintf("%d %ss", count, unit.name)
}
return d.String()
}
// MaskedWebhookURL returns the Slack webhook URL reduced to // MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo // its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of // elided. The path segments are the credential, so none of
+14 -80
View File
@@ -32,7 +32,6 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -158,7 +157,9 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
t, slackTargetName+" (deleted)", view.DisplayName(), t, slackTargetName+" (deleted)", view.DisplayName(),
) )
assert.Equal( assert.Equal(
t, viewFor(t, slackTarget()).Config, view.Config, t,
map[string]string{"Webhook URL": slackMaskedURL},
fieldMap(view.Config),
) )
} }
@@ -188,30 +189,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{"Webhook URL": slackMaskedURL},
"Webhook URL": slackMaskedURL,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
},
fieldMap(view.Config),
)
}
// TestNewTargetViews_SlackRetries proves a Slack target shows
// its retry count the same way an HTTP target does.
func TestNewTargetViews_SlackRetries(t *testing.T) {
t.Parallel()
target := slackTarget()
target.MaxRetries = 2
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "2",
},
fieldMap(view.Config), fieldMap(view.Config),
) )
} }
@@ -224,7 +202,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Config: `{"url":"` + viewExampleHook + `",` + Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"}}`, `"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5, MaxRetries: 5,
MaxQueueSize: 100,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -235,7 +214,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
viewMaxRetries: "5", "Max Retries": "5",
"Max Queue Size": "100",
}, },
fields, fields,
) )
@@ -258,7 +238,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", "Max Retries": "0 (fire-and-forget)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -301,20 +281,14 @@ func TestNewTargetViews_Database(t *testing.T) {
}{ }{
"empty config": {config: "", want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": { "never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`, config: `{"expiry":"` + viewExpiryNever + `"}`,
want: viewExpiryNever, want: viewExpiryNever,
}, },
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
} }
for name, tc := range tests { for name, tc := range tests {
@@ -328,49 +302,13 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{"Archive Expiry": tc.want},
"Archive expiry": tc.want,
"Archive rotation": rotationNone,
},
fieldMap(view.Config), fieldMap(view.Config),
) )
}) })
} }
} }
// TestNewTargetViews_DatabaseRotation proves the target list shows a
// database target's rotation, none when it has none stored.
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
t.Parallel()
// Each stored config, and the rotation the list shows for it.
tests := map[string]string{
"": rotationNone,
`{"rotation":""}`: rotationNone,
}
for _, rotation := range []string{
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
tests[`{"rotation":"`+rotation+`"}`] = rotation
}
for config, want := range tests {
t.Run(config, func(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase,
Config: config,
})
assert.Equal(
t, want, fieldMap(view.Config)["Archive rotation"],
)
})
}
}
func TestNewTargetViews_Log(t *testing.T) { func TestNewTargetViews_Log(t *testing.T) {
t.Parallel() t.Parallel()
@@ -418,10 +356,6 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`, Config: `{"expiry":"a fortnight"}`,
}, },
"invalid archive rotation": {
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
} }
for name, target := range tests { for name, target := range tests {
+97 -215
View File
@@ -3,7 +3,7 @@ package delivery
import ( import (
"context" "context"
"fmt" "fmt"
"strings" "path/filepath"
"sync" "sync"
"time" "time"
@@ -11,76 +11,22 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// archiveNameMaxLen is how many characters of a webhook or target // databaseTarget is a no-retry target that archives the
// name an archive file name keeps. // full inbound event into a per-webhook archive SQLite file,
const archiveNameMaxLen = 40 // separate from the per-webhook event database. The event is
// already persisted in the per-webhook event DB by the time
// databaseTarget is a no-retry target that archives the full // delivery runs; the database target additionally writes a
// inbound event into the target's own archive SQLite file, separate // durable long-term copy into archive-{webhookID}.db and then
// from the per-webhook event database. The event is already // records a single attempt whose outcome reflects whether the
// persisted in the per-webhook event DB by the time delivery runs; // archive write succeeded. See archiveWriter for the
// the database target additionally writes a durable long-term copy // close/reopen, auto-recreate, and expiry semantics.
// into the file ArchiveFileName names, with a period added when the
// target rotates (see archivePeriodPath), 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 { type databaseTarget struct {
eng *Engine eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex mu sync.Mutex
writers map[string]*archiveWriter 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 // Deliver implements Target. It archives the event, then
// records one successful attempt and marks the delivery // records one successful attempt and marks the delivery
// delivered. An archiving error fails the delivery: the // delivered. An archiving error fails the delivery: the
@@ -146,10 +92,9 @@ 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 and // archive database, honouring the optional per-target expiry
// rotation parsed from the target config JSON. With rotation, the // parsed from the target config JSON.
// event goes to the file for the period of its receive time.
func (t *databaseTarget) archive(d *database.Delivery) error { func (t *databaseTarget) archive(d *database.Delivery) error {
webhookID := d.Event.WebhookID webhookID := d.Event.WebhookID
if webhookID == "" { if webhookID == "" {
@@ -161,20 +106,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err return err
} }
rotation, err := parseArchiveRotation(d.Target.Config) w, err := t.writerFor(webhookID)
if err != nil {
return err
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see Engine.hydrateEvent), and goes to the
// file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := t.writerFor(d.TargetID)
if err != nil { if err != nil {
return err return err
} }
@@ -189,34 +121,33 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
ContentType: d.Event.ContentType, ContentType: d.Event.ContentType,
} }
return w.write(row, expiry, archivePeriod(rotation, receivedAt)) return w.write(row, expiry)
} }
// writerFor returns the archive writer for a database target, // writerFor returns the archiveWriter for a webhook, creating
// creating and caching it on first use. Each target has one writer // and caching it on first use. Each webhook has one writer so
// so its close/reopen debounce state is shared across concurrent // its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock // deliveries. The archive file lives beside the per-webhook
// as its writes. // event database in the data directory.
func (t *databaseTarget) writerFor( func (t *databaseTarget) writerFor(
targetID string, webhookID string,
) (*archiveWriter, error) { ) (*archiveWriter, error) {
path, err := t.archivePath(webhookID)
if err != nil {
return nil, err
}
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() 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 { if !ok {
var err error w = newArchiveWriter(path, t.eng.log)
t.writers[webhookID] = w
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
} }
// A delivery claims the entry: even if the idle sweep created // A delivery claims the entry: even if the idle sweep created
@@ -228,39 +159,40 @@ func (t *databaseTarget) writerFor(
} }
// sweepWriterFor returns the archive writer the idle sweep should // sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep // prune a webhook through, together with whether the sweep itself
// itself created the registry entry. // created the registry entry.
// //
// The sweep must route its prune through the registered writer so // The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it // the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran // 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 // re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep // exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to // creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done. // releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor( func (t *databaseTarget) sweepWriterFor(
targetID string, webhookID string,
) (*archiveWriter, bool, error) { ) (*archiveWriter, bool, error) {
t.mu.Lock() path, err := t.archivePath(webhookID)
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil { if t.writers == nil {
t.writers = make(map[string]*archiveWriter) 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 w.sweepOwned = true
t.writers[targetID] = w t.writers[webhookID] = w
return w, true, nil return w, true, nil
} }
@@ -277,92 +209,57 @@ func (t *databaseTarget) sweepWriterFor(
// delivery that adopted the writer keeps a registered, evictable // delivery that adopted the writer keeps a registered, evictable
// one. // one.
func (t *databaseTarget) releaseSweepWriter( func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter, webhookID string, w *archiveWriter,
) { ) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
cur, ok := t.writers[targetID] cur, ok := t.writers[webhookID]
if !ok || cur != w || !cur.sweepOwned { if !ok || cur != w || !cur.sweepOwned {
return return
} }
delete(t.writers, targetID) delete(t.writers, webhookID)
} }
// newWriter builds the writer for a database target's archive. Its // archivePath returns the archive file path for a webhook: it
// path is the one ArchivePath gives for the webhook and the target as // lives beside the per-webhook event database in the data
// the main database names them now; from then on only rename changes // directory. It does not touch the filesystem.
// the name the writer uses. It does not touch the archive files. func (t *databaseTarget) archivePath(
func (t *databaseTarget) newWriter( webhookID string,
targetID string, ) (string, error) {
) (*archiveWriter, error) {
if t.eng.dbManager == nil { 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(). return filepath.Join(
Preload("Webhook"). dir, fmt.Sprintf("archive-%s.db", webhookID),
First(&target, "id = ?", targetID).Error ), nil
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w.webhookID = target.WebhookID
return w, nil
} }
// rename moves every one of a database target's archive files to the // evict drops a webhook's archive writer from the registry and
// name for webhookName and targetName. It goes through the target's // closes its handle, so a deleted webhook does not leave a
// writer, so the move holds the lock that writes and the idle sweep // writer (and an open archive handle within its debounce
// take, and later writes use the new name. // window) alive for the process lifetime.
//
// 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.
// //
// The map entry is removed under the registry lock, which is // The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's // then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other // 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 // closing under the writer's lock means eviction can never race
// a write. // a write.
// //
// Eviction is idempotent and silent for a target with no writer, // Eviction is idempotent and silent for a webhook with no
// which is the common case: only a database target that has // writer, which is the common case: a webhook with no database
// received an event or been renamed has one. It never deletes the // target never creates one. It never deletes the archive file.
// archive file. func (t *databaseTarget) evict(webhookID string) {
func (t *databaseTarget) evict(targetID string) {
t.mu.Lock() t.mu.Lock()
w, ok := t.writers[targetID] w, ok := t.writers[webhookID]
if ok { if ok {
delete(t.writers, targetID) delete(t.writers, webhookID)
} }
t.mu.Unlock() t.mu.Unlock()
@@ -375,41 +272,13 @@ func (t *databaseTarget) evict(targetID string) {
t.eng.log.Info( t.eng.log.Info(
"evicted archive writer", "evicted archive writer",
"target_id", targetID, "webhook_id", webhookID,
"path", w.path, "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 // 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 // workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the // moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file. // -wal, so a clean stop leaves each archive as a single file.
@@ -426,25 +295,38 @@ func (t *databaseTarget) evictAll() {
} }
} }
// sweepArchive prunes one database target's archive of rows older // sweepWebhook prunes one webhook's archive of rows older than
// than expiry, without requiring a write. A missing archive file is // expiry, without requiring a write. It returns nil (nothing to
// left missing (see sweepExpired), so a sweep never creates an // do) when the archive file does not exist, so a sweep never
// archive for a target that has never received an event. // 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 // It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once // 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. // resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive( func (t *databaseTarget) sweepWebhook(
targetID string, expiry time.Duration, webhookID string, expiry time.Duration,
) error { ) 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 { if err != nil {
return err return err
} }
if created { if created {
defer t.releaseSweepWriter(targetID, w) defer t.releaseSweepWriter(webhookID, w)
} }
return w.sweepExpired(expiry) return w.sweepExpired(expiry)
+55 -305
View File
@@ -4,10 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
@@ -43,7 +41,7 @@ const (
var ( var (
// errArchiveMissingWebhookID is returned when an event to // 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( errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id", "cannot archive event without a webhook id",
) )
@@ -63,19 +61,13 @@ var (
) )
// errArchiveWriterEvicted is returned when a writer that has // errArchiveWriterEvicted is returned when a writer that has
// been evicted (its target or its webhook was deleted) is used // been evicted (its webhook was deleted, or its last database
// again. An evicted writer is no longer in the registry, so // target was removed) is used again. An evicted writer is no
// reopening its file would leak a handle nothing owns. // longer in the registry, so reopening its file would leak a
// handle nothing owns.
errArchiveWriterEvicted = errors.New( errArchiveWriterEvicted = errors.New(
"archive writer has been evicted", "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 // databaseTargetConfig is the optional per-target JSON config
@@ -85,14 +77,10 @@ type databaseTargetConfig struct {
// archived rows are pruned, or "never" (the default) to // archived rows are pruned, or "never" (the default) to
// keep them forever. // keep them forever.
Expiry string `json:"expiry"` Expiry string `json:"expiry"`
// Rotation is none (the default), monthly, daily or hourly: see
// archivePeriod.
Rotation string `json:"rotation"`
} }
// archivedEvent is one fully captured webhook event stored in a // 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 // self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention. // database, which may prune events under its own retention.
type archivedEvent struct { type archivedEvent struct {
@@ -182,50 +170,33 @@ func ValidateArchiveExpiry(expiry string) error {
return nil return nil
} }
// archiveWriter owns one database target's archive SQLite files. // archiveWriter owns one per-webhook archive SQLite file. It
// It serialises writes, and after each write closes and reopens // serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so // the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The // an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the // next write recreates a moved or removed file, because the
// file is opened create-if-missing and its schema is migrated // file is opened create-if-missing and its schema is migrated
// on every open. // on every open.
type archiveWriter struct { type archiveWriter struct {
mu sync.Mutex mu sync.Mutex
path string
// path is the target's archive file as ArchivePath names it. A
// target that rotates writes to the files archivePeriodPath names
// for path and a period instead.
path string
// current is the file db is open on.
current string
log *slog.Logger log *slog.Logger
debounce time.Duration debounce time.Duration
db *gorm.DB db *gorm.DB
lastReopen time.Time lastReopen time.Time
reopens int 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 // evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the // per-webhook registry. Its handle is closed and it must
// file again: nothing holds it any more, so a reopen would // never open the file again: nothing holds it any more, so a
// leak the handle for the process lifetime. // reopen would leak the handle for the process lifetime.
evicted bool 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 // 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 removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry // 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 // the writer clears the flag, handing the entry to the
// registry proper. // registry proper.
// //
@@ -244,18 +215,15 @@ func newArchiveWriter(
path: path, path: path,
log: log, log: log,
debounce: archiveReopenDebounce, debounce: archiveReopenDebounce,
now: time.Now,
} }
} }
// write appends the event as a row to the archive file for period // write appends the event as a row, then applies the debounced
// (see archivePeriodPath), then applies the debounced close/reopen. // close/reopen. It recreates the archive file if it was moved
// When period names a different file from the one open, the open one // or removed since the last open. A positive expiry prunes rows
// is closed first. It recreates the archive file if it was moved or // older than it on each (re)open.
// removed since the last open. A positive expiry prunes rows older
// than it on each (re)open.
func (w *archiveWriter) write( func (w *archiveWriter) write(
row archivedEvent, expiry time.Duration, period string, row archivedEvent, expiry time.Duration,
) error { ) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -266,10 +234,8 @@ func (w *archiveWriter) write(
) )
} }
file := archivePeriodPath(w.path, period) if w.db == nil || !fileExists(w.path) {
err := w.reopen(expiry)
if w.db == nil || w.current != file || !fileExists(file) {
err := w.reopen(file, expiry)
if err != nil { if err != nil {
return err return err
} }
@@ -280,41 +246,41 @@ func (w *archiveWriter) write(
err := w.db.Create(&row).Error err := w.db.Create(&row).Error
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"archiving event to %s: %w", file, err, "archiving event to %s: %w", w.path, err,
) )
} }
if w.now().Sub(w.lastReopen) >= w.debounce { if time.Since(w.lastReopen) >= w.debounce {
return w.reopen(file, expiry) return w.reopen(expiry)
} }
return nil return nil
} }
// open opens (creating if missing) an archive file, migrates // open opens (creating if missing) the archive file, migrates
// its schema, records the reopen time, and prunes expired rows // its schema, records the reopen time, and prunes expired rows
// when expiry is positive. // when expiry is positive.
func (w *archiveWriter) open(file string, expiry time.Duration) error { func (w *archiveWriter) open(expiry time.Duration) error {
return w.openMode(file, archiveModeCreate, expiry) return w.openMode(archiveModeCreate, expiry)
} }
// openMode opens an archive file with the given SQLite URI // openMode opens the archive file with the given SQLite URI
// mode, migrates its schema, records the reopen time, and // mode, migrates its schema, records the reopen time, and
// prunes expired rows when expiry is positive. The write path // prunes expired rows when expiry is positive. The write path
// passes archiveModeCreate so a missing file is recreated; the // passes archiveModeCreate so a missing file is recreated; the
// idle sweep passes archiveModeExisting so a missing file is an // idle sweep passes archiveModeExisting so a missing file is an
// error rather than a newly conjured empty archive. // error rather than a newly conjured empty archive.
func (w *archiveWriter) openMode( func (w *archiveWriter) openMode(
file, mode string, expiry time.Duration, mode string, expiry time.Duration,
) error { ) error {
// Opened through database.OpenSQLite so an archive file carries // Opened through database.OpenSQLite so an archive file carries
// the same WAL journaling, busy timeout, immediate-transaction // the same WAL journaling, busy timeout, immediate-transaction
// locking, and pool bounds as every other database file. See // locking, and pool bounds as every other database file. See
// internal/database/sqlite_open.go. // internal/database/sqlite_open.go.
sqlDB, err := database.OpenSQLite(file, mode) sqlDB, err := database.OpenSQLite(w.path, mode)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"opening archive database %s: %w", file, err, "opening archive database %s: %w", w.path, err,
) )
} }
@@ -330,7 +296,7 @@ func (w *archiveWriter) openMode(
return fmt.Errorf( return fmt.Errorf(
"connecting to archive database %s: %w", "connecting to archive database %s: %w",
file, err, w.path, err,
) )
} }
@@ -339,13 +305,12 @@ func (w *archiveWriter) openMode(
_ = sqlDB.Close() _ = sqlDB.Close()
return fmt.Errorf( return fmt.Errorf(
"migrating archive database %s: %w", file, err, "migrating archive database %s: %w", w.path, err,
) )
} }
w.db = gdb w.db = gdb
w.current = file w.lastReopen = time.Now()
w.lastReopen = w.now()
w.reopens++ w.reopens++
if expiry > 0 { if expiry > 0 {
@@ -355,12 +320,12 @@ func (w *archiveWriter) openMode(
return nil return nil
} }
// reopen closes any open handle and opens file afresh. The // reopen closes any open handle and opens the file afresh. The
// fresh open recreates the file if it was moved away. // fresh open recreates the file if it was moved away.
func (w *archiveWriter) reopen(file string, expiry time.Duration) error { func (w *archiveWriter) reopen(expiry time.Duration) error {
w.close() w.close()
return w.open(file, expiry) return w.open(expiry)
} }
// close closes the underlying handle, if any. // close closes the underlying handle, if any.
@@ -377,56 +342,22 @@ func (w *archiveWriter) close() {
w.db = nil w.db = nil
} }
// sweepExpired prunes the target's archive files, which may have // sweepExpired prunes an archive that may have gone idle, with
// gone idle, with no write to trigger the usual on-reopen prune. It // no write to trigger the usual on-reopen prune. It takes the
// lists the files under the writer's own mutex, then takes the mutex // writer's own mutex for the whole operation, so a sweep is
// again for one file at a time, so a write waits for at most one // ordered against concurrent writes rather than reaching around
// file's prune, and each prune is ordered against concurrent writes // them to the file.
// rather than reaching around them to the file.
// //
// It never creates an archive file: it prunes only the files // It never creates the archive file: a missing file is skipped,
// archiveFiles lists, skips one that is gone by the time it is // and the reopen uses archiveModeExisting so SQLite itself
// reached (moved away, or renamed since the listing), and opens each // refuses to create one if the file disappears between the
// with archiveModeExisting so SQLite itself refuses to create one if // check and the open.
// the file disappears between the check and the open. A file named
// for a period that the prune leaves empty is deleted.
// //
// The archive is left CLOSED afterwards. An idle archive holding // The archive is left CLOSED afterwards. An idle archive holding
// no handle is what keeps the operator's move-the-file-away // no handle is what keeps the operator's move-the-file-away
// workflow working; the next write reopens (and recreates) the // workflow working; the next write reopens (and recreates) the
// file as it always has. // file as it always has.
func (w *archiveWriter) sweepExpired(expiry time.Duration) error { func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
w.mu.Lock()
files, err := archiveFiles(w.path)
w.mu.Unlock()
if err != nil {
return err
}
var errs []error
for _, file := range files {
err = w.sweepFile(file, expiry)
if errors.Is(err, errArchiveWriterEvicted) {
return err
}
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// sweepFile prunes one of the target's archive files for sweepExpired,
// holding w.mu while it does. It skips a file that is gone, and deletes
// the file, with its -wal and -shm, when it is named for a period and
// the prune leaves it empty.
func (w *archiveWriter) sweepFile(
file archiveFile, expiry time.Duration,
) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -436,7 +367,7 @@ func (w *archiveWriter) sweepFile(
) )
} }
if !fileExists(file.path) { if !fileExists(w.path) {
return nil return nil
} }
@@ -444,133 +375,21 @@ func (w *archiveWriter) sweepFile(
// freshly opened file, matching the write path's semantics. // freshly opened file, matching the write path's semantics.
w.close() w.close()
err := w.openMode(file.path, archiveModeExisting, expiry) err := w.openMode(archiveModeExisting, expiry)
if err != nil { if err != nil {
return err return err
} }
if file.period == "" {
w.close()
return nil
}
var rows int64
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
w.close() w.close()
if err != nil {
return fmt.Errorf(
"counting rows in archive %s: %w", file.path, err,
)
}
if rows > 0 {
return nil
}
for _, suffix := range []string{"", "-wal", "-shm"} {
err = os.Remove(file.path + suffix)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("deleting empty archive file: %w", err)
}
}
w.log.Info("deleted empty archive file", "path", file.path)
return nil
}
// rename gives every one of the target's archive files the new
// name, keeping the period in the name of each (see
// archivePeriodPath), and the writer uses the files under that name
// from now on. The handle is closed first, which folds the -wal into
// the .db; any -wal or -shm still beside a file (left by a crash) is
// moved with it, because SQLite finds them by name. A target with no
// files is not an error: the operator may have moved them away, and
// the next write creates its file under the new name.
//
// If a file already has one of the new names, 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
}
files, err := archiveFiles(w.path)
if err != nil {
return err
}
// from[i] moves to to[i].
var from, to []string
for _, file := range files {
renamed := archivePeriodPath(path, file.period)
for _, suffix := range []string{"", "-wal", "-shm"} {
from = append(from, file.path+suffix)
to = append(to, renamed+suffix)
}
}
for _, taken := range to {
if fileExists(taken) {
return fmt.Errorf(
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
)
}
}
w.close()
for i := range from {
err = os.Rename(from[i], to[i])
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
for j := range i {
backErr := os.Rename(to[j], from[j])
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", to[j],
"to", from[j],
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", from[i], to[i], err,
)
}
w.path = path
return nil return nil
} }
// evict closes the writer's handle and marks it unusable. It is // evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, because its target // called when the writer leaves the registry, either because the
// or its webhook was deleted, or at shutdown. The archive FILE is // webhook was deleted or because its last database target was
// deliberately left on disk: it is long-term storage an operator // removed. The archive FILE is deliberately left on disk: it is
// may still want. // long-term storage an operator may still want.
func (w *archiveWriter) evict() { func (w *archiveWriter) evict() {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -595,7 +414,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.Error != nil { if res.Error != nil {
w.log.Error( w.log.Error(
"failed to prune expired archive rows", "failed to prune expired archive rows",
"path", w.current, "path", w.path,
"error", res.Error, "error", res.Error,
) )
@@ -605,81 +424,12 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.RowsAffected > 0 { if res.RowsAffected > 0 {
w.log.Info( w.log.Info(
"pruned expired archive rows", "pruned expired archive rows",
"path", w.current, "path", w.path,
"rows_deleted", res.RowsAffected, "rows_deleted", res.RowsAffected,
) )
} }
} }
// ArchiveFileInfo is what the metadata of a database target's archive
// files says about them.
type ArchiveFileInfo struct {
// Files counts the files.
Files int
// Size is the bytes on disk of the files and their -wal together.
Size int64
// Written is when a file or a -wal was last modified, whichever is
// latest: a write lands in the -wal first.
Written time.Time
}
// StatArchive reads the metadata of a database target's archive
// files, given the path ArchivePath gives it (see archiveFiles), and
// of their -wal, without opening them. With no files, which is so
// before the first write and after the operator moved them away, the
// error wraps fs.ErrNotExist.
func StatArchive(path string) (ArchiveFileInfo, error) {
files, err := archiveFiles(path)
if err != nil {
return ArchiveFileInfo{}, err
}
var info ArchiveFileInfo
for _, file := range files {
db, err := os.Stat(file.path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return ArchiveFileInfo{}, err
}
info.Files++
info.Size += db.Size()
if db.ModTime().After(info.Written) {
info.Written = db.ModTime()
}
wal, err := os.Stat(file.path + "-wal")
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return ArchiveFileInfo{}, err
}
info.Size += wal.Size()
if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime()
}
}
if info.Files == 0 {
return ArchiveFileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist,
)
}
return info, nil
}
// fileExists reports whether a path currently exists. // fileExists reports whether a path currently exists.
func fileExists(path string) bool { func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
+82 -92
View File
@@ -17,109 +17,85 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// deliverTo archives one event to a database target, leaving the // evictTestEngine builds an engine backed by a temporary data
// target's writer cached with its handle open. // directory and returns it along with that directory.
func deliverTo( func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
t.Helper() t.Helper()
webhookDB := testWebhookDB(t) dataDir := t.TempDir()
event := seedEvent(t, webhookDB, `{"archived":true}`)
env.eng.ExportDeliverDatabase( eng := delivery.NewTestEngineWithDB(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), nil,
database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
) )
return eng, dataDir
} }
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting // TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets // a webhook drops its archive writer from the registry and
// from the registry and closes their open handles, rather than // closes the open archive handle, rather than leaving both
// leaving them alive for the process lifetime, and leaves another // alive for the process lifetime.
// webhook's writer alone.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, dataDir := evictTestEngine(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} { webhookDB := testWebhookDB(t)
deliverTo(t, env, tgt) event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
require.True( eng.ExportDeliverDatabase(webhookDB, d)
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the writer should hold an open handle after a write",
)
}
env.eng.EvictWebhook(first.WebhookID) webhookID := event.WebhookID
for _, tgt := range []*database.Target{first, second} { require.True(
assert.False( t, eng.ExportHasArchiveWriter(webhookID),
t, env.eng.ExportHasArchiveWriter(tgt.ID), "a delivery should have cached an archive writer",
"eviction should remove the registry entry", )
) require.True(
assert.False( t, eng.ExportArchiveHandleOpen(webhookID),
t, env.eng.ExportArchiveHandleOpen(tgt.ID), "the writer should hold an open handle after a write",
"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",
) )
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one eng.EvictWebhook(webhookID)
// database target leaves the writer of another target of the same
// webhook in place.
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t) assert.False(
doomed := env.seedDatabaseTarget(t, "") t, eng.ExportHasArchiveWriter(webhookID),
kept := env.addDatabaseTarget(t, doomed.WebhookID, "") "eviction should remove the registry entry",
)
assert.False(
t, eng.ExportArchiveHandleOpen(webhookID),
"eviction should close the archive handle",
)
deliverTo(t, env, doomed) archivePath := filepath.Join(
deliverTo(t, env, kept) dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
env.eng.EvictTarget(doomed.ID)
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists( assert.FileExists(
t, env.archivePath(doomed), t, archivePath,
"eviction must not delete the archive file", "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 // TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook or target that never had an // for the common case of a webhook that never had a database
// archive writer, and that repeating it does not panic. // target, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) { func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
env.eng.EvictWebhook("no-such-webhook") eng.EvictWebhook("no-such-webhook")
env.eng.EvictWebhook("no-such-webhook") eng.EvictWebhook("no-such-webhook")
env.eng.EvictTarget("no-such-target")
env.eng.EvictTarget("no-such-target")
}) })
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter("no-such-target"), t, eng.ExportHasArchiveWriter("no-such-webhook"),
"eviction must not create a writer", "eviction must not create a writer",
) )
} }
@@ -313,14 +289,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
tgt := env.seedDatabaseTarget(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 // Prime the registry so the test can hold the very writer the
// eviction is about to detach. // 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.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
@@ -330,7 +309,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex. // eviction has to contend for the writer's mutex.
race.awaitFirstWrite() race.awaitFirstWrite()
env.eng.EvictWebhook(tgt.WebhookID) eng.EvictWebhook(event.WebhookID)
sawEvicted, otherErr := race.wait() sawEvicted, otherErr := race.wait()
@@ -345,33 +324,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted", "been evicted",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, eng.ExportHasArchiveWriter(event.WebhookID),
"the registry entry must stay gone", "the registry entry must stay gone",
) )
} }
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction // 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. // subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what // It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers. // TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) webhookDB := testWebhookDB(t)
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID)) 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. // writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt) second := seedDatabaseTargetDelivery(
t, webhookDB, event, "",
)
eng.ExportDeliverDatabase(webhookDB, second)
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, eng.ExportHasArchiveWriter(event.WebhookID),
"a later delivery should recreate the writer", "a later delivery should recreate the writer",
) )
} }
@@ -383,16 +370,19 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
tgt := env.seedDatabaseTarget(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.NotNil(t, w)
require.True(t, w.HandleOpen()) 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) 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", "a refused write must not reopen the archive",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, eng.ExportHasArchiveWriter(event.WebhookID),
"the stop should empty the registry", "the stop should empty the registry",
) )
-386
View File
@@ -1,386 +0,0 @@
package delivery
import (
"compress/gzip"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// archiveTableQuery counts the archive's table: 0 when the archive
// writer has created the file but not yet the table in it.
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'"
// ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the
// name ArchiveFileName gives it.
func ArchivePath(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
) string {
return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)),
ArchiveFileName(webhook.Name, target.Name, target.ID),
)
}
// ArchiveExportFileName returns the name a database target's archive
// downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in ArchiveFileName and the time in UTC.
func ArchiveExportFileName(
webhookName, targetName string, at time.Time,
) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" +
at.UTC().Format("20060102T150405Z") + ".json.gz"
}
// ArchiveExport is a database target's archive listed for download. It
// opens one of the target's files at a time, only when its rows are
// about to be written out, and closes it before it opens the next, so
// an export holds at most one file open however many the target has.
//
// Each file is read on its own connection inside one read-only
// transaction, so its rows are written out as the file stood when it
// was opened. Archives are in WAL mode, where a reader works from a
// snapshot and never blocks a writer: archive writes go on while a file
// is open, and the export does not see them. SQLite cannot checkpoint
// a -wal past an open snapshot, so the open file's -wal grows until the
// export has written that file out.
type ArchiveExport struct {
// periods are the periods of the target's files when the export
// was listed, "" for the file without one, in the order
// archiveFiles lists them.
periods []string
// lock is held while currentPath is called and a file is opened,
// so that a rename, which holds it too, cannot move the file in
// between.
lock sync.Locker
// currentPath returns the path ArchivePath gives the target under
// the names stored for it now, which a rename may have changed since
// the export was listed.
currentPath func() (string, error)
log *slog.Logger
}
// exportFile is one archive file opened for an export.
type exportFile struct {
db *sql.DB
tx *gorm.DB
// period is the period in the file's name, "" for none.
period string
// empty is true for a file without the archive's table yet.
empty bool
}
// exportedName is how an export names its webhook and its target.
type exportedName struct {
ID string `json:"id"`
Name string `json:"name"`
}
// NewArchiveExport lists a database target's archive files for export,
// given the path ArchivePath gives it (see archiveFiles). It opens none
// of them. Its caller holds lock, which every rename of the target's
// files runs under, from reading the names path is made of until it
// returns, so the files it lists are the ones those names give.
//
// WriteGzipJSON, called without lock held, finds each file again by its
// period under the path currentPath gives, holding lock while it does
// and while it opens the file, so a rename during the export loses no
// file. A file that is gone by then, emptied by the sweep or moved
// away, is skipped. The export never creates a file: with no files, it
// has no rows.
func NewArchiveExport(
path string,
lock sync.Locker,
currentPath func() (string, error),
log *slog.Logger,
) (*ArchiveExport, error) {
files, err := archiveFiles(path)
if err != nil {
return nil, err
}
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
for _, file := range files {
x.periods = append(x.periods, file.period)
}
return x, nil
}
// openExportFile opens one archive file for an export and takes its
// snapshot. The transaction lasts as long as ctx does.
func openExportFile(
ctx context.Context, file archiveFile, log *slog.Logger,
) (*exportFile, error) {
db, err := database.OpenSQLite(file.path, archiveModeExisting)
if err != nil {
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
}
gdb, err := gorm.Open(
sqlite.Dialector{Conn: db}, &gorm.Config{
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(log),
},
)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
}
// ReadOnly makes the driver begin a deferred transaction in place
// of the BEGIN IMMEDIATE the connection string asks for, so the
// export never takes the archive's write lock.
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
if tx.Error != nil {
_ = db.Close()
return nil, fmt.Errorf(
"reading archive %s: %w", file.path, tx.Error,
)
}
// The transaction's first read is what takes the snapshot.
var tables int
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
if err != nil {
_ = tx.Rollback()
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
}
return &exportFile{
db: db, tx: tx, period: file.period, empty: tables == 0,
}, nil
}
// WriteGzipJSON writes the export to w as one gzipped JSON object:
// webhook and target, each an id and a name; exported_at; and
// archived_events, one object per archived row, keyed by column name,
// the files in the order archiveFiles lists them. A row from a file
// named for a period has "period" beside its columns. A body that is
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
// with "body_encoding": "base64" beside it.
//
// Each row is written out before the next is read, so neither the
// archive nor its JSON is ever held in memory whole, and each file is
// closed once its rows are written, before the next is opened. When it
// returns, no file is open. After an error the gzip stream is left
// unfinished, so what was written does not decompress as a whole file.
func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context,
w io.Writer,
webhook *database.Webhook,
target *database.Target,
exportedAt time.Time,
) error {
head, err := json.Marshal(map[string]any{
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
"target": exportedName{ID: target.ID, Name: target.Name},
"exported_at": exportedAt.UTC(),
})
if err != nil {
return fmt.Errorf("encoding archive export: %w", err)
}
zw := gzip.NewWriter(w)
err = x.writeJSON(ctx, zw, head)
if err != nil {
return fmt.Errorf("writing archive export: %w", err)
}
return zw.Close()
}
// openFile finds the target's archive file for period under the path
// currentPath gives now, and opens it for the export, holding x.lock
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
func (x *ArchiveExport) openFile(
ctx context.Context, period string,
) (*exportFile, error) {
x.lock.Lock()
defer x.lock.Unlock()
path, err := x.currentPath()
if err != nil {
return nil, fmt.Errorf("finding archive file: %w", err)
}
file := archiveFile{path: archivePeriodPath(path, period), period: period}
_, err = os.Stat(file.path)
if err != nil {
return nil, err
}
return openExportFile(ctx, file, x.log)
}
// close ends the file's transaction and closes its connection.
func (f *exportFile) close() error {
_ = f.tx.Rollback()
return f.db.Close()
}
// writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time.
func (x *ArchiveExport) writeJSON(
ctx context.Context, w io.Writer, head []byte,
) error {
// head goes out without its closing brace, so that
// archived_events can follow it.
_, err := w.Write(head[:len(head)-1])
if err != nil {
return err
}
_, err = io.WriteString(w, `,"archived_events":[`)
if err != nil {
return err
}
err = x.writeRows(ctx, w)
if err != nil {
return err
}
_, err = io.WriteString(w, "\n]}\n")
return err
}
// writeRows writes the archived rows of each file to w, one per line,
// separated by commas, opening each file in turn and closing it once
// its rows are written.
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
sep := "\n"
for _, period := range x.periods {
f, err := x.openFile(ctx, period)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return err
}
sep, err = f.writeRows(ctx, w, sep)
err = errors.Join(err, f.close())
if err != nil {
return err
}
}
return nil
}
// writeRows writes the file's archived rows to w, oldest first, the
// first after sep and each other after ",\n". It returns what goes
// before the next row: sep again when the file had no rows.
func (f *exportFile) writeRows(
ctx context.Context, w io.Writer, sep string,
) (string, error) {
if f.empty {
return sep, nil
}
rows, err := f.tx.WithContext(ctx).
Model(&archivedEvent{}).Order("id").Rows()
if err != nil {
return "", err
}
defer func() { _ = rows.Close() }()
for ; rows.Next(); sep = ",\n" {
var ev archivedEvent
err = f.tx.ScanRows(rows, &ev)
if err != nil {
return "", err
}
_, err = io.WriteString(w, sep)
if err != nil {
return "", err
}
err = writeRow(w, &ev, f.period)
if err != nil {
return "", err
}
}
return sep, rows.Err()
}
// writeRow writes an archived row to w as a JSON object keyed by
// column name, its body in base64 when it is not valid UTF-8, with
// the period of its file beside them unless that is "".
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
row := map[string]any{
"id": ev.ID,
"event_id": ev.EventID,
"webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID,
"method": ev.Method,
"headers": ev.Headers,
"body": ev.Body,
"content_type": ev.ContentType,
"archived_at": ev.ArchivedAt.UTC(),
}
if !utf8.ValidString(ev.Body) {
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
row["body_encoding"] = "base64"
}
if period != "" {
row["period"] = period
}
line, err := json.Marshal(row)
if err != nil {
return err
}
_, err = w.Write(line)
return err
}
@@ -1,563 +0,0 @@
package delivery_test
import (
"bufio"
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The webhook and the target the export tests' archives belong to.
const (
exportWebhookID = "wh-export"
exportWebhookName = "Orders (EU)"
exportTargetID = "tgt-export"
exportTargetName = "Long-term archive"
)
// binaryBody is a body that is not valid UTF-8.
const binaryBody = "\xff\xfe\x00\x01binary\x80"
// writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z.
func writeExportTo(
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
) error {
t.Helper()
return export.WriteGzipJSON(
t.Context(), w,
&database.Webhook{
BaseModel: database.BaseModel{ID: exportWebhookID},
Name: exportWebhookName,
},
&database.Target{
BaseModel: database.BaseModel{ID: exportTargetID},
Name: exportTargetName,
},
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
)
}
// listExport lists the archive at path for export, as the archive of a
// target whose names do not change.
func listExport(t *testing.T, path string) *delivery.ArchiveExport {
t.Helper()
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
return path, nil
})
}
// newExport lists the archive at path for export, to find each file
// again under the path currentPath gives, holding lock while it does.
// Nothing else takes lock while it lists, so it does not hold lock.
func newExport(
t *testing.T,
path string,
lock sync.Locker,
currentPath func() (string, error),
) *delivery.ArchiveExport {
t.Helper()
export, err := delivery.NewArchiveExport(
path, lock, currentPath, archiveTestLogger(),
)
require.NoError(t, err)
return export
}
// exportArchive runs a whole export of the archive at path and returns
// its JSON, decompressed and parsed.
func exportArchive(t *testing.T, path string) map[string]any {
t.Helper()
return writeExport(t, listExport(t, path))
}
// writeExport writes an opened export and returns its JSON,
// decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished.
func writeExport(
t *testing.T, export *delivery.ArchiveExport,
) map[string]any {
t.Helper()
var buf bytes.Buffer
require.NoError(t, writeExportTo(t, export, &buf))
zr, err := gzip.NewReader(&buf)
require.NoError(t, err)
raw, err := io.ReadAll(zr)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(raw, &got))
return got
}
// exportedEvents returns an export's archived_events.
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
t.Helper()
list, ok := got["archived_events"].([]any)
require.True(t, ok, "archived_events must be an array: %v", got)
events := make([]map[string]any, len(list))
for i, v := range list {
events[i], ok = v.(map[string]any)
require.True(t, ok, "an archived event must be an object: %v", v)
}
return events
}
// exportedEventIDs returns the event_id of each of an export's
// archived_events.
func exportedEventIDs(t *testing.T, got map[string]any) []string {
t.Helper()
events := exportedEvents(t, got)
ids := make([]string, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
}
return ids
}
// TestArchiveExport_MatchesStoredRows proves an export holds the
// webhook, the target, the time, and every column of every stored
// row: a body that is valid UTF-8 as a string, and one that is not in
// base64, marked as such.
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
}, 0))
}
var stored []delivery.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error)
got := exportArchive(t, path)
assert.Equal(t,
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
got["webhook"],
)
assert.Equal(t,
map[string]any{"id": exportTargetID, "name": exportTargetName},
got["target"],
)
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
events := exportedEvents(t, got)
require.Len(t, events, len(bodies))
for i, row := range stored {
assertExportedRow(t, row, events[i])
}
}
// assertExportedRow checks that ev, from an export, holds every column
// of the stored row.
func assertExportedRow(
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
) {
t.Helper()
archivedAt, err := time.Parse(
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
)
require.NoError(t, err)
assert.True(t, archivedAt.Equal(row.ArchivedAt))
assert.EqualValues(t, row.ID, ev["id"])
assert.Equal(t, row.EventID, ev["event_id"])
assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return
}
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
require.NoError(t, err)
assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
}
// TestArchiveExport_Empty proves an archive with nothing in it exports
// as an empty archived_events: no file, which the export must not
// create; a file the archive writer has not yet put its table in; and
// a table with no rows.
func TestArchiveExport_Empty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missing := filepath.Join(dir, "missing.db")
noTable := filepath.Join(dir, "no-table.db")
noRows := filepath.Join(dir, "no-rows.db")
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t,
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0),
)
for _, path := range []string{missing, noTable, noRows} {
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
}
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, missing+suffix)
}
}
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
// export unlocks its lock right after it opens a file.
type unlockHook struct {
sync.Mutex
fn func()
}
func (u *unlockHook) Unlock() {
u.Mutex.Unlock()
u.fn()
}
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
// out as it was when the export opened it, and holds up no archive
// write: a row written after the export was listed but before the file
// was opened is in the export, and one written while the file is open
// is stored, and is not. A write held up for the whole busy timeout
// would fail.
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
}}
export := newExport(t, path, opened, func() (string, error) {
return path, nil
})
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
assert.Equal(t,
[]string{"listed", "before-open"},
exportedEventIDs(t, writeExport(t, export)),
)
var stored int64
require.NoError(t, openArchiveDBForRead(t, path).
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(3), stored)
}
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
// archive after an export has listed it, as renaming its webhook or
// target does, loses no file: the export finds each file again by its
// period under the new name. A file moved away by then is skipped.
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "archive-old.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
current := path
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
return current, nil
})
require.NoError(t, w.Rename("archive-new.db"))
current = filepath.Join(dir, "archive-new.db")
removeArchiveFiles(t, periodPath(current, dayPeriod))
assert.Equal(t,
[]string{"in-", "in-" + hourPeriod},
exportedEventIDs(t, writeExport(t, export)),
)
}
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
// file without a period and to its files for a month, an hour and a
// day, and proves the export holds every row: the file without a
// period first, then the others oldest period first, each row from a
// file named for a period carrying that period. A file made after the
// export was listed is not in it.
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Written in an order that is not the export's.
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
export := listExport(t, path)
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
))
events := exportedEvents(t, writeExport(t, export))
ids := make([]string, 0, len(events))
periods := make([]any, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
periods = append(periods, ev["period"])
}
assert.Equal(t,
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
ids,
)
assert.Equal(t,
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
)
assert.NotContains(t, events[0], "period",
"a row from the file without a period has no period")
}
// openFilesPeak is an io.Writer that discards what it is given and
// records the most archive files in dir the process had open at any
// write, as /proc/self/fd lists the files a process has open.
type openFilesPeak struct {
dir string
max int
}
func (p *openFilesPeak) Write(b []byte) (int, error) {
fds, err := os.ReadDir("/proc/self/fd")
if err != nil {
return 0, err
}
open := map[string]bool{}
for _, fd := range fds {
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
if err == nil && filepath.Dir(file) == p.dir &&
strings.HasSuffix(file, ".db") {
open[file] = true
}
}
p.max = max(p.max, len(open))
return len(b), nil
}
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
// each of 24 hours and proves the export never had more than one of
// them open, and had one open while it wrote. Each file holds a row of
// 48 KiB of random base64, which gzip shrinks little, so the export
// writes output while it reads each file.
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("only Linux lists a process's open files in /proc/self/fd")
}
// Readlink gives each open file's path with no symbolic link in it.
dir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
path := filepath.Join(dir, "archive-wh.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10)
for hour := range 24 {
_, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
}
// The writer's own handle on the last file is not the export's.
w.Evict()
// Through a buffer, the open files are listed once per 8 KiB of
// output, a few times for each file, rather than at each of gzip's
// small writes, which takes far longer.
peak := &openFilesPeak{dir: dir}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
require.NoError(t, buffered.Flush())
assert.Equal(t, 1, peak.max)
}
// heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage before each
// reading, so the heap it reads is what is still held.
type heapPeak struct {
max uint64
}
func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
p.max = max(p.max, m.HeapAlloc)
return len(b), nil
}
// exportHeapGrowth exports an archive of rows random bodies, each
// bodySize bytes of base64, and returns how far the heap rose above
// where it stood when the export began, at its highest.
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3)
for range rows {
_, _ = rand.Read(random)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0))
}
export := listExport(t, path)
runtime.GC()
var start runtime.MemStats
runtime.ReadMemStats(&start)
// Through a buffer, the heap is read once per 8 KiB of output
// rather than at each of gzip's small writes, which takes far
// longer.
peak := &heapPeak{max: start.HeapAlloc}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, export, buffered))
require.NoError(t, buffered.Flush())
return peak.max - start.HeapAlloc
}
// TestArchiveExport_Streams proves an export holds neither the archive
// nor its output in memory whole: exporting 384 KiB more of archive
// raises the heap's peak by less than half of that. The export's own
// memory, mostly gzip's compressor, is the same for both archives, so
// it cancels out. The bodies are random bytes in base64, which gzip
// shrinks by only a quarter, so an export that read every row before
// writing, or built the JSON or the gzipped file before writing it,
// would raise the peak by at least three quarters of the difference.
//
// The smaller archive has two rows so that its export, too, writes
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
// the heap must be read while the export's own memory is held.
//
//nolint:paralleltest // It measures the heap, which tests share.
func TestArchiveExport_Streams(t *testing.T) {
const (
bodySize = 16 << 10
smallRows = 2
largeRows = smallRows + 24
limit = (largeRows - smallRows) * bodySize / 2
)
small := exportHeapGrowth(t, smallRows, bodySize)
large := exportHeapGrowth(t, largeRows, bodySize)
assert.Less(t, large, small+limit,
"the heap rose by %d for %d rows and by %d for %d rows",
small, smallRows, large, largeRows,
)
}
// TestArchiveExportFileName proves the download is named for the
// webhook and the target, with the names made safe as for the archive
// file, and the export time in UTC.
func TestArchiveExportFileName(t *testing.T) {
t.Parallel()
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
),
)
}
@@ -1,198 +0,0 @@
package delivery
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// The archive rotations: how often a database target starts a new
// archive file. Every rotation but none puts the period of an event's
// receive time, in UTC, in the name of the file the event goes to,
// written in the layout beside it.
const (
archiveRotationNone = "none"
archiveRotationMonthly = "monthly"
archiveRotationDaily = "daily"
archiveRotationHourly = "hourly"
archiveMonthLayout = "2006-01"
archiveDayLayout = "2006-01-02"
archiveHourLayout = "2006-01-02-15"
)
// errArchiveRotationUnknown is returned for a rotation that is not one
// of the four.
var errArchiveRotationUnknown = errors.New(
"rotation must be none, monthly, daily or hourly",
)
// archiveFile is one of a database target's archive files, and the
// period in its name: "" for the file named without one.
type archiveFile struct {
path string
period string
}
// ValidateArchiveRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly,
// daily or hourly.
func ValidateArchiveRotation(rotation string) error {
switch rotation {
case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly:
return nil
default:
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
}
}
// parseArchiveRotation reads the rotation from a database target's
// config JSON. An empty config or an empty rotation is none.
func parseArchiveRotation(configJSON string) (string, error) {
if configJSON == "" {
return archiveRotationNone, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return "", fmt.Errorf("parsing database target config: %w", err)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return "", err
}
if cfg.Rotation == "" {
return archiveRotationNone, nil
}
return cfg.Rotation, nil
}
// archivePeriod returns the period, in UTC, that a rotation puts an
// event received at receivedAt in: "2026-10" for monthly,
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
func archivePeriod(rotation string, receivedAt time.Time) string {
switch rotation {
case archiveRotationMonthly:
return receivedAt.UTC().Format(archiveMonthLayout)
case archiveRotationDaily:
return receivedAt.UTC().Format(archiveDayLayout)
case archiveRotationHourly:
return receivedAt.UTC().Format(archiveHourLayout)
default:
return ""
}
}
// archivePeriodPath returns the path of a database target's archive
// file for a period: path, as ArchivePath gives it, with "-" and the
// period put before its ".db". The period "" gives path itself.
func archivePeriodPath(path, period string) string {
if period == "" {
return path
}
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// ArchivePathAt returns the archive file a database target writes an
// event received at receivedAt to: ArchivePath's file, with the
// period in its name when the target rotates.
func ArchivePathAt(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
receivedAt time.Time,
) (string, error) {
rotation, err := parseArchiveRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath(
ArchivePath(dbMgr, webhook, target),
archivePeriod(rotation, receivedAt),
), nil
}
// archiveFiles lists the database target's archive files that exist,
// given the path ArchivePath gives it: the file at path, then each
// file archivePeriodPath names for path and a period, oldest period
// first. Which rotation wrote a file does not matter, so the files of
// an earlier rotation setting are listed too.
func archiveFiles(path string) ([]archiveFile, error) {
dir := filepath.Dir(path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("listing archive files: %w", err)
}
stem := strings.TrimSuffix(filepath.Base(path), ".db")
var files []archiveFile
for _, entry := range entries {
period, ok := archiveFilePeriod(stem, entry.Name())
if ok {
files = append(files, archiveFile{
path: filepath.Join(dir, entry.Name()),
period: period,
})
}
}
// A month sorts before the days and hours in it.
slices.SortFunc(files, func(a, b archiveFile) int {
return strings.Compare(a.period, b.period)
})
return files, nil
}
// archiveFilePeriod reports whether name is the name of an archive
// file of the target whose file name without a period is stem+".db",
// and the period in it.
func archiveFilePeriod(stem, name string) (string, bool) {
rest, ok := strings.CutPrefix(name, stem)
if !ok {
return "", false
}
rest, ok = strings.CutSuffix(rest, ".db")
if !ok {
return "", false
}
if rest == "" {
return "", true
}
period, ok := strings.CutPrefix(rest, "-")
if !ok {
return "", false
}
for _, layout := range []string{
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
} {
_, err := time.Parse(layout, period)
if err == nil {
return period, true
}
}
return "", false
}
@@ -1,389 +0,0 @@
package delivery_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The archive rotations, and the configs of a daily target and of one
// whose rotation is not one of the four.
const (
rotationNone = "none"
rotationMonthly = "monthly"
rotationDaily = "daily"
rotationHourly = "hourly"
dailyConfig = `{"rotation":"daily"}`
weeklyConfig = `{"rotation":"weekly"}`
)
// The periods the tests archive into most: two days, and an hour of
// the first.
const (
dayPeriod = "2026-03-04"
nextDayPeriod = "2026-03-05"
hourPeriod = "2026-03-04-05"
)
// periodPath returns the archive file for a period of the target whose
// file without a period is path.
func periodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// deliverReceivedAt delivers to a database target an event whose
// receive time is receivedAt, and returns the event's id. The receive
// time is what decides a rotated archive's file, so setting it is how
// these tests move the clock across a period boundary.
func (env *archiveEnv) deliverReceivedAt(
t *testing.T, tgt *database.Target, receivedAt time.Time,
) string {
t.Helper()
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
event.CreatedAt = receivedAt
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
return event.ID
}
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
// rotation, an event received in the last second of a period and one
// received in the first second of the next, and checks each lands in
// the file named for its own period, in UTC. Rotation none keeps both
// in the one file.
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel()
berlin := time.FixedZone("CEST", 2*60*60)
cases := []struct {
name string
rotation string
before, after time.Time
// periods are the periods of before and after.
periods [2]string
}{
{
rotationMonthly, rotationMonthly,
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
[2]string{"2026-01", "2026-02"},
},
{
rotationDaily, rotationDaily,
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
[2]string{dayPeriod, nextDayPeriod},
},
{
// The same instants, received in a zone two hours ahead
// of UTC, where they fall on 5 March: the period is UTC's.
"daily in another zone", rotationDaily,
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
[2]string{dayPeriod, nextDayPeriod},
},
{
rotationHourly, rotationHourly,
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
[2]string{hourPeriod, "2026-03-04-06"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
path := env.archivePath(tgt)
first := env.deliverReceivedAt(t, tgt, tc.before)
second := env.deliverReceivedAt(t, tgt, tc.after)
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(path, tc.periods[0])))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, tc.periods[1])))
assert.NoFileExists(t, path,
"a rotated target never writes the file without a period")
})
}
t.Run(rotationNone, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
first := env.deliverReceivedAt(t, tgt, cases[0].before)
second := env.deliverReceivedAt(t, tgt, cases[0].after)
assert.ElementsMatch(t, []string{first, second},
archivedEventIDs(t, env.archivePath(tgt)))
})
}
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
// receive time is not known goes to the file for the time it is
// archived, rather than to one for the year 1.
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
path := env.archivePath(tgt)
before := time.Now().UTC().Format("2006-01")
id := env.deliverReceivedAt(t, tgt, time.Time{})
file := periodPath(path, time.Now().UTC().Format("2006-01"))
_, err := os.Stat(file)
if err != nil {
// The month turned during the delivery.
file = periodPath(path, before)
}
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
assert.NoFileExists(t, periodPath(path, "0001-01"))
}
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
// rotation from none to daily between two events, and checks the
// second goes to the daily file while the first stays where it was.
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
path := env.archivePath(tgt)
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
first := env.deliverReceivedAt(t, tgt, at)
tgt.Config = dailyConfig
second := env.deliverReceivedAt(t, tgt, at)
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, dayPeriod)))
}
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
// the file without a period, left from before it rotated, and two
// daily files. Each holds a row older than the expiry, and one daily
// file also a newer row. The sweep prunes the old row from every file,
// deletes the daily file it leaves empty, and keeps the file without a
// period although it is empty too.
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
)
path := env.archivePath(tgt)
emptied := periodPath(path, dayPeriod)
kept := periodPath(path, nextDayPeriod)
now := time.Now()
old := now.Add(-48 * time.Hour)
seedArchiveFile(t, path, tgt.WebhookID, old)
seedArchiveFile(t, emptied, tgt.WebhookID, old)
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
env.sweeper.ExportSweep(t.Context())
assert.Empty(t, archivedEventIDs(t, path))
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, emptied+suffix)
}
}
// TestRename_MovesEveryFile renames a daily target that also has a
// file without a period, and checks every file moves to the new name
// with its period, rows and all, and that a later write uses the new
// name.
func TestRename_MovesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
unrotated := env.deliverReceivedAt(t, tgt, day)
tgt.Config = dailyConfig
first := env.deliverReceivedAt(t, tgt, day)
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
for _, old := range []string{
oldPath,
periodPath(oldPath, dayPeriod),
periodPath(oldPath, nextDayPeriod),
} {
assert.NoFileExists(t, old)
}
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
assert.Equal(t, []string{third},
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
}
// TestRename_NeverReplacesARotatedFile plants a file at the new name
// of a target's daily file, and proves the rename is refused and moves
// none of the target's files.
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, dailyConfig)
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
env.deliverReceivedAt(t, tgt, day)
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
planted := periodPath(newPath, nextDayPeriod)
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
assert.FileExists(t, periodPath(oldPath, dayPeriod))
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
}
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
// one of a target's files, takes the latest write of any of them, and
// leaves out files whose names only look like the target's.
func TestStatArchive_EveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "archive-wh.db")
files := []string{
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
}
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
var size int64
for i, file := range files {
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
size += int64(100 * (i + 1))
at := written.Add(-time.Duration(i) * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
for _, other := range []string{
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
"archive-wh-other.db", "archive-wh-2026-03.json",
"archive-whx.db",
} {
require.NoError(t,
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
}
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, len(files), got.Files)
assert.Equal(t, size, got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
}
// TestArchivePathAt names the file each rotation writes an event to.
func TestArchivePathAt(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
dbMgr := database.NewTestWebhookDBManager(dataDir)
webhook := &database.Webhook{
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
}
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
cases := map[string]string{
"": "",
`{"rotation":"` + rotationNone + `"}`: "",
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
dailyConfig: "-2026-10-01",
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
}
for config, period := range cases {
target := &database.Target{
BaseModel: database.BaseModel{ID: "tgt-id"},
Name: "Archive",
Config: config,
}
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
require.NoError(t, err, config)
assert.Equal(t,
filepath.Join(
dataDir, "archive-orders-archive-tgt-id"+period+".db",
),
got, config,
)
}
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
Config: weeklyConfig,
}, at)
require.Error(t, err)
}
// TestValidateArchiveRotation accepts the four rotations, and empty,
// and refuses anything else.
func TestValidateArchiveRotation(t *testing.T) {
t.Parallel()
for _, ok := range []string{
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
}
for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
}
}
+45 -404
View File
@@ -3,14 +3,14 @@ package delivery_test
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -18,7 +18,6 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
func archiveTestLogger() *slog.Logger { func archiveTestLogger() *slog.Logger {
@@ -44,8 +43,7 @@ func openArchiveDBForRead(
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -76,18 +74,25 @@ func removeArchiveFiles(t *testing.T, path string) {
// TestDeliverDatabase_ArchivesEvent verifies that delivering to // TestDeliverDatabase_ArchivesEvent verifies that delivering to
// a database target marks the delivery delivered and archives // 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) { func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) dataDir := t.TempDir()
tgt := env.seedDatabaseTarget(t, "") dbMgr := database.NewTestWebhookDBManager(dataDir)
e := delivery.NewTestEngineWithDB(
nil, dbMgr,
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) 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 var updated database.Delivery
@@ -100,7 +105,8 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
) )
archivePath := filepath.Join( archivePath := filepath.Join(
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db", dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
) )
assert.FileExists(t, archivePath) assert.FileExists(t, archivePath)
@@ -184,67 +190,16 @@ func TestArchiveWriter_RecreatesAfterRemoval(
assert.Equal(t, "b", got[0].EventID) assert.Equal(t, "b", got[0].EventID)
} }
// TestStatArchive proves StatArchive finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) { func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel() 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") path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter( 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( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0, delivery.ExportArchivedEvent{EventID: "a"}, 0,
)) ))
@@ -256,7 +211,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
// initial open — no extra close/reopen. // initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens()) assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce) time.Sleep(2100 * time.Millisecond)
require.NoError(t, w.Write( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "c"}, 0, delivery.ExportArchivedEvent{EventID: "c"}, 0,
@@ -333,31 +288,31 @@ func TestParseArchiveExpiry(t *testing.T) {
} }
} }
// seedDatabaseTargetDelivery seeds a pending delivery of an event // seedDatabaseTargetDelivery seeds a pending delivery for a
// to a database target and returns the in-memory delivery the // database target with the given config JSON and returns the
// target handler is invoked with. // in-memory delivery the target handler is invoked with.
func seedDatabaseTargetDelivery( func seedDatabaseTargetDelivery(
t *testing.T, t *testing.T,
webhookDB *gorm.DB, webhookDB *gorm.DB,
event database.Event, event database.Event,
tgt *database.Target, config string,
) *database.Delivery { ) *database.Delivery {
t.Helper() t.Helper()
dlv := seedDelivery( dlv := seedDelivery(
t, webhookDB, event.ID, tgt.ID, t, webhookDB, event.ID, uuid.New().String(),
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
TargetID: tgt.ID, TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Event: event, Event: event,
Target: database.Target{ Target: database.Target{
Name: tgt.Name, Name: "test-db",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: tgt.Config, Config: config,
}, },
} }
d.ID = dlv.ID d.ID = dlv.ID
@@ -375,14 +330,22 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) dataDir := t.TempDir()
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
e := delivery.NewTestEngineWithDB(
nil, database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":false}`) 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 var updated database.Delivery
@@ -410,7 +373,10 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) )
assert.NoFileExists(t, 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", "no archive file should exist for a failed config",
) )
} }
@@ -434,328 +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())
}
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
))
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, entry := range entries {
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
"%s is not under the old name", entry.Name())
}
assert.Equal(t, oldPath, w.Path())
}
+3 -9
View File
@@ -204,11 +204,10 @@ func TestNewTargetConfigForm(t *testing.T) {
form, err = delivery.NewTargetConfigForm(&database.Target{ form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"720h","rotation":"daily"}`, Config: `{"expiry":"720h"}`,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry) assert.Equal(t, "720h", form.Expiry)
assert.Equal(t, rotationDaily, form.Rotation)
form, err = delivery.NewTargetConfigForm(&database.Target{ form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeLog, Type: database.TargetTypeLog,
@@ -217,9 +216,8 @@ func TestNewTargetConfigForm(t *testing.T) {
assert.Empty(t, form.URL) assert.Empty(t, form.URL)
} }
// A keep-forever archive target yields an empty expiry, so the edit // A keep-forever archive target must pre-fill as an empty field, so
// form starts on never; saving it unchanged stores never, which means // saving the form back unchanged stores the same empty config.
// the same as an empty expiry.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel() t.Parallel()
@@ -249,10 +247,6 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"soon"}`, Config: `{"expiry":"soon"}`,
}, },
{
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
{Type: database.TargetType("nope")}, {Type: database.TargetType("nope")},
} }
+6 -13
View File
@@ -234,7 +234,7 @@ func (c *httpCore) handleRetry(
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
backoff := Backoff(attemptNum) backoff := calcBackoff(attemptNum)
retryTask := *task retryTask := *task
retryTask.AttemptNum = attemptNum + 1 retryTask.AttemptNum = attemptNum + 1
@@ -301,7 +301,7 @@ func (c *httpCore) remainingBackoff(
return 0 return 0
} }
backoff := Backoff(attemptNum) backoff := calcBackoff(attemptNum)
elapsed := time.Since(lastResult.CreatedAt) elapsed := time.Since(lastResult.CreatedAt)
remaining := backoff - elapsed remaining := backoff - elapsed
@@ -326,14 +326,12 @@ func (c *httpCore) backoffElapsed(
return true return true
} }
backoff := Backoff(attemptNum) backoff := calcBackoff(attemptNum)
return time.Since(lastResult.CreatedAt) >= backoff return time.Since(lastResult.CreatedAt) >= backoff
} }
// Backoff is how long an http or slack target with retries waits after func calcBackoff(attemptNum int) time.Duration {
// a delivery's failed attempt attemptNum before trying it again.
func Backoff(attemptNum int) time.Duration {
shift := max(attemptNum-1, 0) shift := max(attemptNum-1, 0)
shift = min(shift, maxBackoffShift) shift = min(shift, maxBackoffShift)
@@ -444,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
originScoped := applyRequestHeaders( originScoped := applyRequestHeaders(req, event, cfg)
req, event, cfg, t.eng.userAgent(),
)
client := t.clientForRequest(cfg, originScoped) client := t.clientForRequest(cfg, originScoped)
@@ -566,13 +562,10 @@ func isForwardableHeader(name string) bool {
// Content-Type goes out once: a Content-Type configured on the target // Content-Type goes out once: a Content-Type configured on the target
// wins, otherwise the event's ContentType, otherwise none. The inbound // wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded. // Content-Type in the event's headers is never forwarded.
//
// userAgent is set last, over any configured or inbound User-Agent.
func applyRequestHeaders( func applyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
if event.ContentType != "" { if event.ContentType != "" {
req.Header.Set( req.Header.Set(
@@ -587,7 +580,7 @@ func applyRequestHeaders(
originScoped[http.CanonicalHeaderKey(k)] = struct{}{} 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 // A Content-Type configured on the target describes the body
// being sent rather than the sender. A 307/308 preserves the // being sent rather than the sender. A 307/308 preserves the
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("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) resp, doErr := executeHTTPRequest(t.client, req)
durationMs := time.Since(start).Milliseconds() durationMs := time.Since(start).Milliseconds()
-36
View File
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
// that the failure of a retry abandoned because its target is gone is
// added to that target's own totals, not to a row with no target.
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
var hits atomic.Int64
task, targetID := tRetryChainSetup(
t, s, "gone-counted", &hits,
)
require.NoError(t, s.MainDB.Delete(
&database.Target{}, "id = ?", targetID,
).Error)
s.Engine.ExportProcessRetryTask(
context.Background(), &task,
)
var rows []database.TargetTotals
require.NoError(t, s.WebhookDB.Find(&rows).Error)
assert.Equal(t, []database.TargetTotals{
{TargetID: targetID, Failed: 1},
}, rows)
}
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's // TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
// mutation check: a liveness check that refused every retry would pass // mutation check: a liveness check that refused every retry would pass
// the test above and break every retry there is. // the test above and break every retry there is.
@@ -573,8 +541,6 @@ func TestRecoverPending_TargetDeleted(t *testing.T) {
s := newISetup(t) s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery")
deliveryID := tSeedDeletedTarget( deliveryID := tSeedDeletedTarget(
t, s, "gone-while-pending", "http://example.com/hook", t, s, "gone-while-pending", "http://example.com/hook",
database.DeliveryStatusPending, database.DeliveryStatusPending,
@@ -614,8 +580,6 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
s := newISetup(t) s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery")
deliveryID := tSeedDeletedTarget( deliveryID := tSeedDeletedTarget(
t, s, "gone-but-owned", "http://example.com/hook", t, s, "gone-but-owned", "http://example.com/hook",
database.DeliveryStatusPending, database.DeliveryStatusPending,
-21
View File
@@ -179,27 +179,6 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
) )
} }
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
// target URL that no request can be built from.
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n",
},
&database.Event{},
)
require.Error(t, reqErr)
assert.Zero(t, statusCode)
assertNoCredential(t, reqErr.Error())
assert.Contains(t, reqErr.Error(), "invalid control character")
}
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF // TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
// validator's error does not carry the submitted URL, which // validator's error does not carry the submitted URL, which
// the handler both logs and shows. // the handler both logs and shows.
-91
View File
@@ -1,91 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Both the http and the slack target send webhooker/ and the version
// in Globals, the value the web UI footer shows. A User-Agent
// configured on the target or carried in by the sender does not
// replace it.
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
t.Parallel()
const want = "webhooker/1.2.3-test"
userAgents := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
userAgents <- r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
g := &globals.Globals{Version: "1.2.3-test"}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
e := delivery.New(lc, delivery.EngineParams{
Globals: g,
Logger: log,
// httptest listens on loopback, which the default guard
// refuses.
SSRFGuard: delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
),
Metrics: metrics.New(prometheus.NewRegistry()),
})
statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(),
&delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"User-Agent": "configured/1"},
},
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
require.Len(t, userAgents, 1, "the http target sent no request")
assert.Equal(t, want, <-userAgents, "http target")
db := testWebhookDB(t)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending,
)
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", string(slackCfg),
))
require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target")
}
+2 -7
View File
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
} }
// bootAtDebug starts and stops the real application graph against // bootAtDebug starts and stops the real application graph against
// dataDir with DEBUG=true and nothing else set, and returns everything // dataDir with DEBUG=true, and returns everything it wrote to standard
// it wrote to standard output. // output.
// //
// config.New reads DEBUG from the environment exactly as the binary // config.New reads DEBUG from the environment exactly as the binary
// does, internal/logger builds the handler it builds in production, // does, internal/logger builds the handler it builds in production,
@@ -128,7 +128,6 @@ func readFirstBootSecrets(
func bootAtDebug(t *testing.T, dataDir string) string { func bootAtDebug(t *testing.T, dataDir string) string {
t.Helper() t.Helper()
config.ClearEnvForTest(t)
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
t.Setenv("DATA_DIR", dataDir) t.Setenv("DATA_DIR", dataDir)
@@ -138,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
+4 -4
View File
@@ -7,7 +7,7 @@
// SQL — parameters and all — for every statement that returns an // SQL — parameters and all — for every statement that returns an
// error, including gorm.ErrRecordNotFound. Two of this service's // error, including gorm.ErrRecordNotFound. Two of this service's
// lookups miss by design on unauthenticated routes: the entrypoint // lookups miss by design on unauthenticated routes: the entrypoint
// lookup on /h/{uuid}, whose path segment the client picks // lookup on /webhook/{uuid}, whose path segment the client picks
// outright, and the user lookup behind the login form, whose username // outright, and the user lookup behind the login form, whose username
// the client picks outright. Under the default logger each of those // the client picks outright. Under the default logger each of those
// misses printed an unbounded, attacker-chosen string, at no level the // misses printed an unbounded, attacker-chosen string, at no level the
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
// //
// One GORM path does not consult this: (*gorm.DB).Scan records the // One GORM path does not consult this: (*gorm.DB).Scan records the
// statement through gorm's own traceRecorder, which does not implement // statement through gorm's own traceRecorder, which does not implement
// this interface. No production code path calls it; only tests do, and // this interface. No production code path calls it; its one caller is
// what a test binds is fixture data. scan_guard_test.go fails if a // internal/database/database_test.go:91, whose SELECT 1 binds nothing.
// non-test file calls it. // scan_guard_test.go fails if a non-test file calls it.
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback // (*gorm.DB).Pluck, Row and Raw all run through the normal callback
// processor and are filtered. // processor and are filtered.
func (l *Logger) ParamsFilter( func (l *Logger) ParamsFilter(
+40 -99
View File
@@ -14,16 +14,18 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// isRowProducer reports whether name is GORM's Row or database/sql's // minNonTestFiles guards the walk below against passing because it
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is // found nothing to look at. The tree held 60 non-test .go files when
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed: // this was written.
// it also returns an error, so Scan is never called on its result const minNonTestFiles = 40
// directly. It matches the method name only and resolves no types, so
// a repo-local method with one of these names that returns *gorm.DB // isRowProducer reports whether name is a method that returns a
// gets past it: Scan on that method's result is not reported. // database/sql row handle. GORM's Row and Rows return *sql.Row and
// *sql.Rows, so Scan on the result of one of them is database/sql's
// Scan and never (*gorm.DB).Scan.
func isRowProducer(name string) bool { func isRowProducer(name string) bool {
switch name { switch name {
case "Row", "QueryRow", "QueryRowContext": case "Row", "Rows", "QueryRow", "QueryRowContext":
return true return true
default: default:
return false return false
@@ -48,14 +50,9 @@ func receiverIsRowHandle(x ast.Expr) bool {
} }
// unguardedScans returns the position of every Scan call in file whose // unguardedScans returns the position of every Scan call in file whose
// receiver is not a call to a row producer. It fails closed: any other // receiver is not a row handle. It fails closed: a receiver it cannot
// receiver — a local variable, a struct field, a call to any other // resolve syntactically — a local variable, a struct field — is
// method — is reported rather than assumed safe. // reported rather than assumed safe.
//
// It sees only calls written x.Scan(...). A method value, f := db.Scan
// followed by f(&v), is out of scope: Scan is never the called
// expression there, and nobody writes a query that way by accident,
// which is the mistake this check exists to catch.
func unguardedScans( func unguardedScans(
fset *token.FileSet, file *ast.File, fset *token.FileSet, file *ast.File,
) []token.Position { ) []token.Position {
@@ -114,15 +111,15 @@ func skipDir(name string) bool {
} }
} }
// walkNonTestGo parses every non-test .go file under root. It returns // walkNonTestGo parses every non-test .go file under root and returns
// the directories, relative to root, it parsed a file in, along with // how many it parsed along with every unguarded Scan it found.
// every unguarded Scan it found. func walkNonTestGo(t *testing.T, root string) (int, []string) {
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
t.Helper() t.Helper()
walked := map[string]bool{} var (
parsed int
var hits []string hits []string
)
fset := token.NewFileSet() fset := token.NewFileSet()
@@ -150,12 +147,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
return err return err
} }
dir, err := filepath.Rel(root, filepath.Dir(path)) parsed++
if err != nil {
return err
}
walked[dir] = true
for _, pos := range unguardedScans(fset, file) { for _, pos := range unguardedScans(fset, file) {
hits = append(hits, relPosition(root, pos)) hits = append(hits, relPosition(root, pos))
@@ -165,7 +157,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
}, },
)) ))
return walked, hits return parsed, hits
} }
// isNonTestGo reports whether a file name is Go source this check // isNonTestGo reports whether a file name is Go source this check
@@ -197,39 +189,19 @@ func relPosition(root string, pos token.Position) string {
// logged with its values interpolated. The package comment states the // logged with its values interpolated. The package comment states the
// limit; this fails when someone adds a call site anyway. // limit; this fails when someone adds a call site anyway.
// //
// Test files are not governed: what a test binds is fixture data. // The current tree has one caller, internal/database/database_test.go,
// which this check does not govern: it is test-only and its SELECT 1
// binds nothing.
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) { func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
t.Parallel() t.Parallel()
root := moduleRoot(t) parsed, offenders := walkNonTestGo(t, moduleRoot(t))
walked, offenders := walkNonTestGo(t, root)
// The module's packages are static, templates, and every directory
// directly under cmd and internal. Each holds non-test code, so one
// the walk parsed nothing in was skipped, and a Scan there would
// pass unseen.
packages := []string{"static", "templates"}
for _, parent := range []string{"cmd", "internal"} {
entries, err := os.ReadDir(filepath.Join(root, parent))
require.NoError(t, err)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packages = append(packages, filepath.Join(parent, entry.Name()))
}
}
for _, dir := range packages {
require.True(
t, walked[dir],
"the walk parsed no non-test .go file in %s", dir,
)
}
require.GreaterOrEqual(
t, parsed, minNonTestFiles,
"parsed %d non-test .go files, so this check found "+
"nothing to look at", parsed,
)
require.Empty( require.Empty(
t, offenders, t, offenders,
"Scan called on a receiver this check cannot show is a "+ "Scan called on a receiver this check cannot show is a "+
@@ -250,51 +222,18 @@ type scanGuardCase struct {
want int want int
} }
// scanGuardCases covers each receiver form unguardedScans names, plus
// each row producer isRowProducer lets through. Each body is valid Go
// inside plantedFile.
func scanGuardCases() []scanGuardCase { func scanGuardCases() []scanGuardCase {
return []scanGuardCase{ return []scanGuardCase{
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1}, {"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
{"struct field", `s.db.Scan(&v)`, 1}, {"gorm receiver", `gdb.Scan(&v)`, 1},
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1}, {"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
{ {"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
"sql rows in a variable", {"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)", {"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
1,
},
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
{
"sql QueryRowContext",
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
0,
},
{"unrelated call", `gdb.Find(&v)`, 0}, {"unrelated call", `gdb.Find(&v)`, 0},
} }
} }
// plantedFile wraps one case body in a function that declares every
// name the bodies use, so each body is the Go it stands for. The result
// is parsed, never compiled.
const plantedFile = `package p
import (
"context"
"database/sql"
"gorm.io/gorm"
)
type store struct{ db *gorm.DB }
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
var v int
%s
}
`
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it // TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
// a detector that matched nothing would satisfy the walk above no // a detector that matched nothing would satisfy the walk above no
// matter what the tree contained. // matter what the tree contained.
@@ -306,7 +245,9 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
t.Parallel() t.Parallel()
fset := token.NewFileSet() fset := token.NewFileSet()
src := fmt.Sprintf(plantedFile, tc.body) src := fmt.Sprintf(
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
)
file, err := parser.ParseFile( file, err := parser.ParseFile(
fset, tc.name+".go", src, 0, fset, tc.name+".go", src, 0,
-58
View File
@@ -1,58 +0,0 @@
package handlers
const (
// archiveExpiryNever is the archive expiry that keeps archived
// events forever. A stored empty expiry means the same.
archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the
// entries of a page's archive expiry select.
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
)
// archiveChoice is one entry of a database target's archive expiry
// or archive rotation select: the value stored, the label shown, and
// whether the select starts on it.
type archiveChoice struct {
Value string
Label string
Selected bool
}
// archiveExpiryChoices lists the archive expiries offered by the new
// webhook page, the add target form and the target edit form.
func archiveExpiryChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveExpiryNever, Label: archiveExpiryNever},
{Value: "1h", Label: "1h"},
{Value: "12h", Label: "12h"},
{Value: "24h", Label: "24h"},
{Value: "720h", Label: "30d"},
{Value: "2160h", Label: "90d"},
{Value: "8760h", Label: "365d"},
}
}
// archiveExpiryOptions returns the choices with expiry selected; an
// empty expiry selects never. An expiry that is not one of the
// choices comes first as its own selected entry, so saving the form
// unchanged keeps it.
func archiveExpiryOptions(expiry string) []archiveChoice {
if expiry == "" {
expiry = archiveExpiryNever
}
options := archiveExpiryChoices()
for i := range options {
if options[i].Value == expiry {
options[i].Selected = true
return options
}
}
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
return append([]archiveChoice{own}, options...)
}
-185
View File
@@ -1,185 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// expiryNever is the archive expiry that keeps archived events
// forever.
const expiryNever = "never"
// matched returns what the one group of pattern matched in page, at
// each match.
func matched(pattern, page string) []string {
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
groups := make([]string, 0, len(matches))
for _, m := range matches {
groups = append(groups, m[1])
}
return groups
}
// expiryShown returns the archive expiries the webhook page's target
// list shows.
func expiryShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return matched(
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
)
}
// expirySelected returns the target edit page and the expiries its
// expiry select starts on.
func expirySelected(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) (string, []string) {
t.Helper()
page := targetEditPage(t, env, webhookID, targetID)
return page, selectedIn(page, "expiry")
}
// targetEditPage returns a target's edit page.
func targetEditPage(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) string {
t.Helper()
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// selectedIn returns the values the select named name on page starts
// on.
func selectedIn(page, name string) []string {
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
options, _, _ := strings.Cut(rest, "</select>")
return matched(`<option value="([^"]*)" selected>`, options)
}
// TestArchiveExpiryChoices adds a database target with each archive
// expiry the forms offer, and checks that it is stored as chosen,
// shown in plain units in the target list, and that the target edit
// form starts on it.
func TestArchiveExpiryChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
}
for _, choice := range choices {
t.Run(choice.value, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", choice.value)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(
t, []string{choice.shown},
expiryShown(t, env, webhook.ID),
)
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{choice.value}, selected)
})
}
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
assert.Equal(t, []string{expiryNever}, selected)
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
assert.Equal(t, []string{"36h"}, selected)
assert.Regexp(
t,
`<select id="expiry" name="expiry" class="input">\s*`+
`<option value="36h" selected>36h</option>\s*`+
`<option value="never">never</option>`,
page,
)
assert.Contains(t, page, `<option value="8760h">365d</option>`)
form := url.Values{}
form.Set("name", unlisted.Name)
form.Set("expiry", "36h")
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
)
}
-42
View File
@@ -1,42 +0,0 @@
package handlers
const (
// archiveRotationNone is the archive rotation that keeps a
// database target's archive in one file. A stored empty rotation
// means the same.
archiveRotationNone = "none"
// tmplKeyArchiveRotationChoices is the template data key for the
// entries of a page's archive rotation select.
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
)
// archiveRotationChoices lists the archive rotations offered by the
// new webhook page, the add target form and the target edit form.
func archiveRotationChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveRotationNone, Label: archiveRotationNone},
{Value: "monthly", Label: "monthly"},
{Value: "daily", Label: "daily"},
{Value: "hourly", Label: "hourly"},
}
}
// archiveRotationOptions returns the choices with rotation selected;
// an empty rotation, or one that is not a choice, selects none. A
// stored rotation is always a choice: the forms refuse any other.
func archiveRotationOptions(rotation string) []archiveChoice {
options := archiveRotationChoices()
for i := range options {
if options[i].Value == rotation {
options[i].Selected = true
return options
}
}
options[0].Selected = true
return options
}
-229
View File
@@ -1,229 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// rotationNone is the archive rotation that keeps one file.
const rotationNone = "none"
// rotationShown returns the archive rotations the webhook page's
// target list shows.
func rotationShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
return matched(
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID),
)
}
// TestArchiveRotationChoices adds a database target with each archive
// rotation the forms offer, and checks that it is stored as chosen,
// shown in the target list, and that the target edit form starts on
// it. It then edits the target to hourly, keeping its expiry.
func TestArchiveRotationChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
t.Run(rotation, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", "720h")
form.Set("rotation", rotation)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID))
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
form = url.Values{}
form.Set("name", "archive")
form.Set("expiry", "720h")
form.Set("rotation", "hourly")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
`{"expiry":"720h","rotation":"hourly"}`,
storedTarget(t, env, targets[0].ID).Config,
)
})
}
}
// TestArchiveRotationEditStartsOnNone checks the edit form of a
// database target with no rotation stored starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
page := targetEditPage(t, env, webhook.ID, target.ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
}
// TestArchiveRotationRefused proves a rotation that is not one of the
// four is refused on the add target form and the target edit form, and
// that nothing is stored.
func TestArchiveRotationRefused(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("rotation", "weekly")
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
form.Del("type")
w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.JSONEq(t,
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
)
}
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
// page's archive rotation is stored on the archive target it creates.
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "rotated")
form.Set("archive", "on")
form.Set("archive_expiry", "720h")
form.Set("archive_rotation", "daily")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", "rotated").First(&webhook).Error)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
}
// TestArchiveFileView_Rotated describes a daily target's archive files
// at two times. On a day that has a file, the view names that file;
// on the next, before any event, it names the file the next event
// will go to, not created yet. Both times the size is of every file
// together and the last write the latest of them.
func TestArchiveFileView_Rotated(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
for i, day := range []string{"2026-10-01", "2026-10-02"} {
file := stem + "-" + day + ".db"
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
view := env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
assert.Empty(t, view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
view = env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
assert.Equal(t, "not created yet", view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
}
// renderedPage returns the webhook page.
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
+38 -16
View File
@@ -12,6 +12,12 @@ import (
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
// maxNextBytes bounds the page to return to after login. The login
// page writes it into its form, and every page is rendered into a
// buffer first (see executeTemplate), so without a bound a request
// would choose the size of that buffer.
const maxNextBytes = 2048
// loginDestination returns where a successful login sends the // loginDestination returns where a successful login sends the
// browser: next when it is a path on this site, otherwise "/", which // browser: next when it is a path on this site, otherwise "/", which
// leads to the webhook list. // leads to the webhook list.
@@ -23,7 +29,7 @@ import (
// is checked after percent-decoding, so an encoded form of any of // is checked after percent-decoding, so an encoded form of any of
// these is refused too. // these is refused too.
func loginDestination(next string) string { func loginDestination(next string) string {
if len(next) > middleware.MaxNextBytes { if len(next) > maxNextBytes {
return "/" return "/"
} }
@@ -74,7 +80,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
@@ -139,7 +145,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. // authenticateUser looks up and verifies a user's credentials.
@@ -148,10 +155,9 @@ func (h *Handlers) renderLoginError(
// The credential check runs BEFORE any rate-limit budget is // The credential check runs BEFORE any rate-limit budget is
// consulted, and only a failed check spends budget. That is what // consulted, and only a failed check spends budget. That is what
// keeps the single administrative path reachable: behind the reverse // keeps the single administrative path reachable: behind the reverse
// proxy this deployment requires, when TRUSTED_PROXIES does not cover // proxy this deployment requires, with TRUSTED_PROXIES unset, every
// it, every client shares one bucket, so a limiter spent on arrival // client shares one bucket, so a limiter spent on arrival lets any
// lets any stranger deny the operator's own correct password // stranger deny the operator's own correct password indefinitely.
// indefinitely.
// //
// Verifying first means every login POST costs an Argon2id hash, so // Verifying first means every login POST costs an Argon2id hash, so
// the work is taken under a bounded number of verification slots. // the work is taken under a bounded number of verification slots.
@@ -211,7 +217,11 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password) valid, err := database.VerifyPassword(password, user.Password)
if err != nil { 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 return user, err
} }
@@ -268,7 +278,7 @@ func (h *Handlers) rejectLogin(
))) )))
h.renderLoginError( h.renderLoginError(
w, r, w, r,
"Too many failed sign-in attempts. Please try again later.", "Too many failed login attempts. Please try again later.",
http.StatusTooManyRequests, http.StatusTooManyRequests,
) )
} }
@@ -283,14 +293,24 @@ func (h *Handlers) createAuthenticatedSession(
) error { ) error {
oldSess, err := h.session.Get(r) oldSess, err := h.session.Get(r)
if err != nil { 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 return err
} }
sess, err := h.session.Regenerate(r, w, oldSess) sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil { 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 return err
} }
@@ -299,7 +319,11 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess) err = h.session.Save(r, w, sess)
if err != nil { 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 return err
} }
@@ -332,9 +356,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
http.Redirect( // Redirect to login page
w, r, withNotice("/pages/login", signedOut), http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
http.StatusSeeOther,
)
} }
} }
+10 -116
View File
@@ -3,7 +3,6 @@ package handlers_test
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -26,7 +25,7 @@ const (
// sharedProxyPeer is the whole point of this file. Production is // sharedProxyPeer is the whole point of this file. Production is
// required to run behind a TLS-terminating reverse proxy, and // required to run behind a TLS-terminating reverse proxy, and
// when TRUSTED_PROXIES does not cover it every client — attacker // TRUSTED_PROXIES defaults to empty, so every client — attacker
// and operator alike — reaches the process from the proxy's // and operator alike — reaches the process from the proxy's
// address and shares one rate-limit bucket. Both parties in // address and shares one rate-limit bucket. Both parties in
// these tests therefore use the same RemoteAddr. // these tests therefore use the same RemoteAddr.
@@ -116,11 +115,11 @@ func floodFailures(
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150. // done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
// //
// The attacker and the operator share one rate-limit bucket, because // The attacker and the operator share one rate-limit bucket, because
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not // behind the mandated reverse proxy with TRUSTED_PROXIES unset every
// cover it, every client keys on the proxy's address. The attacker // client keys on the proxy's address. The attacker floods the
// floods the operator's own username — a single-admin product has a // operator's own username — a single-admin product has a predictable
// predictable one — far past the failure limit. The operator must // one — far past the failure limit. The operator must still be able
// still be able to log in with the correct password. // to log in with the correct password.
// //
// This fails if credentials stop being verified ahead of the limiter. // This fails if credentials stop being verified ahead of the limiter.
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) { func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
@@ -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: // TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate. // the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) { func TestLogin_SuccessCreatesSession(t *testing.T) {
@@ -529,7 +474,7 @@ func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
seedOperator(t, db) seedOperator(t, db)
cases := []struct{ next, want string }{ cases := []struct{ next, want string }{
{"/hook/abc/events?page=2", "/hook/abc/events?page=2"}, {"/source/abc/logs?page=2", "/source/abc/logs?page=2"},
{"", "/"}, {"", "/"},
{"https://evil.example/", "/"}, {"https://evil.example/", "/"},
{"https%3A%2F%2Fevil.example%2F", "/"}, {"https%3A%2F%2Fevil.example%2F", "/"},
@@ -542,14 +487,6 @@ func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
{`/a/../\evil.example/`, "/"}, {`/a/../\evil.example/`, "/"},
{"/\t/evil.example/", "/"}, {"/\t/evil.example/", "/"},
{"/%09/evil.example/", "/"}, {"/%09/evil.example/", "/"},
{"/\n/evil.example/", "/"},
{"/%0A/evil.example/", "/"},
{"/\r/evil.example/", "/"},
{"/%0D/evil.example/", "/"},
{"/%00/evil.example/", "/"},
{"/%7F/evil.example/", "/"},
{"%252F%252Fevil.example/", "/"},
{"https%253A%252F%252Fevil.example%252F", "/"},
{"/" + strings.Repeat("a", 4096), "/"}, {"/" + strings.Repeat("a", 4096), "/"},
} }
@@ -580,49 +517,6 @@ func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
} }
} }
// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong
// password the login page is shown again with the same next, so the
// next attempt still returns to the page that was asked for.
func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", "wrong")
form.Set("next", "/hook/abc")
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(
t, w.Body.String(), `name="next" value="/hook/abc"`,
)
}
// loginPageGet renders the login page as a GET with the given next // loginPageGet renders the login page as a GET with the given next
// value and cookies. // value and cookies.
func loginPageGet( func loginPageGet(
@@ -661,8 +555,8 @@ func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
assert.Contains( assert.Contains(
t, loginPageGet(h, "/hook/abc", nil).Body.String(), t, loginPageGet(h, "/source/abc", nil).Body.String(),
`name="next" value="/hook/abc"`, `name="next" value="/source/abc"`,
) )
assert.Contains( assert.Contains(
t, loginPageGet(h, "//evil.example/", nil).Body.String(), t, loginPageGet(h, "//evil.example/", nil).Body.String(),
@@ -672,7 +566,7 @@ func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser") cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
cases := []struct{ next, want string }{ cases := []struct{ next, want string }{
{"/hook/abc", "/hook/abc"}, {"/source/abc", "/source/abc"},
{"//evil.example/", "/"}, {"//evil.example/", "/"},
{`/\evil.example/`, "/"}, {`/\evil.example/`, "/"},
} }
@@ -1,89 +0,0 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
// event's page and in the event log, that an http or slack attempt
// shows its status as before, while a database or log attempt, which
// sends no HTTP request, says what it did and shows no status.
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
t.Parallel()
cases := []struct {
targetType database.TargetType
success bool
statusCode int
errText string
outcome string
status string // "" when the attempt must show no status
}{
{
database.TargetTypeHTTP, false, 0, "",
"failure", "Status: &mdash; (no response)",
},
{
database.TargetTypeSlack, true, http.StatusOK, "",
"success", "Status: 200",
},
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
{
database.TargetTypeDatabase, false, 0,
"opening archive database: disk full", "failure", "",
},
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
event := f.event(t, contentTypeJSON, "{}", time.Now())
dlv := f.delivery(
t, event, target.ID, database.DeliveryStatusDelivered,
)
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
Success: tc.success,
StatusCode: tc.statusCode,
Error: tc.errText,
},
).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
pages := []string{
w.Body.String(),
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
}
for _, page := range pages {
assert.Contains(t, page, ">"+tc.outcome+"</span>")
if tc.errText != "" {
assert.Contains(t, page, "Error: "+tc.errText)
}
if tc.status == "" {
assert.NotContains(t, page, "Status:")
} else {
assert.Contains(t, page, tc.status)
}
}
})
}
}
+77 -52
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm" "gorm.io/gorm"
@@ -10,39 +11,72 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// The outcomes of a replay POST, as the notice codes its redirect // replayOutcomeParam is the query parameter the replay POST redirects
// carries. noticeFor holds the line each one shows. // 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 ( const (
// replayQueued reports that a new delivery was created and handed // replayQueued reports that a new delivery was created and handed
// to the delivery engine. // to the delivery engine.
replayQueued noticeCode = "replay-queued" replayQueued replayOutcomeCode = "queued"
// replayTargetDeleted reports a target that once existed and has // replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no // since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its // foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event. The // target and this is the ordinary case for an old event.
// event log shows no Replay button for such a delivery, so only replayTargetDeleted replayOutcomeCode = "target-deleted"
// a page loaded before the delete reaches this.
replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at // replayTargetMissing reports a target id that names no row at
// all, deleted or otherwise. // all, deleted or otherwise.
replayTargetMissing noticeCode = "replay-target-missing" replayTargetMissing replayOutcomeCode = "target-missing"
// replayTargetInactive reports a target the operator has // replayTargetInactive reports a target the operator has
// deactivated. A deactivated target receives no new deliveries, so // deactivated. A deactivated target receives no new deliveries, so
// a replay to it would be a delivery they switched off. // 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 // replayNotTerminal reports a delivery the engine has not finished
// with. // with.
replayNotTerminal noticeCode = "replay-not-terminal" replayNotTerminal replayOutcomeCode = "not-terminal"
// replayInFlight reports that an earlier replay of this event to // replayInFlight reports that an earlier replay of this event to
// this target is still running. // 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 // HandleDeliveryReplay re-sends a finished delivery's event to its
// target. // target.
// //
@@ -71,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -88,14 +124,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook, webhook database.Webhook,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -106,14 +142,14 @@ func (h *Handlers) replayDelivery(
} }
if !original.Status.Terminal() { if !original.Status.Terminal() {
redirectToEventLog(w, r, webhook, replayNotTerminal) h.finishReplay(w, r, webhook, replayNotTerminal)
return return
} }
target, code := h.replayTarget(webhook.ID, original.TargetID) target, code := h.replayTarget(webhook.ID, original.TargetID)
if target == nil { if target == nil {
redirectToEventLog(w, r, webhook, code) h.finishReplay(w, r, webhook, code)
return return
} }
@@ -137,7 +173,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"), &original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error ).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return nil, false return nil, false
} }
@@ -159,14 +195,14 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to count in-flight deliveries", err, w, "failed to count in-flight deliveries", err,
) )
return return
} }
if inFlight > 0 { if inFlight > 0 {
redirectToEventLog(w, r, webhook, replayInFlight) h.finishReplay(w, r, webhook, replayInFlight)
return return
} }
@@ -176,7 +212,7 @@ func (h *Handlers) queueReplay(
err = webhookDB. err = webhookDB.
First(&event, "id = ?", original.EventID).Error First(&event, "id = ?", original.EventID).Error
if err != nil { 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 return
} }
@@ -186,7 +222,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to create replay delivery", err, w, "failed to create replay delivery", err,
) )
return return
@@ -204,7 +240,7 @@ func (h *Handlers) queueReplay(
"delivery_id", task.DeliveryID, "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. // replayTarget loads the delivery's target as it stands now.
@@ -217,7 +253,7 @@ func (h *Handlers) queueReplay(
// with the returned code saying why. // with the returned code saying why.
func (h *Handlers) replayTarget( func (h *Handlers) replayTarget(
webhookID, targetID string, webhookID, targetID string,
) (*database.Target, noticeCode) { ) (*database.Target, replayOutcomeCode) {
var target database.Target var target database.Target
err := h.db.DB().Unscoped().Where( err := h.db.DB().Unscoped().Where(
@@ -263,9 +299,8 @@ func countInFlightDeliveries(
return count, err return count, err
} }
// createReplayDelivery writes the new pending delivery row, adds it to // createReplayDelivery writes the new pending delivery row and returns
// its target's totals in the same transaction, and returns the task // the task that carries it to the delivery engine.
// that carries it to the delivery engine.
// //
// The row is written with associations omitted, and neither Event nor // The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would // Target is populated on it: GORM's SaveBeforeAssociations would
@@ -282,19 +317,9 @@ func createReplayDelivery(
EventID: event.ID, EventID: event.ID,
TargetID: target.ID, TargetID: target.ID,
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Replay: true,
} }
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Omit(clause.Associations).Create(dlv).Error
err := tx.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, database.TargetTotals{
TargetID: dlv.TargetID, Deliveries: 1,
})
})
if err != nil { if err != nil {
return delivery.Task{}, err return delivery.Task{}, err
} }
@@ -328,26 +353,26 @@ func replayBody(body string) *string {
return &body return &body
} }
// redirectToEventLog redirects a replay or resubmit back to the event // finishReplay redirects back to the event log the replay was
// log it was triggered from, carrying the outcome as its notice. A // triggered from, carrying the outcome code the page turns into a
// Replay form carries the list it was pressed in as show, so a replay // banner and the page number the form submitted.
// returns to the Failed or Pending list; a Resubmit form carries none, func (h *Handlers) finishReplay(
// so a resubmit returns to the full log, where its new event is the
// newest.
func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code noticeCode, code replayOutcomeCode,
) { ) {
location := withNotice("/hook/"+webhook.ID+"/events", code) dest := "/source/" + webhook.ID + "/logs?" +
replayOutcomeParam + "=" + string(code)
show := r.PostFormValue(showParam) // The page is read from the form rather than the query string:
if eventLogStatuses(show) != nil { // this is a POST, and its query string is what logs and Referer
location += "&" + showParam + "=" + show // headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
} }
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses http.Redirect(w, r, dest, http.StatusSeeOther)
w, r, location, http.StatusSeeOther,
)
} }
+11 -132
View File
@@ -1,10 +1,8 @@
package handlers_test package handlers_test
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -140,7 +138,7 @@ func postReplay(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/hook/"+webhookID+"/deliveries/"+ "/source/"+webhookID+"/deliveries/"+
deliveryID+"/replay", deliveryID+"/replay",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
@@ -214,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-queued", "/source/"+wh.ID+"/logs?replay=queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -364,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-target-deleted", "/source/"+wh.ID+"/logs?replay=target-deleted",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -392,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code) require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-target-missing", "/source/"+wh.ID+"/logs?replay=target-missing",
missing.Header().Get("Location"), missing.Header().Get("Location"),
) )
} }
@@ -433,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code) require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal( require.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-queued", "/source/"+wh.ID+"/logs?replay=queued",
first.Header().Get("Location"), first.Header().Get("Location"),
) )
@@ -441,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code) require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-in-flight", "/source/"+wh.ID+"/logs?replay=in-flight",
second.Header().Get("Location"), second.Header().Get("Location"),
) )
@@ -467,71 +465,11 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code) require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?notice=replay-not-terminal", "/source/"+wh.ID+"/logs?replay=not-terminal",
pending.Header().Get("Location"), pending.Header().Get("Location"),
) )
} }
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
// Replay pressed in the Failed list carries that list in its form and
// returns to it, and that a show value the event log does not know
// returns to the full log.
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?show=failed",
), `name="show" value="failed"`)
// The second replay is refused, as the first is still queued.
for _, tc := range []struct{ show, location string }{
{"failed", "/hook/" + wh.ID +
"/events?notice=replay-queued&show=failed"},
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
} {
req := postRequest(
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
map[string]string{
paramSourceID: wh.ID,
paramDeliveryID: original.ID,
},
)
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleDeliveryReplay().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
}
}
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action // TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
// reaches the page it belongs on: a finished delivery renders a POST // reaches the page it belongs on: a finished delivery renders a POST
// form carrying a CSRF token, and the outcome code a refusal redirects // form carrying a CSRF token, and the outcome code a refusal redirects
@@ -563,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains( assert.Contains(
t, body, t, body,
`action="/hook/`+wh.ID+`/deliveries/`+ `action="/source/`+wh.ID+`/deliveries/`+
original.ID+`/replay"`, original.ID+`/replay"`,
) )
assert.Contains(t, body, `method="POST"`) assert.Contains(t, body, `method="POST"`)
@@ -571,77 +509,18 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(t, body, ">Replay<") assert.Contains(t, body, ">Replay<")
refused := renderSourceLogsPageWithQuery( 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") assert.Contains(t, refused, "alert-error")
assert.Contains( assert.Contains(t, refused, "has been deleted")
t, refused,
"has been deleted. Use Resubmit to send the event "+
"to the webhook",
)
// An outcome code nobody issued renders no banner at all. // An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery( unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?notice=made-up", t, h, sess, wh.ID, "?replay=made-up",
) )
assert.NotContains(t, unknown, "alert-error") assert.NotContains(t, unknown, "alert-error")
assert.NotContains(t, unknown, "alert-success") assert.NotContains(t, unknown, "alert-success")
assert.NotContains(t, unknown, "made-up") assert.NotContains(t, unknown, "made-up")
} }
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
// by Replay is labelled as a replay in the event's summary line in the
// event log, and in the list of the event's deliveries there and on
// the event's page, while the delivery it repeats is not.
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code)
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, eventLog, tgt.Name+": failed")
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
w = serveEventPage(t, h, sess, wh.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
// In each delivery list a row names the target, then the label if
// it is a replay, then its status: the replay is still pending, the
// original failed.
replayRow := tgt.Name + `</span> ` +
`<span class="text-xs text-gray-500">replay</span> ` +
`<span class="text-xs text-gray-400">pending</span>`
originalRow := tgt.Name + `</span> ` +
`<span class="text-xs text-red-600">failed</span>`
for _, page := range []string{eventLog, w.Body.String()} {
page = strings.Join(strings.Fields(page), " ")
assert.Contains(t, page, replayRow)
assert.Contains(t, page, originalRow)
}
}
+2 -13
View File
@@ -1,9 +1,6 @@
package handlers package handlers
import ( import (
"time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -27,8 +24,8 @@ const maxRenderedResponseBytes = 4096
// bytes rather than characters, and they make SQLite do the // bytes rather than characters, and they make SQLite do the
// cut, so an oversized stored response never becomes a Go // cut, so an oversized stored response never becomes a Go
// string at all. // string at all.
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " + const deliveryResultColumns = "delivery_id, attempt_num, success, " +
"success, status_code, error, duration, " + "status_code, error, duration, " +
"substr(cast(response_body as blob), 1, ?) AS response_body, " + "substr(cast(response_body as blob), 1, ?) AS response_body, " +
"length(cast(response_body as blob)) AS response_bytes" "length(cast(response_body as blob)) AS response_bytes"
@@ -48,11 +45,6 @@ type DeliveryResultView struct {
AttemptNum int AttemptNum int
Success bool Success bool
// Ran is how long ago the attempt was recorded, and RanUTC the
// full timestamp the page shows on hover.
Ran string
RanUTC string
// StatusCode is 0 when the attempt never got a response, // StatusCode is 0 when the attempt never got a response,
// which is why the page asks HasStatusCode rather than // which is why the page asks HasStatusCode rather than
// printing the number. // printing the number.
@@ -108,7 +100,6 @@ func (v DeliveryResultView) HasStatusCode() bool {
type deliveryResultRow struct { type deliveryResultRow struct {
DeliveryID string DeliveryID string
AttemptNum int AttemptNum int
CreatedAt time.Time
Success bool Success bool
StatusCode int StatusCode int
Error string Error string
@@ -166,8 +157,6 @@ func (r *deliveryResultRow) view(
return DeliveryResultView{ return DeliveryResultView{
AttemptNum: r.AttemptNum, AttemptNum: r.AttemptNum,
Success: r.Success, Success: r.Success,
Ran: humanize.Time(r.CreatedAt),
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
StatusCode: r.StatusCode, StatusCode: r.StatusCode,
Error: redactor.Redact(r.Error), Error: redactor.Redact(r.Error),
DurationMS: r.Duration, DurationMS: r.Duration,
@@ -435,7 +435,9 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
}).Error) }).Error)
} }
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) views := h.LoadEventLogViewsForTest(
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
@@ -487,7 +489,9 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
stored := strings.Repeat("A", responseCap*4) + tail stored := strings.Repeat("A", responseCap*4) + tail
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) views := h.LoadEventLogViewsForTest(
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
require.Len(t, views[0].Deliveries[0].Results, 1) require.Len(t, views[0].Deliveries[0].Results, 1)
-169
View File
@@ -1,169 +0,0 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEntrypointCreate handles adding a new entrypoint.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
description := r.PostFormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, r, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
)
}
}
// HandleEntrypointEdit handles changing an entrypoint's description.
// It writes only the description column, so the entrypoint keeps its
// URL, and an activate or deactivate saved since the page was shown
// is not undone.
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
entrypointID := chi.URLParam(r, "entrypointID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
result := h.db.DB().Model(&database.Entrypoint{}).Where(
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
).Update("description", r.PostFormValue("description"))
if result.Error != nil {
h.serverError(
w, r, "failed to edit entrypoint", result.Error,
)
return
}
// The id came from the URL and may name another webhook's
// entrypoint, which this webhook does not have.
if result.RowsAffected == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
http.StatusSeeOther,
)
}
}
// HandleEntrypointDelete handles deleting an entrypoint.
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
return h.deleteChildResource(
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
entrypointDeleted,
)
}
// HandleEntrypointToggle handles toggling an entrypoint's
// active state.
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) (bool, error) {
var ep database.Entrypoint
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&ep).Error
if err != nil {
return false, err
}
// Only the active column: saving the whole row would
// write back the description read above over an edit
// saved since.
active := !ep.Active
return active, h.db.DB().Model(&ep).
Update("active", active).Error
},
"failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
)
}
@@ -1,95 +0,0 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle
// which loaded the entrypoint before an edit of its description was
// saved does not write the old description back over the edit. The
// edit is submitted from a callback on the toggle's own read of the
// entrypoint, so it is saved after that read and before the toggle
// writes.
func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 30)
ep := seedEntrypoint(t, env.db, wh.ID)
require.True(t, ep.Active)
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/entrypoints/{entrypointID}/edit",
env.handlers.HandleEntrypointEdit(),
)
router.Post(
"/hook/{sourceID}/entrypoints/{entrypointID}/toggle",
env.handlers.HandleEntrypointToggle(),
)
// post submits one of the entrypoint's forms as the test user and
// returns the response's status code.
post := func(action string, form url.Values) int {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action,
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range env.cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code
}
var (
edited bool
editCode int
)
require.NoError(t, env.db.DB().Callback().Query().
After("gorm:query").
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
// Only the first read of an entrypoint, the toggle's,
// submits the edit.
if tx.Statement.Table != "entrypoints" || edited {
return
}
edited = true
editCode = post(
"edit", url.Values{"description": {"Billing sender"}},
)
}),
)
require.Equal(t, http.StatusSeeOther, post("toggle", nil))
require.Equal(t, http.StatusSeeOther, editCode)
var stored database.Entrypoint
require.NoError(
t, env.db.DB().First(&stored, "id = ?", ep.ID).Error,
)
assert.False(t, stored.Active)
assert.Equal(t, "Billing sender", stored.Description)
}
-77
View File
@@ -1,11 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -16,21 +11,6 @@ type EntrypointView struct {
Path string Path string
Description string Description string
Active bool Active bool
// Events is how many events arrived on the entrypoint's URL within
// the webhook's retention period. LastEvent is when the newest
// event ever to arrive on it did, relative, and LastEventUTC the
// full time; both are empty when none ever did.
Events int64
LastEvent string
LastEventUTC string
}
// entrypointEvents is one entrypoint's count read by
// addEntrypointEvents.
type entrypointEvents struct {
EntrypointID string
Events int64
} }
// NewEntrypointViews projects entrypoints for rendering. // NewEntrypointViews projects entrypoints for rendering.
@@ -52,60 +32,3 @@ func NewEntrypointViews(
return views return views
} }
// addEntrypointEvents fills in each view's event figures from the
// webhook's event database: when the last event arrived on its URL,
// from its EntrypointTotals row, and how many events arrived on it
// since the webhook's retention cutoff, counted in one query over the
// events' entrypoint_id index. Resubmitted copies did not arrive on
// the URL and are left out of both.
func addEntrypointEvents(
webhookDB *gorm.DB,
webhook *database.Webhook,
views []EntrypointView,
now time.Time,
) error {
ids := make([]string, len(views))
byID := make(map[string]*EntrypointView, len(views))
for i := range views {
ids[i] = views[i].ID
byID[views[i].ID] = &views[i]
}
var totals []database.EntrypointTotals
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
if err != nil {
return fmt.Errorf("reading entrypoint totals: %w", err)
}
query := webhookDB.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
cutoff, finite := webhook.RetentionCutoff(now)
if finite {
query = query.Where("created_at >= ?", cutoff)
}
var counts []entrypointEvents
err = query.Group("entrypoint_id").Find(&counts).Error
if err != nil {
return fmt.Errorf("counting events by entrypoint: %w", err)
}
for _, row := range totals {
view := byID[row.EntrypointID]
view.LastEvent = humanize.Time(row.LastEventAt)
view.LastEventUTC =
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
for _, row := range counts {
byID[row.EntrypointID].Events = row.Events
}
return nil
}
-197
View File
@@ -1,197 +0,0 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// entrypointRow returns the part of a rendered webhook page from an
// entrypoint's URL to the next entrypoint's, which holds its figures.
func entrypointRow(t *testing.T, page, entrypointID string) string {
t.Helper()
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
require.True(t, found)
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
return row
}
// lastEventShown matches an entrypoint row's last event arriving at at.
func lastEventShown(at time.Time) string {
return `Last Event:</span>\s*<span title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
}
// eventsShown matches an entrypoint row's count of n events.
func eventsShown(n int) string {
return `Events Within Retention:</span>\s*<span>` +
strconv.Itoa(n) + `</span>`
}
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
// on the webhook page shows its own figures: how many events arrived
// through it within the webhook's retention period, leaving out one
// older than that, and when the newest arrived, or "never" for an
// entrypoint with none.
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
entrypoint := func() *database.Entrypoint {
ep := &database.Entrypoint{
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// event stores an event that arrived on ep's URL age ago and
// records it as ep's last event, as the receiver does.
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
e := &database.Event{
WebhookID: wh.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
}
e.CreatedAt = time.Now().Add(-age)
require.NoError(t,
webhookDB.Omit(clause.Associations).Create(e).Error)
require.NoError(t, database.AddEntrypointTotals(webhookDB,
database.EntrypointTotals{
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
}))
return e.CreatedAt
}
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
event(busy, 8*24*time.Hour) // older than the 7 days kept
event(busy, 3*time.Hour)
busyLast := event(busy, time.Hour)
quietLast := event(quiet, 2*24*time.Hour)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
entrypointRow(t, body, unused.ID))
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
}
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
// that once retention has removed every event that arrived on an
// entrypoint's URL, the entrypoint still shows when the last one
// arrived rather than "never".
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(0), row)
}
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
// resubmitted copy, which did not arrive on the entrypoint's URL,
// changes neither the entrypoint's last event nor its count.
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
require.Equal(t, http.StatusSeeOther,
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
require.Len(t, listEvents(t, webhookDB), 2)
var totals database.EntrypointTotals
require.NoError(t, webhookDB.Take(&totals).Error)
assert.True(t, arrived.Equal(totals.LastEventAt))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(1), row)
}

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