Compare commits
59
Commits
d88948a1c3
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f282c6363d | ||
|
|
61371d388e | ||
|
|
19a6705c63 | ||
|
|
93911f28f9 | ||
|
|
9305af4f85 | ||
|
|
ff24638ba4 | ||
|
|
da75950e91 | ||
|
|
719d7013ee | ||
|
|
faf7ca1a5e | ||
|
|
0f5f6ba6bf | ||
|
|
820d9391ff | ||
|
|
2967c475a1 | ||
|
|
35d2f28c67 | ||
|
|
9526e961b5 | ||
|
|
4915d60d8e | ||
|
|
0945831442 | ||
|
|
f82b730c31 | ||
|
|
1a1fee0874 | ||
|
|
290925f184 | ||
|
|
73353bc8e5 | ||
|
|
40f59ec4d2 | ||
|
|
806c95e305 | ||
|
|
45bd7e9b94 | ||
|
|
bf3df0312b | ||
|
|
503c57efd9 | ||
|
|
d084f4f912 | ||
|
|
e67fffb05d | ||
|
|
4958a6f2e4 | ||
|
|
385fbc1a6a | ||
|
|
c22ca6218e | ||
|
|
0ccb01cada | ||
|
|
1f22b30de3 | ||
|
|
debe588bba | ||
|
|
e8379272ae | ||
|
|
3e209bfe4e | ||
|
|
88b961c115 | ||
|
|
dc9deda173 | ||
|
|
c706199389 | ||
|
|
d52cac1ec6 | ||
|
|
0f9b68a0e8 | ||
|
|
8cf5acaf1d | ||
|
|
9ade217222 | ||
|
|
5551f75251 | ||
|
|
fd036774f9 | ||
|
|
21aafbf928 | ||
|
|
c87b469dcd | ||
|
|
b14b27b78b | ||
|
|
287e47df7f | ||
|
|
8b5541734e | ||
|
|
c513816a55 | ||
|
|
2bb4683512 | ||
|
|
5b1d283d06 | ||
|
|
b78abdc9da | ||
|
|
c23ffbac65 | ||
|
|
2ac4d4d793 | ||
|
|
eb4c4cc849 | ||
|
|
cb7bafab17 | ||
|
|
2416528b77 | ||
|
|
38157d8936 |
+10
-4
@@ -1,14 +1,20 @@
|
|||||||
|
# .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
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
.editorconfig
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ 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 fingerprint step below needs history to find the last commit
|
# The superseded-status step needs history to walk ancestors (it
|
||||||
# that touched the Docker build context, and the superseded-status
|
# aborts on a shallow clone).
|
||||||
# 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
|
||||||
@@ -28,16 +27,11 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
# Writes the hash of the commit being checked into the context, which
|
||||||
# commit legitimately replays the whole image from cache and stays
|
# invalidates the `COPY . .` layer of every check stage: a commit
|
||||||
# cheap. Every other commit writes a new fingerprint into the context,
|
# that was never linted, format-checked, stylesheet-checked, tested
|
||||||
# which invalidates the `COPY . .` layer of both check stages: a
|
# and built cannot report success from cache.
|
||||||
# commit that was never linted, formatted-checked, tested and built
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
# cannot report success from cache.
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
|
||||||
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
|
||||||
|
|
||||||
- name: Build Docker image (runs make check)
|
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, make test, make build)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+62
-11
@@ -12,8 +12,8 @@ WORKDIR /src
|
|||||||
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, whose
|
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
||||||
# value changes with every commit that touches the build context (see
|
# holds the hash of the commit being checked (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.
|
||||||
@@ -25,8 +25,45 @@ COPY . .
|
|||||||
# would need a docker daemon inside the build. Keep these steps in step with
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
|
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 ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|
||||||
|
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
||||||
|
# tailwindcss, from static/css/input.css and the files its @source lines
|
||||||
|
# name. `make css` (script/css) writes it out from the css-output stage.
|
||||||
|
# The css-check stage fails when the committed file differs from what is
|
||||||
|
# generated; `make check` runs it, and so does the build stage below.
|
||||||
|
#
|
||||||
|
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
||||||
|
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
||||||
|
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
||||||
|
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
||||||
|
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
# TARGETARCH, set by docker, is the architecture being built for.
|
||||||
|
FROM tailwind-${TARGETARCH} AS css
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
||||||
|
|
||||||
|
FROM scratch AS css-output
|
||||||
|
COPY --from=css /out/tailwind.css /
|
||||||
|
|
||||||
|
# Both files are split after each "}", one rule per line, so that when they
|
||||||
|
# differ the diff shows the rules that differ.
|
||||||
|
FROM css AS css-check
|
||||||
|
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||||
|
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
||||||
|
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
||||||
|
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
@@ -34,12 +71,18 @@ RUN --network=none golangci-lint run --config .golangci.yml ./...
|
|||||||
# 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 lint stage passing
|
# Depend on the lint and stylesheet check 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
|
||||||
|
|
||||||
# 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.
|
# suite executes. git is what script/version derives the version with.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# A build context sent as a tar archive keeps its files' owners, and git
|
||||||
|
# refuses to read a checkout owned by another user. Trust this one
|
||||||
|
# whoever owns it.
|
||||||
|
RUN git config --system --add safe.directory /build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -55,14 +98,22 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# nothing in this stage can derive it: script/docker resolves it on the
|
# given, otherwise what script/version derives from the .git the build
|
||||||
# host and passes it in. The default is what a bare `docker build .`
|
# context carries, so any `docker build .` of a clone stamps its commit.
|
||||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
# With neither, as from a source tarball, it is "unknown".
|
||||||
#
|
#
|
||||||
# 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=unknown
|
ARG VERSION
|
||||||
|
|
||||||
|
# A context that carries .git must not stamp "unknown": that means git is
|
||||||
|
# missing here or could not read the checkout, and the image could not be
|
||||||
|
# traced back to its commit.
|
||||||
|
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
||||||
|
echo "version is unknown although the build context carries .git" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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$'
|
||||||
+7
-1
@@ -31,7 +31,13 @@ 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
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
# --build-tags browser also lints the browser test, which is built only with
|
||||||
|
# that tag (make test-browser).
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check 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`) where git metadata is
|
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
||||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
# how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
||||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
# VERSION="$VERSION"` when no VERSION build arg was given) 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,6 +33,9 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
test-browser:
|
||||||
|
@script/test-browser
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
@@ -71,4 +74,7 @@ hooks:
|
|||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
css:
|
css:
|
||||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
@script/css
|
||||||
|
|
||||||
|
css-check:
|
||||||
|
@script/css-check
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ duplicate. That is deliberate — the alternative is a silent lost
|
|||||||
delivery — and the README says so under Rationale. It is not a defect
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
to re-file.
|
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
|
||||||
|
|||||||
+28
-7
@@ -8,6 +8,7 @@ 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"
|
||||||
@@ -16,6 +17,7 @@ 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"
|
||||||
@@ -36,17 +38,19 @@ 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 3s HTTP drain plus the Sentry flush that
|
// close. That hook is the HTTP drain plus the Sentry flush that
|
||||||
// follows it in the same hook, so the flush is clamped to the stop
|
// follows it in the same hook, and each 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 2s; the reserve is what the tail hooks
|
// running for its own fixed 3s and 2s; the reserve is what the tail
|
||||||
// live on, and they are microsecond-scale in normal operation.
|
// hooks live on, and they are microsecond-scale in normal operation.
|
||||||
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
||||||
// across every drain length.
|
// across every drain length and every amount of budget the hooks
|
||||||
|
// 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.
|
||||||
// and can still consume the whole budget on their own.
|
// What they spend comes out of the drain first, but past 3s it comes
|
||||||
|
// out of the reserve, and they can consume the whole budget.
|
||||||
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
|
||||||
@@ -167,6 +171,19 @@ 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,
|
||||||
@@ -177,6 +194,10 @@ 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
|
||||||
|
|||||||
+129
-9
@@ -2,12 +2,19 @@ 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"
|
||||||
@@ -30,6 +37,7 @@ 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()
|
||||||
@@ -38,6 +46,100 @@ 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 —
|
||||||
@@ -150,22 +252,40 @@ 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.
|
||||||
//
|
//
|
||||||
// Shrinking either budget, or unbounding the flush again, must fail
|
// Nor does the hook start on a full budget: the ArchiveSweeper and
|
||||||
// here rather than silently recreating a hook that swallows the
|
// RetentionReaper hooks run before it, and whatever they spent is
|
||||||
// whole sequence.
|
// gone. The outer sweep walks every amount they can spend. Once they
|
||||||
|
// have eaten into the headroom themselves, the hook must spend
|
||||||
|
// nothing of what is left. A drain that starts on the full budget
|
||||||
|
// must still get all of ShutdownTimeout, so a smaller stopTimeout
|
||||||
|
// cannot silently shorten every drain.
|
||||||
|
//
|
||||||
|
// Shrinking either budget, or unbounding the drain or the flush
|
||||||
|
// again, must fail here rather than silently recreating a hook that
|
||||||
|
// swallows the whole sequence.
|
||||||
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 drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
|
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
|
||||||
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
|
remaining := stopTimeout - spent
|
||||||
|
longest := max(server.DrainBudget(remaining), 0)
|
||||||
|
|
||||||
require.LessOrEqual(
|
for drain := time.Duration(0); drain <= longest; drain += step {
|
||||||
t, hook+tailHeadroom, stopTimeout,
|
hook := drain + server.SentryFlushBudget(remaining-drain)
|
||||||
"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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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/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
|
||||||
@@ -18,7 +20,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.20.1
|
go.uber.org/fx v1.24.0
|
||||||
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
|
||||||
@@ -29,13 +31,17 @@ 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/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
|
||||||
@@ -44,13 +50,12 @@ 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/atomic v1.9.0 // indirect
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
go.uber.org/dig v1.17.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/zap v1.26.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.37.0 // indirect
|
golang.org/x/sys v0.47.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
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||||
|
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/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=
|
||||||
@@ -23,6 +24,14 @@ 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=
|
||||||
@@ -55,17 +64,20 @@ 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=
|
||||||
@@ -82,28 +94,24 @@ 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/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||||
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
|
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||||
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
|
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
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=
|
||||||
@@ -111,8 +119,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
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=
|
||||||
|
|||||||
+26
-24
@@ -80,8 +80,7 @@ const (
|
|||||||
// process over a Docker network or a private LAN connects from.
|
// 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"
|
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number. The lower
|
// maxPort is the highest valid TCP port number.
|
||||||
// bound (at least 1) is enforced by envPositiveInt.
|
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
|
|
||||||
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
||||||
@@ -105,7 +104,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 above the valid port range.
|
// TCP port number is set to a number outside 1 to 65535.
|
||||||
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
|
||||||
@@ -149,7 +148,6 @@ 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
|
||||||
@@ -196,12 +194,13 @@ 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 plus the cloud metadata
|
// here. That set is link-local, the unspecified addresses
|
||||||
// endpoints outside it that disclose credentials or user data
|
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
||||||
// at a provider-fixed, non-public address; it is not
|
// link-local that disclose credentials or user data at a
|
||||||
// exhaustive of every cloud's metadata address. See
|
// provider-fixed, non-public address; it is not exhaustive of
|
||||||
// alwaysBlockedNetworks for the authoritative list and the
|
// every cloud's metadata address. See
|
||||||
// criterion it is built from.
|
// alwaysBlockedNetworks for the authoritative list and why
|
||||||
|
// each entry is on it.
|
||||||
AllowedEgressCIDRs []netip.Prefix
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
@@ -363,17 +362,27 @@ 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.
|
// naming the key and the bad value; every out-of-range value wraps
|
||||||
|
// 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) {
|
||||||
port, err := envPositiveInt(key, defaultValue)
|
v := os.Getenv(key)
|
||||||
if err != nil {
|
if v == "" {
|
||||||
return 0, err
|
return defaultValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if port > maxPort {
|
// strconv.ErrRange means a number too large or too small for an
|
||||||
|
// int, which is outside the port range as well.
|
||||||
|
port, err := strconv.Atoi(v)
|
||||||
|
if err != nil && !errors.Is(err, strconv.ErrRange) {
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"%w: %s must be at most %d, got %d",
|
"invalid integer for %s: %q: %w", key, v, err,
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,11 +666,6 @@ 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,
|
||||||
@@ -711,7 +715,6 @@ 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,
|
||||||
@@ -798,7 +801,6 @@ 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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -71,14 +70,12 @@ 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 {
|
||||||
@@ -124,6 +121,11 @@ 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,
|
||||||
@@ -194,14 +196,11 @@ 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 {
|
||||||
@@ -272,6 +271,7 @@ 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,14 +335,11 @@ 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 {
|
||||||
@@ -364,6 +361,7 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -390,20 +388,17 @@ 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,
|
||||||
@@ -438,9 +433,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.
|
||||||
if set == "" {
|
config.ClearEnvForTest(t)
|
||||||
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
|
||||||
} else {
|
if set != "" {
|
||||||
t.Setenv("DATA_DIR", set)
|
t.Setenv("DATA_DIR", set)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,14 +498,11 @@ 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 {
|
||||||
@@ -534,6 +526,7 @@ func testReceiverRateLimitSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -621,12 +614,11 @@ 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 {
|
||||||
@@ -650,6 +642,7 @@ func testTrustedProxiesSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -732,14 +725,11 @@ 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 {
|
||||||
@@ -763,6 +753,7 @@ func testAllowedEgressCIDRsSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -806,13 +797,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,20 +933,14 @@ 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 {
|
||||||
@@ -1006,6 +988,7 @@ 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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,17 +22,6 @@ 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 {
|
||||||
@@ -50,9 +39,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 // unsetDotEnvKey uses t.Setenv.
|
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||||
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||||
unsetDotEnvKey(t)
|
config.ClearEnvForTest(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))
|
||||||
@@ -65,9 +54,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 // unsetDotEnvKey uses t.Setenv.
|
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||||
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||||
unsetDotEnvKey(t)
|
config.ClearEnvForTest(t)
|
||||||
|
|
||||||
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
@@ -93,9 +82,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 // unsetDotEnvKey uses t.Setenv.
|
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||||
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||||
unsetDotEnvKey(t)
|
config.ClearEnvForTest(t)
|
||||||
|
|
||||||
path := writeDotEnv(
|
path := writeDotEnv(
|
||||||
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
||||||
@@ -143,7 +132,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) {
|
||||||
unsetDotEnvKey(t)
|
config.ClearEnvForTest(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
|
|||||||
+81
-103
@@ -1,7 +1,6 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -18,10 +17,9 @@ 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"
|
||||||
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
envKeyBindAddress = "BIND_ADDRESS"
|
||||||
envKeyBindAddress = "BIND_ADDRESS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sample BIND_ADDRESS values used by the tables below.
|
// Sample BIND_ADDRESS values used by the tables below.
|
||||||
@@ -122,10 +120,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(
|
||||||
@@ -146,17 +144,62 @@ 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
|
||||||
|
|
||||||
tests := []struct {
|
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
|
||||||
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,
|
||||||
@@ -193,51 +236,14 @@ 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
|
||||||
|
|
||||||
tests := []struct {
|
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
|
||||||
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,
|
||||||
@@ -265,7 +271,14 @@ func TestEnvPort(t *testing.T) {
|
|||||||
set: true,
|
set: true,
|
||||||
value: "0",
|
value: "0",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrNonPositiveValue,
|
errIs: config.ErrInvalidPort,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
@@ -274,37 +287,14 @@ func TestEnvPort(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrInvalidPort,
|
errIs: config.ErrInvalidPort,
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
|
name: "too large for an int is rejected",
|
||||||
for _, tt := range tests {
|
set: true,
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
value: "99999999999999999999",
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
expectError: true,
|
||||||
// is incompatible with parallel subtests.
|
errIs: config.ErrInvalidPort,
|
||||||
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.
|
||||||
@@ -320,10 +310,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(
|
||||||
@@ -486,6 +476,7 @@ 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)
|
||||||
|
|
||||||
@@ -604,12 +595,6 @@ func flagEnvValueCases() []badEnvValueCase {
|
|||||||
value: "ture",
|
value: "ture",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "unparseable MAINTENANCE_MODE aborts startup",
|
|
||||||
key: envKeyMaintenanceMode,
|
|
||||||
value: "sometimes",
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,22 +638,15 @@ 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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -101,10 +100,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)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -119,3 +120,26 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -203,8 +202,7 @@ 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.
|
||||||
_, statErr := os.Stat(dbPath)
|
created := missingOrEmpty(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
|
||||||
@@ -213,13 +211,15 @@ 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
|
// Then use it with GORM. Its errors are SQLite's alone and name no
|
||||||
|
// 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,10 +229,11 @@ 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 err
|
return fmt.Errorf("connecting to %s: %w", dbPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
d.db = db
|
d.db = db
|
||||||
@@ -243,8 +244,12 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
d.log.Info("connected to database", "path", dbPath)
|
d.log.Info("connected to database", "path", dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations
|
err = d.migrate()
|
||||||
return d.migrate()
|
if err != nil {
|
||||||
|
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) migrate() error {
|
func (d *Database) migrate() error {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
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"
|
||||||
@@ -100,3 +106,22 @@ func TestDatabaseConnection(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpen_UnreadableDatabaseIsNamed pins
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
|
||||||
|
// read webhooker.db, the error that stops the server and `webhooker
|
||||||
|
// resetpw` names the file, not only SQLite's own message.
|
||||||
|
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
|
||||||
|
))
|
||||||
|
|
||||||
|
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), path)
|
||||||
|
assert.Contains(t, err.Error(), "file is not a database")
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,6 +199,77 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
"(deleted_at=? AND 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
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type APIKey struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
User User `json:"user,omitzero"`
|
// User.APIKeys leads back here, and the JSON could loop.
|
||||||
|
User User `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Event Event `json:"event,omitzero"`
|
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||||
Target Target `json:"target,omitzero"`
|
// JSON could loop.
|
||||||
|
Event Event `json:"-"`
|
||||||
|
Target Target `json:"-"`
|
||||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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
|
// Relations. No model marshals the record it belongs to:
|
||||||
Delivery Delivery `json:"delivery,omitzero"`
|
// Delivery.DeliveryResults leads back here, and the JSON could loop.
|
||||||
|
Delivery Delivery `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
// Relations. No model marshals the record it belongs to:
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook.Entrypoints leads back here, and the JSON could loop.
|
||||||
|
Webhook Webhook `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ 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.
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
// DeletedAt is also the second column of the resubmitted_from_id
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
// index, for the reason DeliveryResult gives. The entrypoint_id
|
||||||
|
// index, for the webhook page's entrypoint list, has it second too,
|
||||||
|
// resubmitted_from_id third, and created_at last, which the list
|
||||||
|
// compares with a range.
|
||||||
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
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" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
|
||||||
|
|
||||||
// Request data
|
// Request data
|
||||||
Method string `gorm:"not null" json:"method"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
@@ -32,8 +37,8 @@ type Event struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||||
// is stored so the recent events list can show it without reading
|
// is stored, so that the recent events list, which reads only the
|
||||||
// the body.
|
// start of each body, knows the whole body's size.
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
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
|
||||||
@@ -42,10 +47,11 @@ type Event struct {
|
|||||||
// 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" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to, so
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
Webhook Webhook `json:"-"`
|
||||||
|
Entrypoint Entrypoint `json:"-"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
|
||||||
|
// reference to the record it belongs to is left out of the JSON, so a
|
||||||
|
// webhook and its targets cannot marshal each other in a loop, and that
|
||||||
|
// GORM still preloads that reference, since it ignores json tags.
|
||||||
|
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := startedTestDB(t)
|
||||||
|
|
||||||
|
stored := database.Webhook{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Name: testWebhookName,
|
||||||
|
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
||||||
|
Targets: []database.Target{{
|
||||||
|
Name: "log",
|
||||||
|
Type: database.TargetTypeLog,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(&stored).Error)
|
||||||
|
|
||||||
|
entrypointID := stored.Entrypoints[0].ID
|
||||||
|
targetID := stored.Targets[0].ID
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, db.
|
||||||
|
Preload("Entrypoints.Webhook").
|
||||||
|
Preload("Targets.Webhook").
|
||||||
|
First(&webhook, "id = ?", stored.ID).Error)
|
||||||
|
|
||||||
|
require.Len(t, webhook.Entrypoints, 1)
|
||||||
|
require.Len(t, webhook.Targets, 1)
|
||||||
|
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
|
||||||
|
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
|
||||||
|
|
||||||
|
encoded := marshalModel(t, webhook)
|
||||||
|
|
||||||
|
assert.Contains(t, encoded, entrypointID)
|
||||||
|
assert.Contains(t, encoded, targetID)
|
||||||
|
|
||||||
|
// Each child holds the parent's id as its webhookId, so the parent
|
||||||
|
// is looked for by its own id field.
|
||||||
|
parentIDField := `"id":"` + stored.ID + `"`
|
||||||
|
|
||||||
|
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
|
||||||
|
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
|
||||||
|
|
||||||
|
var target database.Target
|
||||||
|
|
||||||
|
require.NoError(t, db.
|
||||||
|
Preload("Webhook").
|
||||||
|
First(&target, "id = ?", targetID).Error)
|
||||||
|
|
||||||
|
assert.Equal(t, stored.ID, target.Webhook.ID)
|
||||||
|
|
||||||
|
encoded = marshalModel(t, target)
|
||||||
|
|
||||||
|
assert.Contains(t, encoded, stored.ID)
|
||||||
|
assert.NotContains(t, encoded, parentIDField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsMarshalWithoutTheirParent covers the other references to a
|
||||||
|
// parent: each model is built with its parent set, and the parent's id
|
||||||
|
// must not appear in the JSON.
|
||||||
|
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := database.BaseModel{ID: uuid.New().String()}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
model any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Webhook.User",
|
||||||
|
model: database.Webhook{User: database.User{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "APIKey.User",
|
||||||
|
model: database.APIKey{User: database.User{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delivery.Event",
|
||||||
|
model: database.Delivery{Event: database.Event{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delivery.Target",
|
||||||
|
model: database.Delivery{Target: database.Target{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeliveryResult.Delivery",
|
||||||
|
model: database.DeliveryResult{
|
||||||
|
Delivery: database.Delivery{BaseModel: parent},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event.Webhook",
|
||||||
|
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event.Entrypoint",
|
||||||
|
model: database.Event{
|
||||||
|
Entrypoint: database.Entrypoint{BaseModel: parent},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ type Target struct {
|
|||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
Webhook Webhook `json:"-"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ func (TargetTotals) TableName() string {
|
|||||||
return "target_totals"
|
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,
|
// 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
|
// 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
|
// set. Call it on the transaction that writes or deletes the events it
|
||||||
@@ -97,3 +112,25 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ type Webhook struct {
|
|||||||
// must equal DefaultRetentionDays.
|
// must equal DefaultRetentionDays.
|
||||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
User User `json:"user,omitzero"`
|
// User.Webhooks leads back here, and the JSON could loop.
|
||||||
|
User User `json:"-"`
|
||||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -110,6 +111,13 @@ 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 {
|
||||||
|
|||||||
@@ -3,7 +3,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, EventTotals,
|
||||||
// TargetTotals) 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(
|
||||||
|
|||||||
@@ -184,18 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
wh := webhooks[i]
|
wh := webhooks[i]
|
||||||
|
|
||||||
// Skip retain-forever webhooks before building any query.
|
// A missing database has nothing to reap. Restart recovery
|
||||||
// RetainsForever covers both the RetentionForeverDays
|
// reports a lost one (see WebhookDBManager.GetDB).
|
||||||
// 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
|
||||||
}
|
}
|
||||||
@@ -212,6 +202,13 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
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(
|
||||||
@@ -223,11 +220,6 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, err := reapExpired(ctx, db, cutoff)
|
deleted, err := reapExpired(ctx, db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|||||||
t,
|
t,
|
||||||
overflowingRetentionDays,
|
overflowingRetentionDays,
|
||||||
database.RetentionForeverDays,
|
database.RetentionForeverDays,
|
||||||
"the test value must not be rescued by the forever skip",
|
"the test value must not be treated as retain-forever",
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookID := createWebhook(
|
webhookID := createWebhook(
|
||||||
|
|||||||
@@ -182,6 +182,29 @@ 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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -93,7 +94,8 @@ 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.
|
// touches it, and tightens any sidecar already on disk. A directory in
|
||||||
|
// 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
|
||||||
@@ -143,7 +145,15 @@ func reserveSQLiteFile(path string, create bool) error {
|
|||||||
for _, p := range append(
|
for _, p := range append(
|
||||||
[]string{path}, sqliteSidecarPaths(path)...,
|
[]string{path}, sqliteSidecarPaths(path)...,
|
||||||
) {
|
) {
|
||||||
err := os.Chmod(p, SQLiteFilePerm)
|
// Chmod accepts a directory, and SQLite opens a database whose
|
||||||
|
// -shm is one read-only, without a word: every write then
|
||||||
|
// fails naming no file.
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chmod(p, SQLiteFilePerm)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -152,6 +162,20 @@ 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.
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
|||||||
// seedExpiredEvents stores count events created at the given time,
|
// seedExpiredEvents stores count events created at the given time,
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
// each with a delivered delivery to one target and a failed delivery
|
||||||
// to the other, and one attempt for each 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(
|
func seedExpiredEvents(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
@@ -138,8 +145,8 @@ func seedExpiredEvents(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
results := make([]database.DeliveryResult, len(deliveries))
|
||||||
for i := range deliveries {
|
for i := range deliveries {
|
||||||
@@ -148,7 +155,7 @@ func seedExpiredEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
require.NoError(t, db.CreateInBatches(results, 50).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedBareEvents stores count events created at the given time, with
|
// seedBareEvents stores count events created at the given time, with
|
||||||
@@ -172,7 +179,7 @@ func seedBareEvents(
|
|||||||
events[i].CreatedAt = createdAt
|
events[i].CreatedAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||||
|
|||||||
@@ -33,10 +33,23 @@ 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, DeliveryResults and the
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
// 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
|
||||||
@@ -85,34 +98,37 @@ func NewWebhookDBManager(
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the database connection for a webhook,
|
// GetDB returns the database connection for a webhook, opening it on
|
||||||
// creating the database file lazily if it doesn't exist.
|
// first use.
|
||||||
|
//
|
||||||
|
// The file is made by CreateDB when the webhook is created. One that is
|
||||||
|
// missing or zero-length here means the webhook's events and pending
|
||||||
|
// deliveries are gone: an empty database is created in its place so
|
||||||
|
// the webhook keeps receiving, and that is logged as a warning naming
|
||||||
|
// the file, as a new main database is.
|
||||||
func (m *WebhookDBManager) GetDB(
|
func (m *WebhookDBManager) GetDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) (*gorm.DB, error) {
|
) (*gorm.DB, error) {
|
||||||
// Fast path: already open
|
return m.getDB(webhookID, false)
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
}
|
||||||
return asGormDB(val, webhookID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path: open the database under the lock, looking in the
|
// GetDBIf is GetDB, done only when check reports true. check runs under
|
||||||
// cache again first. A caller that raced another one here then
|
// the lock DeleteDB holds while it removes the files, so a caller can
|
||||||
// waits for its handle instead of opening a second one.
|
// confirm the webhook still exists and open its database with no delete
|
||||||
|
// in between. The handle is nil when check reports false. check must
|
||||||
|
// not call the manager.
|
||||||
|
func (m *WebhookDBManager) GetDBIf(
|
||||||
|
webhookID string, check func() (bool, error),
|
||||||
|
) (*gorm.DB, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
ok, err := check()
|
||||||
return asGormDB(val, webhookID)
|
if err != nil || !ok {
|
||||||
}
|
|
||||||
|
|
||||||
db, err := m.openDB(webhookID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.dbs.Store(webhookID, db)
|
return m.getDBLocked(webhookID, false)
|
||||||
|
|
||||||
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
|
||||||
@@ -130,12 +146,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDB explicitly creates a new per-webhook database file
|
// CreateDB creates a new webhook's database file and runs
|
||||||
// and runs migrations.
|
// migrations.
|
||||||
func (m *WebhookDBManager) CreateDB(
|
func (m *WebhookDBManager) CreateDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
_, err := m.GetDB(webhookID)
|
_, err := m.getDB(webhookID, true)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -151,7 +167,10 @@ 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. The file is permanently removed.
|
// for a webhook, with its -wal and -shm sidecars. The files are
|
||||||
|
// permanently removed. Each file is tried even when another could
|
||||||
|
// not be removed, and the error wraps ErrEventDBNotRemoved or
|
||||||
|
// ErrSidecarNotRemoved to say which was left, naming each file.
|
||||||
func (m *WebhookDBManager) DeleteDB(
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -170,16 +189,23 @@ 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"} {
|
|
||||||
err := os.Remove(path + suffix)
|
dbErr := removeFile(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
sidecarErr := errors.Join(
|
||||||
return fmt.Errorf(
|
removeFile(path+"-wal"),
|
||||||
"deleting webhook database file %s%s: %w",
|
removeFile(path+"-shm"),
|
||||||
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(
|
||||||
@@ -190,6 +216,17 @@ 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 {
|
||||||
@@ -232,6 +269,54 @@ 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 {
|
||||||
@@ -296,7 +381,7 @@ 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{},
|
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|||||||
@@ -182,17 +182,91 @@ 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 file is actually gone from disk
|
// Verify the files are actually gone from disk
|
||||||
|
assert.NoFileExists(t, dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockRemoval puts a non-empty directory at path, which os.Remove
|
||||||
|
// cannot remove whoever runs the test, root included.
|
||||||
|
func blockRemoval(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
|
||||||
|
// event database file cannot be removed, the error says so, and both
|
||||||
|
// sidecars are still removed.
|
||||||
|
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
dbPath := mgr.DBPath(webhookID)
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
_, err = os.Stat(dbPath)
|
blockRemoval(t, dbPath)
|
||||||
assert.True(t, os.IsNotExist(err))
|
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
||||||
|
|
||||||
|
err := mgr.DeleteDB(webhookID)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
|
||||||
|
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
|
||||||
|
assert.Contains(t, err.Error(), dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
|
||||||
|
// event database file is removed but a sidecar is not, the error
|
||||||
|
// says the database file is gone, and the other sidecar is still
|
||||||
|
// removed.
|
||||||
|
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
|
require.NoError(t, mgr.CreateDB(webhookID))
|
||||||
|
// Closing removes the sidecars, so the ones below are the only
|
||||||
|
// ones there.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
blockRemoval(t, dbPath+"-wal")
|
||||||
|
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
||||||
|
|
||||||
|
err := mgr.DeleteDB(webhookID)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
|
||||||
|
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
|
||||||
|
assert.Contains(t, err.Error(), dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||||
@@ -215,6 +289,75 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,13 @@ type ArchiveSweeper struct {
|
|||||||
eng *Engine
|
eng *Engine
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
// cancel needs no lock: fx calls the stop hook only after the
|
||||||
|
// start hook has returned, so stop never reads it while start
|
||||||
|
// is still setting it.
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArchiveSweeper creates the archive sweeper and registers
|
// NewArchiveSweeper creates the archive sweeper and registers
|
||||||
@@ -163,10 +168,18 @@ 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,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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"
|
||||||
@@ -20,6 +22,7 @@ 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 (
|
||||||
@@ -68,7 +71,8 @@ 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}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -166,7 +170,8 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -225,7 +230,8 @@ 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}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -681,6 +687,64 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
|
||||||
|
// closes the handle it finds open before it reopens the file.
|
||||||
|
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
|
||||||
|
// close, the reopen replaces the handle without closing it, the
|
||||||
|
// sweep then closes only the new one, and one connection leaks per
|
||||||
|
// archive per sweep.
|
||||||
|
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
|
||||||
|
w := delivery.NewExportArchiveWriter(
|
||||||
|
path, archiveTestLogger(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, w.Open(time.Hour))
|
||||||
|
|
||||||
|
before, err := w.DB().DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, w.SweepExpired(time.Hour))
|
||||||
|
|
||||||
|
assert.Error(
|
||||||
|
t, before.PingContext(t.Context()),
|
||||||
|
"the handle open before the sweep must be closed by it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
|
||||||
|
// context is already cancelled, as when the app stops just as a
|
||||||
|
// sweep starts, returns without an error line: stopping is not a
|
||||||
|
// failure.
|
||||||
|
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
|
||||||
|
var errorLines bytes.Buffer
|
||||||
|
|
||||||
|
sweeper := delivery.NewTestArchiveSweeper(
|
||||||
|
env.mainDB, env.eng,
|
||||||
|
slog.New(slog.NewTextHandler(
|
||||||
|
&errorLines,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
sweeper.ExportSweep(ctx)
|
||||||
|
|
||||||
|
assert.Empty(
|
||||||
|
t, errorLines.String(),
|
||||||
|
"a cancelled sweep must not log at error level",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
// 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.
|
||||||
|
|||||||
+51
-15
@@ -14,6 +14,7 @@ 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"
|
||||||
@@ -139,7 +140,7 @@ type Notifier interface {
|
|||||||
type Archives interface {
|
type Archives interface {
|
||||||
EvictWebhook(webhookID string)
|
EvictWebhook(webhookID string)
|
||||||
EvictTarget(targetID string)
|
EvictTarget(targetID string)
|
||||||
RenameArchive(targetID, webhookName, targetName string) error
|
Rename(targetID, webhookName, targetName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// EngineParams are the fx dependencies for the delivery
|
// EngineParams are the fx dependencies for the delivery
|
||||||
@@ -149,8 +150,10 @@ 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
|
||||||
@@ -170,10 +173,14 @@ type Engine struct {
|
|||||||
retryCh chan Task
|
retryCh chan Task
|
||||||
workers int
|
workers int
|
||||||
|
|
||||||
// mtr is the delivery metric set. Production wires the
|
// version is the running build's version, the one the web UI
|
||||||
// process-wide one; a test can substitute a set registered on
|
// footer shows. userAgent puts it on every outbound request.
|
||||||
// a private registry so its assertions are not disturbed by
|
version string
|
||||||
// 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.
|
||||||
@@ -207,7 +214,8 @@ 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,
|
||||||
mtr: metrics.Default(),
|
version: params.Globals.Version,
|
||||||
|
mtr: params.Metrics,
|
||||||
}
|
}
|
||||||
|
|
||||||
e.initTargets(&http.Client{
|
e.initTargets(&http.Client{
|
||||||
@@ -276,12 +284,13 @@ func (e *Engine) EvictTarget(targetID string) {
|
|||||||
e.dbTarget.evict(targetID)
|
e.dbTarget.evict(targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameArchive implements Archives. It renames a database target's
|
// Rename implements Archives. It renames a database target's
|
||||||
// archive file to ArchiveFileName(webhookName, targetName,
|
// archive file to ArchiveFileName(webhookName, targetName,
|
||||||
// targetID), under the lock the target's archive writes and the
|
// targetID), under the lock the target's archive writes and the
|
||||||
// idle sweep take. The caller renames before it saves the new name:
|
// idle sweep take. It never replaces a file: if one already has the
|
||||||
// see databaseTarget.rename.
|
// new name, the error is ErrArchiveNameTaken. The caller renames
|
||||||
func (e *Engine) RenameArchive(
|
// before it saves the new name: see databaseTarget.rename.
|
||||||
|
func (e *Engine) Rename(
|
||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
if e.dbTarget == nil {
|
if e.dbTarget == nil {
|
||||||
@@ -329,6 +338,13 @@ 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
|
||||||
@@ -683,10 +699,9 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if !e.dbManager.DBExists(webhookID) {
|
// Opened even when its file is missing, so that a lost
|
||||||
continue
|
// database is reported at start, not when the webhook next
|
||||||
}
|
// receives an event, which for a quiet webhook may be never.
|
||||||
|
|
||||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,7 +709,24 @@ 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,
|
||||||
) {
|
) {
|
||||||
webhookDB, err := e.dbManager.GetDB(webhookID)
|
// The web interface is already serving, so the webhook may have
|
||||||
|
// been deleted since the list was read. Opening its database then
|
||||||
|
// would create the file again after the delete removed it.
|
||||||
|
stillExists := func() (bool, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := e.database.DB().
|
||||||
|
Model(&database.Webhook{}).
|
||||||
|
Where("id = ?", webhookID).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("confirming webhook exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
|
||||||
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",
|
||||||
@@ -705,6 +737,10 @@ func (e *Engine) recoverWebhookDeliveries(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if webhookDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
e.recoverPendingDeliveries(
|
e.recoverPendingDeliveries(
|
||||||
ctx, webhookDB, webhookID,
|
ctx, webhookDB, webhookID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,6 +24,7 @@ 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.
|
||||||
@@ -80,7 +82,8 @@ 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}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -355,9 +358,14 @@ 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, _ *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
receivedBody = string(body)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -397,6 +405,8 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -443,9 +453,14 @@ 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, _ *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
receivedBody = string(body)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -482,6 +497,8 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -1120,6 +1137,85 @@ 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) {
|
||||||
@@ -1411,6 +1507,32 @@ 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) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -25,6 +26,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ 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}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -1056,6 +1059,21 @@ 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,
|
||||||
) {
|
) {
|
||||||
@@ -1239,11 +1257,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
testContentType,
|
testContentType,
|
||||||
receivedHeaders.Get("Content-Type"),
|
receivedHeaders.Get("Content-Type"),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t,
|
// A response that ends before the length it announced is an error, not
|
||||||
"webhooker/1.0",
|
// a short body.
|
||||||
receivedHeaders.Get("User-Agent"),
|
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "100")
|
||||||
|
_, _ = w.Write([]byte("cut short"))
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
_, body, _, err := e.ExportDoHTTPRequest(
|
||||||
|
context.TODO(),
|
||||||
|
&delivery.HTTPTargetConfig{URL: ts.URL},
|
||||||
|
&database.Event{},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||||
|
assert.Empty(t, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
@@ -1312,6 +1352,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
ContentType: tc.event,
|
ContentType: tc.event,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
"webhooker/dev",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
@@ -1321,6 +1362,34 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
@@ -40,11 +41,6 @@ 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
|
||||||
@@ -70,6 +66,11 @@ 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)
|
||||||
@@ -82,8 +83,9 @@ 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)
|
return applyRequestHeaders(req, event, cfg, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportTruncate exposes truncate for testing.
|
// ExportTruncate exposes truncate for testing.
|
||||||
@@ -399,7 +401,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.Default(),
|
mtr: metrics.New(prometheus.NewRegistry()),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -414,7 +416,7 @@ func NewTestEngineSmallRetry(
|
|||||||
e := &Engine{
|
e := &Engine{
|
||||||
log: log,
|
log: log,
|
||||||
retryCh: make(chan Task, 1),
|
retryCh: make(chan Task, 1),
|
||||||
mtr: metrics.Default(),
|
mtr: metrics.New(prometheus.NewRegistry()),
|
||||||
}
|
}
|
||||||
e.initTargets(nil)
|
e.initTargets(nil)
|
||||||
|
|
||||||
@@ -437,7 +439,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.Default(),
|
mtr: metrics.New(prometheus.NewRegistry()),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -445,8 +447,7 @@ 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 private registry instead of
|
// assert on collectors registered on a registry it holds.
|
||||||
// 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
|
||||||
}
|
}
|
||||||
@@ -511,6 +512,12 @@ func (e *ExportArchiveWriter) Reopen(
|
|||||||
return e.w.reopen(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.
|
||||||
func (e *ExportArchiveWriter) Reopens() int {
|
func (e *ExportArchiveWriter) Reopens() int {
|
||||||
return e.w.reopens
|
return e.w.reopens
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ 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
|
||||||
// private registry. The process-wide collectors are moved by every
|
// registry this test holds, so its exact assertions can gather from
|
||||||
// other delivery test running in parallel, so exact assertions are
|
// it.
|
||||||
// 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,3 +376,97 @@ 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
"Content-Type": testContentType,
|
"Content-Type": testContentType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"webhooker/dev",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
+104
-39
@@ -25,43 +25,64 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
errBlockedIP = errors.New(
|
// ErrBlockedPrivateOrReservedIP reports an address in the
|
||||||
"blocked private, reserved or cloud metadata address",
|
// default blocklist's private and reserved ranges,
|
||||||
|
// blockedNetworks.
|
||||||
|
ErrBlockedPrivateOrReservedIP = errors.New(
|
||||||
|
"blocked private or reserved address",
|
||||||
|
)
|
||||||
|
// errBlockedPublicMetadata reports a public address on the
|
||||||
|
// default blocklist, one in blockedPublicNetworks.
|
||||||
|
errBlockedPublicMetadata = errors.New(
|
||||||
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local or cloud instance metadata " +
|
"blocked link-local, cloud instance metadata or " +
|
||||||
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
"unspecified 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 is the default blocklist: the private and
|
// blockedNetworks and blockedPublicNetworks together are the
|
||||||
// reserved IP ranges, plus the public cloud metadata addresses,
|
// default blocklist: the private and reserved IP ranges, plus
|
||||||
// that are blocked to prevent SSRF attacks. An operator can
|
// the public cloud metadata addresses, that are blocked to
|
||||||
// permit specific blocks out of this set with
|
// prevent SSRF attacks. An operator can permit specific blocks
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// A public address belongs on the default blocklist only if it
|
// blockedNetworks holds the private and reserved IP ranges.
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything. A
|
|
||||||
// provider's other public addresses are not refused, since
|
|
||||||
// reaching them can be legitimate and no list of them could be
|
|
||||||
// complete.
|
|
||||||
//
|
//
|
||||||
//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
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// blockedPublicNetworks holds the default blocklist's public
|
||||||
// open: the link-local blocks and the cloud instance metadata
|
// addresses, kept apart from blockedNetworks so that they are
|
||||||
// endpoints that live outside them. Reaching one is credential
|
// refused as cloud metadata addresses, never as private or
|
||||||
// or user-data theft rather than delivery to an internal
|
// reserved ones.
|
||||||
// service, so a supplied CIDR that covers such an address still
|
|
||||||
// leaves it blocked.
|
|
||||||
//
|
//
|
||||||
// Inclusion criterion — an address belongs here only if BOTH
|
// A public address belongs on the default blocklist only if it
|
||||||
// hold, and every entry below satisfies both:
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything; it goes
|
||||||
|
// in this list. A provider's other public addresses are not
|
||||||
|
// refused, since reaching them can be legitimate and no list of
|
||||||
|
// them could be complete.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
|
// open, so a supplied CIDR that covers one still leaves it
|
||||||
|
// blocked. An entry is here for one of two reasons: it is a
|
||||||
|
// metadata endpoint (the link-local blocks and the cloud
|
||||||
|
// instance metadata endpoints that live outside them), or it is
|
||||||
|
// an unspecified address. Reaching a metadata endpoint is
|
||||||
|
// credential or user-data theft rather than delivery to an
|
||||||
|
// internal service.
|
||||||
|
//
|
||||||
|
// Inclusion criterion for metadata endpoints — one belongs here
|
||||||
|
// only if BOTH hold, and every metadata entry below satisfies
|
||||||
|
// 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.
|
||||||
@@ -72,8 +93,8 @@ var blockedNetworks []*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
|
||||||
// candidate and say why. An endpoint disclosing only the
|
// metadata candidate and say why. An endpoint disclosing only
|
||||||
// operator's own inventory (instance id, region, disks, NICs)
|
// the 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 —
|
||||||
@@ -88,12 +109,21 @@ var blockedNetworks []*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 blockedNetworks instead,
|
// exists to remove. Default-block it in blockedPublicNetworks
|
||||||
// which an allowlist can override.
|
// instead, 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
|
||||||
@@ -113,23 +143,49 @@ 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",
|
||||||
})
|
})
|
||||||
@@ -186,6 +242,14 @@ 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.
|
||||||
@@ -225,13 +289,6 @@ 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
|
||||||
@@ -329,10 +386,12 @@ 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 or a
|
// consulted, so no configured CIDR reaches link-local, a
|
||||||
// cloud metadata endpoint at a non-public address.
|
// cloud metadata endpoint at a non-public address, or an
|
||||||
|
// unspecified address.
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network becomes reachable.
|
// network, or a listed public address on the default
|
||||||
|
// 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) {
|
||||||
@@ -345,9 +404,15 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if isBlockedIP(ip) {
|
if matchesAny(blockedNetworks, ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, errBlockedIP,
|
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchesAny(blockedPublicNetworks, ip) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ 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.
|
||||||
@@ -164,12 +168,13 @@ 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, so no allowlist
|
// rather than delivery to an internal service, and the
|
||||||
// reaches one. Every guard below names a CIDR that covers its
|
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
||||||
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
// on Linux, so no allowlist reaches any of them. Every guard
|
||||||
// CGNAT blocks an operator would really list — and the address
|
// below names a CIDR that covers its target — including
|
||||||
// must stay refused anyway, on both the validation and the
|
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
||||||
// delivery path.
|
// operator would really list — and the address must stay
|
||||||
|
// refused anyway, on both the validation and the delivery path.
|
||||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -215,15 +220,17 @@ type metadataAlwaysRefusedCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||||
// blocked address together with an allowlist entry that would
|
// blocked address (link-local, the cloud metadata endpoints and
|
||||||
// otherwise reach it. Split by family of address only to stay
|
// the unspecified addresses) together with an allowlist entry
|
||||||
// under the function-length limit.
|
// that would otherwise reach it. Split by family of address only
|
||||||
|
// 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, encodedMetadataRefusedCases()...)
|
return append(cases, unspecifiedRefusedCases()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkLocalRefusedCases covers the link-local blocks, including
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
@@ -237,7 +244,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: "169.254.0.0/16",
|
allow: linkLocalIPv4,
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -363,6 +370,23 @@ 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.
|
||||||
@@ -412,6 +436,9 @@ 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)
|
||||||
|
|
||||||
@@ -496,7 +523,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.
|
||||||
"169.254.0.0/16",
|
linkLocalIPv4,
|
||||||
// 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
|
||||||
@@ -517,6 +544,10 @@ 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.
|
||||||
@@ -526,6 +557,94 @@ 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.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,12 +56,14 @@ func TestIsBlockedIP_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,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
"isBlockedIP(%s) = %v, want %v",
|
"default guard refuses %s = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -99,6 +101,42 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func targetConfigFields(
|
|||||||
) []ConfigField {
|
) []ConfigField {
|
||||||
switch t.Type {
|
switch t.Type {
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return slackConfigFields(t.Config)
|
return slackConfigFields(t)
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return httpConfigFields(t)
|
return httpConfigFields(t)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
@@ -119,10 +119,11 @@ func unavailableConfigFields() []ConfigField {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// slackConfigFields describes a Slack target. Only the masked
|
// slackConfigFields describes a Slack target: its masked
|
||||||
// webhook URL is shown; the full URL is the credential.
|
// webhook URL and its retry count. Only the masked URL is
|
||||||
func slackConfigFields(configJSON string) []ConfigField {
|
// shown; the full URL is the credential.
|
||||||
cfg, err := parseSlackConfig(configJSON)
|
func slackConfigFields(t *database.Target) []ConfigField {
|
||||||
|
cfg, err := parseSlackConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ func slackConfigFields(configJSON string) []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
|
||||||
@@ -170,21 +171,7 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return append(fields, retryFields(t)...)
|
fields = append(fields, maxRetriesField(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 {
|
if t.MaxQueueSize > 0 {
|
||||||
fields = append(fields, ConfigField{
|
fields = append(fields, ConfigField{
|
||||||
@@ -196,6 +183,20 @@ func retryFields(t *database.Target) []ConfigField {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxRetriesField describes a target's retry count, which lives
|
||||||
|
// on the target row rather than in its configuration blob.
|
||||||
|
func maxRetriesField(t *database.Target) ConfigField {
|
||||||
|
retries := strconv.Itoa(t.MaxRetries)
|
||||||
|
if t.MaxRetries == 0 {
|
||||||
|
retries += " (fire-and-forget)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigField{
|
||||||
|
Label: "Max Retries",
|
||||||
|
Value: retries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// databaseConfigFields describes an archive target. Its
|
// databaseConfigFields describes an archive target. Its
|
||||||
// configuration is optional, and an absent or empty expiry
|
// configuration is optional, and an absent or empty expiry
|
||||||
// means the archive is kept forever. An expiry that is set
|
// means the archive is kept forever. An expiry that is set
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
|
viewMaxRetries = "Max Retries"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -157,9 +158,7 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|||||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t, viewFor(t, slackTarget()).Config, view.Config,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
|
||||||
fieldMap(view.Config),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +188,32 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
map[string]string{
|
||||||
|
"Webhook URL": slackMaskedURL,
|
||||||
|
viewMaxRetries: "0 (fire-and-forget)",
|
||||||
|
},
|
||||||
|
fieldMap(view.Config),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
||||||
|
// its retry count the same way an HTTP target does, and no
|
||||||
|
// queue size even when one is stored: delivery never reads it.
|
||||||
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
target := slackTarget()
|
||||||
|
target.MaxRetries = 2
|
||||||
|
target.MaxQueueSize = 100
|
||||||
|
|
||||||
|
view := viewFor(t, target)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]string{
|
||||||
|
"Webhook URL": slackMaskedURL,
|
||||||
|
viewMaxRetries: "2",
|
||||||
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +238,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Max Retries": "5",
|
viewMaxRetries: "5",
|
||||||
"Max Queue Size": "100",
|
"Max Queue Size": "100",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
@@ -238,7 +262,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Max Retries": "0 (fire-and-forget)",
|
viewMaxRetries: "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -277,10 +276,9 @@ func (t *databaseTarget) releaseSweepWriter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. The
|
// newWriter builds the writer for a database target's archive. The
|
||||||
// file lives beside the webhook's event database in the data
|
// file is the one ArchivePath gives for the webhook and the target as
|
||||||
// directory and is named for the webhook and the target as the main
|
// the main database names them now; from then on only rename changes
|
||||||
// database has them now; from then on only rename changes the name
|
// the name the writer uses. It does not touch the archive file.
|
||||||
// the writer uses. It does not touch the archive file.
|
|
||||||
func (t *databaseTarget) newWriter(
|
func (t *databaseTarget) newWriter(
|
||||||
targetID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
@@ -299,12 +297,10 @@ func (t *databaseTarget) newWriter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
w := newArchiveWriter(
|
||||||
name := ArchiveFileName(
|
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
|
||||||
target.Webhook.Name, target.Name, target.ID,
|
t.eng.log,
|
||||||
)
|
)
|
||||||
|
|
||||||
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
|
||||||
w.webhookID = target.WebhookID
|
w.webhookID = target.WebhookID
|
||||||
|
|
||||||
return w, nil
|
return w, nil
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ var (
|
|||||||
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
|
||||||
@@ -187,6 +194,10 @@ type archiveWriter struct {
|
|||||||
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
|
// registry. Its handle is closed and it must never open the
|
||||||
// file again: nothing holds it any more, so a reopen would
|
// file again: nothing holds it any more, so a reopen would
|
||||||
@@ -221,6 +232,7 @@ func newArchiveWriter(
|
|||||||
path: path,
|
path: path,
|
||||||
log: log,
|
log: log,
|
||||||
debounce: archiveReopenDebounce,
|
debounce: archiveReopenDebounce,
|
||||||
|
now: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +268,7 @@ func (w *archiveWriter) write(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Since(w.lastReopen) >= w.debounce {
|
if w.now().Sub(w.lastReopen) >= w.debounce {
|
||||||
return w.reopen(expiry)
|
return w.reopen(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +328,7 @@ func (w *archiveWriter) openMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.db = gdb
|
w.db = gdb
|
||||||
w.lastReopen = time.Now()
|
w.lastReopen = w.now()
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
if expiry > 0 {
|
if expiry > 0 {
|
||||||
@@ -398,6 +410,11 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
// it, because SQLite finds them by name. A missing file is not an
|
// it, because SQLite finds them by name. A missing file is not an
|
||||||
// error: the operator may have moved it away, and the next write
|
// error: the operator may have moved it away, and the next write
|
||||||
// creates it under the new name.
|
// creates it under the new name.
|
||||||
|
//
|
||||||
|
// If a file already has the new name, nothing is moved and the
|
||||||
|
// error is ErrArchiveNameTaken. If one file fails to move, those
|
||||||
|
// already moved are moved back before the error is returned, so the
|
||||||
|
// archive is never split across two names.
|
||||||
func (w *archiveWriter) rename(name string) error {
|
func (w *archiveWriter) rename(name string) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -413,17 +430,41 @@ func (w *archiveWriter) rename(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w.close()
|
suffixes := []string{"", "-wal", "-shm"}
|
||||||
|
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
for _, suffix := range suffixes {
|
||||||
err := os.Rename(w.path+suffix, path+suffix)
|
if fileExists(path + suffix) {
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"renaming archive %s to %s: %w", w.path, path, err,
|
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
for i, suffix := range suffixes {
|
||||||
|
err := os.Rename(w.path+suffix, path+suffix)
|
||||||
|
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, moved := range suffixes[:i] {
|
||||||
|
backErr := os.Rename(path+moved, w.path+moved)
|
||||||
|
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||||
|
w.log.Error(
|
||||||
|
"failed to move archive file back",
|
||||||
|
"from", path+moved,
|
||||||
|
"to", w.path+moved,
|
||||||
|
"error", backErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
w.path = path
|
w.path = path
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -474,6 +515,47 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||||
|
// file says about it.
|
||||||
|
type ArchiveFileInfo struct {
|
||||||
|
// Size is the bytes on disk of the file and its -wal together.
|
||||||
|
Size int64
|
||||||
|
|
||||||
|
// Written is when the file or its -wal was last modified, whichever
|
||||||
|
// is later: a write lands in the -wal first.
|
||||||
|
Written time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatArchive reads the metadata of the archive file at path and of
|
||||||
|
// its -wal, without opening the archive. With no file at path, which is
|
||||||
|
// so before the first write and after the operator moved it away, the
|
||||||
|
// error wraps fs.ErrNotExist.
|
||||||
|
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||||
|
file, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
||||||
|
|
||||||
|
wal, err := os.Stat(path + "-wal")
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Size += wal.Size()
|
||||||
|
|
||||||
|
if wal.ModTime().After(info.Written) {
|
||||||
|
info.Written = wal.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fileExists reports whether a path currently exists.
|
// 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)
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveTableQuery counts the archive's table: 0 when the archive
|
||||||
|
// writer has created the file but not yet the table in it.
|
||||||
|
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
|
||||||
|
"WHERE type = 'table' AND name = 'archived_events'"
|
||||||
|
|
||||||
|
// ArchivePath returns where a database target's archive file is: in
|
||||||
|
// the data directory, beside the webhook's event database, under the
|
||||||
|
// name ArchiveFileName gives it.
|
||||||
|
func ArchivePath(
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
) string {
|
||||||
|
return filepath.Join(
|
||||||
|
filepath.Dir(dbMgr.DBPath(webhook.ID)),
|
||||||
|
ArchiveFileName(webhook.Name, target.Name, target.ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveExportFileName returns the name a database target's archive
|
||||||
|
// downloads under:
|
||||||
|
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
|
||||||
|
// made safe as in ArchiveFileName and the time in UTC.
|
||||||
|
func ArchiveExportFileName(
|
||||||
|
webhookName, targetName string, at time.Time,
|
||||||
|
) string {
|
||||||
|
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||||
|
archiveNamePart(targetName) + "-" +
|
||||||
|
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveExport is a database target's archive opened for download.
|
||||||
|
// It reads the file on its own connection, inside one read-only
|
||||||
|
// transaction, so it writes out the archive as it stood when
|
||||||
|
// OpenArchiveExport returned.
|
||||||
|
//
|
||||||
|
// Archives are in WAL mode, where a reader works from a snapshot and
|
||||||
|
// never blocks a writer: archive writes go on while an export is open,
|
||||||
|
// and the export does not see them. SQLite cannot checkpoint the -wal
|
||||||
|
// past an open snapshot, so the -wal grows until the export is closed.
|
||||||
|
type ArchiveExport struct {
|
||||||
|
db *sql.DB
|
||||||
|
tx *gorm.DB
|
||||||
|
|
||||||
|
// empty is true when there is nothing to read: no file, or a file
|
||||||
|
// without the archive's table yet.
|
||||||
|
empty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedName is how an export names its webhook and its target.
|
||||||
|
type exportedName struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenArchiveExport opens the archive file at path for export and
|
||||||
|
// takes the snapshot the export reads. It never creates the file: with
|
||||||
|
// no file at path, the export has no rows.
|
||||||
|
//
|
||||||
|
// Once it has returned, the file is open, so a rename or a move of it
|
||||||
|
// does not affect the export, which reads the same file under its new
|
||||||
|
// name.
|
||||||
|
//
|
||||||
|
// The transaction lasts as long as ctx does, so ctx must last for the
|
||||||
|
// whole export.
|
||||||
|
func OpenArchiveExport(
|
||||||
|
ctx context.Context, path string, log *slog.Logger,
|
||||||
|
) (*ArchiveExport, error) {
|
||||||
|
if !fileExists(path) {
|
||||||
|
return &ArchiveExport{empty: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.OpenSQLite(path, archiveModeExisting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(
|
||||||
|
sqlite.Dialector{Conn: db}, &gorm.Config{
|
||||||
|
// Never leave this at GORM's default. See
|
||||||
|
// internal/gormlog.
|
||||||
|
Logger: gormlog.New(log),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOnly makes the driver begin a deferred transaction in place
|
||||||
|
// of the BEGIN IMMEDIATE the connection string asks for, so the
|
||||||
|
// export never takes the archive's write lock.
|
||||||
|
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
|
||||||
|
if tx.Error != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transaction's first read is what takes the snapshot.
|
||||||
|
var tables int
|
||||||
|
|
||||||
|
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("reading archive %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
||||||
|
// webhook and target, each an id and a name; exported_at; and
|
||||||
|
// archived_events, one object per archived row, keyed by column name.
|
||||||
|
// A body that is not valid UTF-8 cannot be a JSON string, so it is
|
||||||
|
// written in base64, with "body_encoding": "base64" beside it.
|
||||||
|
//
|
||||||
|
// Each row is written out before the next is read, so neither the
|
||||||
|
// archive nor its JSON is ever held in memory whole. After an error
|
||||||
|
// the gzip stream is left unfinished, so what was written does not
|
||||||
|
// decompress as a whole file.
|
||||||
|
func (x *ArchiveExport) WriteGzipJSON(
|
||||||
|
ctx context.Context,
|
||||||
|
w io.Writer,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
exportedAt time.Time,
|
||||||
|
) error {
|
||||||
|
head, err := json.Marshal(map[string]any{
|
||||||
|
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
|
||||||
|
"target": exportedName{ID: target.ID, Name: target.Name},
|
||||||
|
"exported_at": exportedAt.UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding archive export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zw := gzip.NewWriter(w)
|
||||||
|
|
||||||
|
err = x.writeJSON(ctx, zw, head)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing archive export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ends the export's transaction and closes its connection.
|
||||||
|
func (x *ArchiveExport) Close() error {
|
||||||
|
if x.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = x.tx.Rollback()
|
||||||
|
|
||||||
|
return x.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes head with archived_events added as its last key,
|
||||||
|
// the rows going into it one at a time.
|
||||||
|
func (x *ArchiveExport) writeJSON(
|
||||||
|
ctx context.Context, w io.Writer, head []byte,
|
||||||
|
) error {
|
||||||
|
// head goes out without its closing brace, so that
|
||||||
|
// archived_events can follow it.
|
||||||
|
_, err := w.Write(head[:len(head)-1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, `,"archived_events":[`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = x.writeRows(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, "\n]}\n")
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRows writes each archived row to w, oldest first, one per line,
|
||||||
|
// separated by commas.
|
||||||
|
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
||||||
|
if x.empty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := x.tx.WithContext(ctx).
|
||||||
|
Model(&archivedEvent{}).Order("id").Rows()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for sep := "\n"; rows.Next(); sep = ",\n" {
|
||||||
|
var ev archivedEvent
|
||||||
|
|
||||||
|
err = x.tx.ScanRows(rows, &ev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, sep)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeRow(w, &ev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRow writes an archived row to w as a JSON object keyed by
|
||||||
|
// column name, its body in base64 when it is not valid UTF-8.
|
||||||
|
func writeRow(w io.Writer, ev *archivedEvent) error {
|
||||||
|
row := map[string]any{
|
||||||
|
"id": ev.ID,
|
||||||
|
"event_id": ev.EventID,
|
||||||
|
"webhook_id": ev.WebhookID,
|
||||||
|
"entrypoint_id": ev.EntrypointID,
|
||||||
|
"method": ev.Method,
|
||||||
|
"headers": ev.Headers,
|
||||||
|
"body": ev.Body,
|
||||||
|
"content_type": ev.ContentType,
|
||||||
|
"archived_at": ev.ArchivedAt.UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utf8.ValidString(ev.Body) {
|
||||||
|
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
|
||||||
|
row["body_encoding"] = "base64"
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(line)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The webhook and the target the export tests' archives belong to.
|
||||||
|
const (
|
||||||
|
exportWebhookID = "wh-export"
|
||||||
|
exportWebhookName = "Orders (EU)"
|
||||||
|
exportTargetID = "tgt-export"
|
||||||
|
exportTargetName = "Long-term archive"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// binaryBody is a body that is not valid UTF-8.
|
||||||
|
binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||||
|
|
||||||
|
// openedEventID is the event the snapshot tests archive before
|
||||||
|
// they open the export.
|
||||||
|
openedEventID = "opened"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeExportTo writes export to w as the archive of the export tests'
|
||||||
|
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
||||||
|
func writeExportTo(
|
||||||
|
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
|
||||||
|
) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return export.WriteGzipJSON(
|
||||||
|
t.Context(), w,
|
||||||
|
&database.Webhook{
|
||||||
|
BaseModel: database.BaseModel{ID: exportWebhookID},
|
||||||
|
Name: exportWebhookName,
|
||||||
|
},
|
||||||
|
&database.Target{
|
||||||
|
BaseModel: database.BaseModel{ID: exportTargetID},
|
||||||
|
Name: exportTargetName,
|
||||||
|
},
|
||||||
|
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportArchive runs a whole export of the archive at path and returns
|
||||||
|
// its JSON, decompressed and parsed.
|
||||||
|
func exportArchive(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
return writeExport(t, export)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExport writes an opened export and returns its JSON,
|
||||||
|
// decompressed and parsed. Reading to the end makes the gzip reader
|
||||||
|
// check that the stream was finished.
|
||||||
|
func writeExport(
|
||||||
|
t *testing.T, export *delivery.ArchiveExport,
|
||||||
|
) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, export, &buf))
|
||||||
|
|
||||||
|
zr, err := gzip.NewReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(zr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &got))
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedEvents returns an export's archived_events.
|
||||||
|
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
list, ok := got["archived_events"].([]any)
|
||||||
|
require.True(t, ok, "archived_events must be an array: %v", got)
|
||||||
|
|
||||||
|
events := make([]map[string]any, len(list))
|
||||||
|
|
||||||
|
for i, v := range list {
|
||||||
|
events[i], ok = v.(map[string]any)
|
||||||
|
require.True(t, ok, "an archived event must be an object: %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedEventIDs returns the event_id of each of an export's
|
||||||
|
// archived_events.
|
||||||
|
func exportedEventIDs(t *testing.T, got map[string]any) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
events := exportedEvents(t, got)
|
||||||
|
ids := make([]string, 0, len(events))
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_MatchesStoredRows proves an export holds the
|
||||||
|
// webhook, the target, the time, and every column of every stored
|
||||||
|
// row: a body that is valid UTF-8 as a string, and one that is not in
|
||||||
|
// base64, marked as such.
|
||||||
|
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
|
||||||
|
|
||||||
|
for i, body := range bodies {
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||||
|
EventID: fmt.Sprintf("ev-%d", i),
|
||||||
|
WebhookID: exportWebhookID,
|
||||||
|
EntrypointID: "ep-1",
|
||||||
|
Method: "POST",
|
||||||
|
Headers: `{"X-Test":["yes"]}`,
|
||||||
|
Body: body,
|
||||||
|
ContentType: testContentType,
|
||||||
|
}, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored []delivery.ExportArchivedEvent
|
||||||
|
|
||||||
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
|
Order("id").Find(&stored).Error)
|
||||||
|
|
||||||
|
got := exportArchive(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
|
||||||
|
got["webhook"],
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
map[string]any{"id": exportTargetID, "name": exportTargetName},
|
||||||
|
got["target"],
|
||||||
|
)
|
||||||
|
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
|
||||||
|
|
||||||
|
events := exportedEvents(t, got)
|
||||||
|
require.Len(t, events, len(bodies))
|
||||||
|
|
||||||
|
for i, row := range stored {
|
||||||
|
assertExportedRow(t, row, events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertExportedRow checks that ev, from an export, holds every column
|
||||||
|
// of the stored row.
|
||||||
|
func assertExportedRow(
|
||||||
|
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
archivedAt, err := time.Parse(
|
||||||
|
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, archivedAt.Equal(row.ArchivedAt))
|
||||||
|
|
||||||
|
assert.EqualValues(t, row.ID, ev["id"])
|
||||||
|
assert.Equal(t, row.EventID, ev["event_id"])
|
||||||
|
assert.Equal(t, row.WebhookID, ev["webhook_id"])
|
||||||
|
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
|
||||||
|
assert.Equal(t, row.Method, ev["method"])
|
||||||
|
assert.Equal(t, row.Headers, ev["headers"])
|
||||||
|
assert.Equal(t, row.ContentType, ev["content_type"])
|
||||||
|
|
||||||
|
if row.Body != binaryBody {
|
||||||
|
assert.Equal(t, row.Body, ev["body"])
|
||||||
|
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, binaryBody, string(body))
|
||||||
|
assert.Equal(t, "base64", ev["body_encoding"])
|
||||||
|
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_Empty proves an archive with nothing in it exports
|
||||||
|
// as an empty archived_events: no file, which the export must not
|
||||||
|
// create; a file the archive writer has not yet put its table in; and
|
||||||
|
// a table with no rows.
|
||||||
|
func TestArchiveExport_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
missing := filepath.Join(dir, "missing.db")
|
||||||
|
noTable := filepath.Join(dir, "no-table.db")
|
||||||
|
noRows := filepath.Join(dir, "no-rows.db")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
|
||||||
|
require.NoError(t,
|
||||||
|
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
||||||
|
Open(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, path := range []string{missing, noTable, noRows} {
|
||||||
|
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.NoFileExists(t, missing+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
|
||||||
|
// archive as it was when it was opened, and holds up no archive
|
||||||
|
// write: a row written while the export is open is stored, and is not
|
||||||
|
// in the export. A write held up for the whole busy timeout would
|
||||||
|
// fail.
|
||||||
|
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
||||||
|
|
||||||
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
||||||
|
)
|
||||||
|
|
||||||
|
var stored int64
|
||||||
|
|
||||||
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
|
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
||||||
|
assert.Equal(t, int64(2), stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_SurvivesRename proves that renaming the archive
|
||||||
|
// while an export of it is open, as renaming its webhook or target
|
||||||
|
// does, leaves the export reading the same file.
|
||||||
|
func TestArchiveExport_SurvivesRename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive-old.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
||||||
|
|
||||||
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, w.Rename("archive-new.db"))
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
|
||||||
|
require.NoFileExists(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heapPeak is an io.Writer that discards what it is given and records
|
||||||
|
// the largest heap it saw at a write. It collects garbage before each
|
||||||
|
// reading, so the heap it reads is what is still held.
|
||||||
|
type heapPeak struct {
|
||||||
|
max uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *heapPeak) Write(b []byte) (int, error) {
|
||||||
|
var m runtime.MemStats
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
p.max = max(p.max, m.HeapAlloc)
|
||||||
|
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportHeapGrowth exports an archive of rows random bodies, each
|
||||||
|
// bodySize bytes of base64, and returns how far the heap rose above
|
||||||
|
// where it stood when the export began, at its highest.
|
||||||
|
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
// Base64 makes four characters of every three bytes.
|
||||||
|
random := make([]byte, bodySize/4*3)
|
||||||
|
|
||||||
|
for range rows {
|
||||||
|
_, _ = rand.Read(random)
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||||
|
Body: base64.StdEncoding.EncodeToString(random),
|
||||||
|
}, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export, err := delivery.OpenArchiveExport(
|
||||||
|
t.Context(), path, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, export.Close()) }()
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
|
||||||
|
var start runtime.MemStats
|
||||||
|
|
||||||
|
runtime.ReadMemStats(&start)
|
||||||
|
|
||||||
|
// Through a buffer, the heap is read once per 8 KiB of output
|
||||||
|
// rather than at each of gzip's small writes, which takes far
|
||||||
|
// longer.
|
||||||
|
peak := &heapPeak{max: start.HeapAlloc}
|
||||||
|
buffered := bufio.NewWriterSize(peak, 8<<10)
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, export, buffered))
|
||||||
|
require.NoError(t, buffered.Flush())
|
||||||
|
|
||||||
|
return peak.max - start.HeapAlloc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_Streams proves an export holds neither the archive
|
||||||
|
// nor its output in memory whole: exporting 384 KiB more of archive
|
||||||
|
// raises the heap's peak by less than half of that. The export's own
|
||||||
|
// memory, mostly gzip's compressor, is the same for both archives, so
|
||||||
|
// it cancels out. The bodies are random bytes in base64, which gzip
|
||||||
|
// shrinks by only a quarter, so an export that read every row before
|
||||||
|
// writing, or built the JSON or the gzipped file before writing it,
|
||||||
|
// would raise the peak by at least three quarters of the difference.
|
||||||
|
//
|
||||||
|
// The smaller archive has two rows so that its export, too, writes
|
||||||
|
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
|
||||||
|
// the heap must be read while the export's own memory is held.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // It measures the heap, which tests share.
|
||||||
|
func TestArchiveExport_Streams(t *testing.T) {
|
||||||
|
const (
|
||||||
|
bodySize = 16 << 10
|
||||||
|
smallRows = 2
|
||||||
|
largeRows = smallRows + 24
|
||||||
|
limit = (largeRows - smallRows) * bodySize / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
small := exportHeapGrowth(t, smallRows, bodySize)
|
||||||
|
large := exportHeapGrowth(t, largeRows, bodySize)
|
||||||
|
|
||||||
|
assert.Less(t, large, small+limit,
|
||||||
|
"the heap rose by %d for %d rows and by %d for %d rows",
|
||||||
|
small, smallRows, large, largeRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExportFileName proves the download is named for the
|
||||||
|
// webhook and the target, with the names made safe as for the archive
|
||||||
|
// file, and the export time in UTC.
|
||||||
|
func TestArchiveExportFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
|
||||||
|
delivery.ArchiveExportFileName(
|
||||||
|
exportWebhookName, exportTargetName,
|
||||||
|
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,7 @@ 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 {
|
||||||
@@ -42,7 +44,8 @@ func openArchiveDBForRead(
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -181,16 +184,66 @@ 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, 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()
|
||||||
|
|
||||||
// A generous debounce keeps the two rapid writes inside
|
const debounce = 2 * time.Second
|
||||||
// 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(), 2*time.Second,
|
path, archiveTestLogger(), debounce,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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,
|
||||||
))
|
))
|
||||||
@@ -202,7 +255,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())
|
||||||
|
|
||||||
time.Sleep(2100 * time.Millisecond)
|
now = now.Add(debounce)
|
||||||
|
|
||||||
require.NoError(t, w.Write(
|
require.NoError(t, w.Write(
|
||||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||||
@@ -479,9 +532,9 @@ func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenameArchive_MovesTheFile proves a rename moves the archive,
|
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
||||||
// rows and all, and that later writes go to the new name.
|
// and all, and that later writes go to the new name.
|
||||||
func TestRenameArchive_MovesTheFile(t *testing.T) {
|
func TestRename_MovesTheFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupArchiveTest(t)
|
||||||
@@ -496,7 +549,7 @@ func TestRenameArchive_MovesTheFile(t *testing.T) {
|
|||||||
require.FileExists(t, oldPath)
|
require.FileExists(t, oldPath)
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t, env.eng.RenameArchive(tgt.ID, "Orders", "Long Term"),
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||||
)
|
)
|
||||||
|
|
||||||
newPath := filepath.Join(
|
newPath := filepath.Join(
|
||||||
@@ -521,19 +574,73 @@ func TestRenameArchive_MovesTheFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenameArchive_BeforeTheNameIsSaved covers the order the
|
// TestRename_NeverReplacesAFile plants a file at the new name, once
|
||||||
// handlers use: they rename before they save the new name, so a
|
// the .db alone, once a lone -wal and once a lone -shm, and proves
|
||||||
// delivery in between must write under the new name although the
|
// each time that the rename is refused, the planted file survives,
|
||||||
// main database still has the old one. It also shows that renaming
|
// and the archive keeps its name and its rows.
|
||||||
// an archive that does not exist yet is not an error.
|
func TestRename_NeverReplacesAFile(t *testing.T) {
|
||||||
func TestRenameArchive_BeforeTheNameIsSaved(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()
|
t.Parallel()
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
env := setupArchiveTest(t)
|
||||||
tgt := env.seedDatabaseTarget(t, "")
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t, env.eng.RenameArchive(tgt.ID, "Orders", "Archive"),
|
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
@@ -581,3 +688,35 @@ func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, newPath, w.Path())
|
assert.Equal(t, newPath, w.Path())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
|
||||||
|
// move after the .db has moved, and proves the .db is moved back, so
|
||||||
|
// the archive is never split across two names. The new name is 255
|
||||||
|
// bytes, the longest a file name may be, so the .db can take it but
|
||||||
|
// the -wal, four bytes longer, cannot.
|
||||||
|
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
oldPath := filepath.Join(dir, "archive-old.db")
|
||||||
|
newName := strings.Repeat("a", 252) + ".db"
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
require.NoError(
|
||||||
|
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := delivery.NewExportArchiveWriter(
|
||||||
|
oldPath, archiveTestLogger(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, w.Rename(newName))
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.FileExists(t, oldPath+suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||||
|
assert.Equal(t, oldPath, w.Path())
|
||||||
|
}
|
||||||
|
|||||||
@@ -442,7 +442,9 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
originScoped := applyRequestHeaders(req, event, cfg)
|
originScoped := applyRequestHeaders(
|
||||||
|
req, event, cfg, t.eng.userAgent(),
|
||||||
|
)
|
||||||
|
|
||||||
client := t.clientForRequest(cfg, originScoped)
|
client := t.clientForRequest(cfg, originScoped)
|
||||||
|
|
||||||
@@ -562,10 +564,13 @@ 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(
|
||||||
@@ -580,7 +585,7 @@ func applyRequestHeaders(
|
|||||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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", "webhooker/1.0")
|
req.Header.Set("User-Agent", t.eng.userAgent())
|
||||||
|
|
||||||
resp, doErr := executeHTTPRequest(t.client, req)
|
resp, doErr := executeHTTPRequest(t.client, req)
|
||||||
durationMs := time.Since(start).Milliseconds()
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|||||||
@@ -573,6 +573,8 @@ 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,
|
||||||
@@ -612,6 +614,8 @@ 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,
|
||||||
|
|||||||
@@ -179,6 +179,27 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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 returns everything it wrote to standard
|
// dataDir with DEBUG=true and nothing else set, and returns everything
|
||||||
// output.
|
// it wrote to standard 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,6 +128,7 @@ 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)
|
||||||
|
|
||||||
@@ -137,6 +138,10 @@ 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,
|
||||||
|
|||||||
@@ -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; its one caller is
|
// this interface. No production code path calls it; only tests do, and
|
||||||
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
|
// what a test binds is fixture data. scan_guard_test.go fails if a
|
||||||
// scan_guard_test.go fails if a non-test file calls it.
|
// 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(
|
||||||
|
|||||||
@@ -14,18 +14,16 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minNonTestFiles guards the walk below against passing because it
|
// isRowProducer reports whether name is GORM's Row or database/sql's
|
||||||
// found nothing to look at. The tree held 60 non-test .go files when
|
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
|
||||||
// this was written.
|
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
|
||||||
const minNonTestFiles = 40
|
// it also returns an error, so Scan is never called on its result
|
||||||
|
// directly. It matches the method name only and resolves no types, so
|
||||||
// isRowProducer reports whether name is a method that returns a
|
// a repo-local method with one of these names that returns *gorm.DB
|
||||||
// database/sql row handle. GORM's Row and Rows return *sql.Row and
|
// gets past it: Scan on that method's result is not reported.
|
||||||
// *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", "Rows", "QueryRow", "QueryRowContext":
|
case "Row", "QueryRow", "QueryRowContext":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -50,9 +48,14 @@ 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 row handle. It fails closed: a receiver it cannot
|
// receiver is not a call to a row producer. It fails closed: any other
|
||||||
// resolve syntactically — a local variable, a struct field — is
|
// receiver — a local variable, a struct field, a call to any other
|
||||||
// reported rather than assumed safe.
|
// method — is reported rather than assumed safe.
|
||||||
|
//
|
||||||
|
// It sees only calls written x.Scan(...). A method value, f := db.Scan
|
||||||
|
// followed by f(&v), is out of scope: Scan is never the called
|
||||||
|
// expression there, and nobody writes a query that way by accident,
|
||||||
|
// which is the mistake this check exists to catch.
|
||||||
func unguardedScans(
|
func unguardedScans(
|
||||||
fset *token.FileSet, file *ast.File,
|
fset *token.FileSet, file *ast.File,
|
||||||
) []token.Position {
|
) []token.Position {
|
||||||
@@ -111,15 +114,15 @@ func skipDir(name string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkNonTestGo parses every non-test .go file under root and returns
|
// walkNonTestGo parses every non-test .go file under root. It returns
|
||||||
// how many it parsed along with every unguarded Scan it found.
|
// the directories, relative to root, it parsed a file in, along with
|
||||||
func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
// every unguarded Scan it found.
|
||||||
|
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var (
|
walked := map[string]bool{}
|
||||||
parsed int
|
|
||||||
hits []string
|
var hits []string
|
||||||
)
|
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
|
|
||||||
@@ -147,7 +150,12 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed++
|
dir, err := filepath.Rel(root, filepath.Dir(path))
|
||||||
|
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))
|
||||||
@@ -157,7 +165,7 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
return parsed, hits
|
return walked, hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNonTestGo reports whether a file name is Go source this check
|
// isNonTestGo reports whether a file name is Go source this check
|
||||||
@@ -189,19 +197,39 @@ 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.
|
||||||
//
|
//
|
||||||
// The current tree has one caller, internal/database/database_test.go,
|
// Test files are not governed: what a test binds is fixture data.
|
||||||
// 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()
|
||||||
|
|
||||||
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
|
root := moduleRoot(t)
|
||||||
|
walked, offenders := walkNonTestGo(t, root)
|
||||||
|
|
||||||
|
// The module's packages are static, templates, and every directory
|
||||||
|
// directly under cmd and internal. Each holds non-test code, so one
|
||||||
|
// the walk parsed nothing in was skipped, and a Scan there would
|
||||||
|
// pass unseen.
|
||||||
|
packages := []string{"static", "templates"}
|
||||||
|
|
||||||
|
for _, parent := range []string{"cmd", "internal"} {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(root, parent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packages = append(packages, filepath.Join(parent, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range packages {
|
||||||
|
require.True(
|
||||||
|
t, walked[dir],
|
||||||
|
"the walk parsed no non-test .go file in %s", dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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 "+
|
||||||
@@ -222,18 +250,51 @@ 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{
|
||||||
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
|
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
|
||||||
{"gorm receiver", `gdb.Scan(&v)`, 1},
|
{"struct field", `s.db.Scan(&v)`, 1},
|
||||||
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
|
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
|
||||||
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
|
{
|
||||||
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
"sql rows in a variable",
|
||||||
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
|
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
||||||
|
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
|
||||||
|
{
|
||||||
|
"sql QueryRowContext",
|
||||||
|
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
|
||||||
|
0,
|
||||||
|
},
|
||||||
{"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.
|
||||||
@@ -245,9 +306,7 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
src := fmt.Sprintf(
|
src := fmt.Sprintf(plantedFile, tc.body)
|
||||||
"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,
|
||||||
|
|||||||
+10
-27
@@ -74,7 +74,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)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,8 +139,7 @@ func (h *Handlers) renderLoginError(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(status)
|
h.renderTemplateStatus(w, r, "login.html", data, 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.
|
||||||
@@ -212,11 +211,7 @@ 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.log.Error("failed to verify password", "error", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -288,24 +283,14 @@ 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.log.Error("failed to get session", "error", err)
|
h.serverError(w, r, "failed to get session", 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.log.Error(
|
h.serverError(w, r, "failed to regenerate session", err)
|
||||||
"failed to regenerate session", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -314,11 +299,7 @@ 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.log.Error("failed to save session", "error", err)
|
h.serverError(w, r, "failed to save session", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,7 +332,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
http.Redirect(
|
||||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
w, r, withNotice("/pages/login", signedOut),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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"
|
||||||
@@ -404,6 +405,60 @@ 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) {
|
||||||
|
|||||||
@@ -11,72 +11,37 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// replayOutcomeParam is the query parameter the replay POST redirects
|
// The outcomes of a replay POST, as the notice codes its redirect
|
||||||
// with and the event log page reads its banner from.
|
// carries. noticeFor holds the line each one shows.
|
||||||
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 replayOutcomeCode = "queued"
|
replayQueued noticeCode = "replay-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.
|
// target and this is the ordinary case for an old event.
|
||||||
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
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 replayOutcomeCode = "target-missing"
|
replayTargetMissing noticeCode = "replay-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 replayOutcomeCode = "target-inactive"
|
replayTargetInactive noticeCode = "replay-target-inactive"
|
||||||
|
|
||||||
// replayNotTerminal reports a delivery the engine has not finished
|
// replayNotTerminal reports a delivery the engine has not finished
|
||||||
// with.
|
// with.
|
||||||
replayNotTerminal replayOutcomeCode = "not-terminal"
|
replayNotTerminal noticeCode = "replay-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 replayOutcomeCode = "in-flight"
|
replayInFlight noticeCode = "replay-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.
|
||||||
//
|
//
|
||||||
@@ -105,9 +70,7 @@ 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 {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,14 +87,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
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, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,14 +105,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !original.Status.Terminal() {
|
if !original.Status.Terminal() {
|
||||||
h.finishReplay(w, r, webhook, replayNotTerminal)
|
redirectToEventLog(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 {
|
||||||
h.finishReplay(w, r, webhook, code)
|
redirectToEventLog(w, r, webhook, code)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +136,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 {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -195,14 +158,14 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to count in-flight deliveries", err,
|
w, r, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if inFlight > 0 {
|
if inFlight > 0 {
|
||||||
h.finishReplay(w, r, webhook, replayInFlight)
|
redirectToEventLog(w, r, webhook, replayInFlight)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,7 +175,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, "failed to load event for replay", err)
|
h.serverError(w, r, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,7 +185,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to create replay delivery", err,
|
w, r, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -240,7 +203,7 @@ func (h *Handlers) queueReplay(
|
|||||||
"delivery_id", task.DeliveryID,
|
"delivery_id", task.DeliveryID,
|
||||||
)
|
)
|
||||||
|
|
||||||
h.finishReplay(w, r, webhook, replayQueued)
|
redirectToEventLog(w, r, webhook, replayQueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayTarget loads the delivery's target as it stands now.
|
// replayTarget loads the delivery's target as it stands now.
|
||||||
@@ -253,7 +216,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, replayOutcomeCode) {
|
) (*database.Target, noticeCode) {
|
||||||
var target database.Target
|
var target database.Target
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().Where(
|
err := h.db.DB().Unscoped().Where(
|
||||||
@@ -363,17 +326,16 @@ func replayBody(body string) *string {
|
|||||||
return &body
|
return &body
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishReplay redirects back to the event log the replay was
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// triggered from, carrying the outcome code the page turns into a
|
// log it was triggered from, carrying the outcome as its notice and
|
||||||
// banner and the page number the form submitted.
|
// the page number the form submitted.
|
||||||
func (h *Handlers) finishReplay(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code replayOutcomeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/hook/" + webhook.ID + "/events?" +
|
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||||
replayOutcomeParam + "=" + string(code)
|
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
// this is a POST, and its query string is what logs and Referer
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?replay=queued",
|
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,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?replay=target-deleted",
|
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,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?replay=target-missing",
|
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -431,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?replay=queued",
|
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,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?replay=in-flight",
|
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ 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?replay=not-terminal",
|
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,7 +509,7 @@ 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, "?replay=target-deleted",
|
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?replay=made-up",
|
t, h, sess, wh.ID, "?notice=made-up",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NotContains(t, unknown, "alert-error")
|
assert.NotContains(t, unknown, "alert-error")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +16,21 @@ 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.
|
||||||
@@ -32,3 +52,60 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
||||||
|
// which cannot render answers with the status it was reporting, as
|
||||||
|
// plain text, and is not attempted again: a page whose own render
|
||||||
|
// fails reaches the error page, and the error page failing as well
|
||||||
|
// ends there with the 500.
|
||||||
|
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &h)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// .Status is an int, so asking it for a field fails the render.
|
||||||
|
failing := `{{.Status.Missing}}`
|
||||||
|
h.AddTemplateForTest("error.html", template.Must(
|
||||||
|
template.New("error").Parse(failing),
|
||||||
|
))
|
||||||
|
h.AddTemplateForTest("failing.html", template.Must(
|
||||||
|
template.New("failing").Parse(`{{.Data.Missing}}`),
|
||||||
|
))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, "Not Found\n", w.Body.String())
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -15,16 +15,19 @@ import (
|
|||||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||||
// to blob is what makes the driver hand back the stored bytes
|
// to blob is what makes the driver hand back the stored bytes
|
||||||
// rather than a string conversion, so Content-Length taken from
|
// rather than a string conversion, so Content-Length taken from
|
||||||
// the result matches what goes on the wire. The soft-delete
|
// the result matches what goes on the wire. The retention reaper
|
||||||
// predicate is spelled out because Raw bypasses GORM's default
|
// deletes event rows outright, so a reaped event is simply gone
|
||||||
// scope, and it is what stops a reaped event still being
|
// and the query finds no row. The deleted_at predicate repeats
|
||||||
// downloadable.
|
// the soft-delete scope GORM adds to its own queries, which Raw
|
||||||
|
// bypasses; nothing soft-deletes an event, so today it excludes
|
||||||
|
// nothing.
|
||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
// HandleEventBodyDownload serves one event's stored body in
|
// HandleEventBodyDownload serves one event's stored body byte
|
||||||
// full, which the event log page cannot: it caps each rendered
|
// for byte, which the pages do not: they show it as escaped
|
||||||
// body at maxRenderedBodyBytes.
|
// text, cut at maxRenderedBodyBytes in the lists of events, and
|
||||||
|
// leave a binary one out.
|
||||||
//
|
//
|
||||||
// The bytes are attacker-supplied — anyone who can reach the
|
// The bytes are attacker-supplied — anyone who can reach the
|
||||||
// public receiver chooses them — and this route hands them back
|
// public receiver chooses them — and this route hands them back
|
||||||
@@ -52,7 +55,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +106,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
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, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to read event body", err)
|
h.serverError(w, r, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +133,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,10 +405,11 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
|||||||
// route. The body is read in one query before any header is
|
// route. The body is read in one query before any header is
|
||||||
// written, so a reaped event cannot produce a partial download:
|
// written, so a reaped event cannot produce a partial download:
|
||||||
// it is a clean 404 with no Content-Length and no
|
// it is a clean 404 with no Content-Length and no
|
||||||
// Content-Disposition. Both removals the codebase performs are
|
// Content-Disposition. The reaper deletes event rows outright,
|
||||||
// covered — the reaper hard-deletes, and a soft-deleted row is
|
// which is the "hard deleted" case. The "soft deleted" case
|
||||||
// excluded by the query's own deleted_at predicate rather than
|
// covers a row no code produces today: it only pins the query's
|
||||||
// by GORM's default scope, which Raw bypasses.
|
// own deleted_at predicate, the soft-delete condition Raw would
|
||||||
|
// otherwise skip.
|
||||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRenderedBodyBytes is the most of one event's body that the
|
||||||
|
// recent events on a webhook's page and the event log show; a larger
|
||||||
|
// body is cut there and shown whole only on the event's own page.
|
||||||
|
// Bodies come from the unauthenticated receiver under its 1 MB cap,
|
||||||
|
// and renderTemplate buffers a whole page before writing it, so a list
|
||||||
|
// of events cannot show every body whole.
|
||||||
|
const maxRenderedBodyBytes = 32 << 10
|
||||||
|
|
||||||
|
// maxInlineBodyLines is the most lines a body is shown at its full
|
||||||
|
// height with. A body with more lines, or larger than
|
||||||
|
// maxRenderedBodyBytes, is shown in a box of fixed height that
|
||||||
|
// scrolls, so that it does not make the page huge.
|
||||||
|
const maxInlineBodyLines = 200
|
||||||
|
|
||||||
|
// maxIndentDepth is how deeply a JSON body's objects and arrays may
|
||||||
|
// nest for it to be indented at all; a deeper one is shown as received.
|
||||||
|
// Each level indents every line inside it two more spaces, so 10 KB of
|
||||||
|
// nested brackets would indent to some 50 MB; within this depth a body
|
||||||
|
// grows at most 35 times.
|
||||||
|
const maxIndentDepth = 16
|
||||||
|
|
||||||
|
// A JSON body is shown pretty-printed only when that makes it at most
|
||||||
|
// maxIndentGrowth times its size plus indentAllowance bytes, and
|
||||||
|
// otherwise as received, so that indenting does not undo
|
||||||
|
// maxRenderedBodyBytes. The allowance keeps a small nested body
|
||||||
|
// pretty-printed.
|
||||||
|
const (
|
||||||
|
maxIndentGrowth = 4
|
||||||
|
indentAllowance = 1 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonIndent is the indent of a pretty-printed JSON body.
|
||||||
|
const jsonIndent = " "
|
||||||
|
|
||||||
|
// BodyView is an event's body as the pages show it. newBodyView
|
||||||
|
// decides it and templates/event_body.html shows it, the same way in
|
||||||
|
// the recent events on a webhook's page, in the event log and on the
|
||||||
|
// event's own page.
|
||||||
|
type BodyView struct {
|
||||||
|
// EventURL is the event's own page. The stored body downloads
|
||||||
|
// from EventURL/body.
|
||||||
|
EventURL string
|
||||||
|
|
||||||
|
// Text is the body as shown, pretty-printed when it is JSON.
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// Size is the stored body's size in bytes, and ShownBytes how
|
||||||
|
// many of them Text holds when Cut.
|
||||||
|
Size int64
|
||||||
|
ShownBytes int
|
||||||
|
|
||||||
|
// Cut reports that Text is only the start of the body.
|
||||||
|
Cut bool
|
||||||
|
|
||||||
|
// Binary reports a body that is not text. It is not shown.
|
||||||
|
Binary bool
|
||||||
|
|
||||||
|
// Scroll reports a body to show in a box that scrolls.
|
||||||
|
Scroll bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBodyView decides how to show an event's body. body is the
|
||||||
|
// stored body, or its first maxRenderedBodyBytes when only those were
|
||||||
|
// read, and size is the stored body's size.
|
||||||
|
func newBodyView(eventURL string, body []byte, size int64) BodyView {
|
||||||
|
v := BodyView{EventURL: eventURL, Size: size}
|
||||||
|
|
||||||
|
if size > int64(len(body)) {
|
||||||
|
v.Cut = true
|
||||||
|
body = trimPartialRune(body)
|
||||||
|
v.ShownBytes = len(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// html/template shows invalid UTF-8 as replacement characters,
|
||||||
|
// and a browser shows a control character other than tab, line
|
||||||
|
// feed and carriage return as a box or not at all, so a body
|
||||||
|
// holding either is not text.
|
||||||
|
isControl := func(r rune) bool {
|
||||||
|
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
|
||||||
|
v.Binary = true
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cut JSON document is no longer valid JSON.
|
||||||
|
if !v.Cut {
|
||||||
|
body = indentJSON(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page shows a carriage return, a line feed, or the two
|
||||||
|
// together as one line break. A final one ends the last line
|
||||||
|
// rather than starting another.
|
||||||
|
text := bytes.TrimSuffix(body, []byte("\n"))
|
||||||
|
text = bytes.TrimSuffix(text, []byte("\r"))
|
||||||
|
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
|
||||||
|
bytes.Count(text, []byte("\r\n"))
|
||||||
|
lines := breaks + 1
|
||||||
|
|
||||||
|
v.Text = string(body)
|
||||||
|
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// indentJSON returns body pretty-printed when it is a JSON document,
|
||||||
|
// and unchanged when it is not, nests deeper than maxIndentDepth, or
|
||||||
|
// would grow past maxIndentGrowth times its size plus indentAllowance
|
||||||
|
// bytes.
|
||||||
|
func indentJSON(body []byte) []byte {
|
||||||
|
if !json.Valid(body) || !indentFits(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
err := json.Indent(&out, body, "", jsonIndent)
|
||||||
|
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// indentFits reports whether the objects and arrays of the JSON
|
||||||
|
// document body nest at most maxIndentDepth deep.
|
||||||
|
func indentFits(body []byte) bool {
|
||||||
|
depth := 0
|
||||||
|
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
|
||||||
|
// A number too large for a float64 is still valid JSON.
|
||||||
|
dec.UseNumber()
|
||||||
|
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tok {
|
||||||
|
case json.Delim('{'), json.Delim('['):
|
||||||
|
depth++
|
||||||
|
if depth > maxIndentDepth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case json.Delim('}'), json.Delim(']'):
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bodyView is how the pages would show body, stored whole.
|
||||||
|
func bodyView(body string) handlers.BodyView {
|
||||||
|
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines is n lines of text, without a newline after the last.
|
||||||
|
func lines(n int) string {
|
||||||
|
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
|
||||||
|
// pretty-printed, whatever its content type, with its keys in
|
||||||
|
// the order they arrived.
|
||||||
|
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
`{`,
|
||||||
|
` "b": 1,`,
|
||||||
|
` "a": [`,
|
||||||
|
` true,`,
|
||||||
|
` null,`,
|
||||||
|
` "x"`,
|
||||||
|
` ],`,
|
||||||
|
` "c": {}`,
|
||||||
|
`}`,
|
||||||
|
}, strings.Split(v.Text, "\n"))
|
||||||
|
assert.False(t, v.Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_FormatsNestedJSON proves a small document with a
|
||||||
|
// few levels of nesting is pretty-printed.
|
||||||
|
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
`{`,
|
||||||
|
` "data": [`,
|
||||||
|
` [`,
|
||||||
|
` 1,`,
|
||||||
|
` 2,`,
|
||||||
|
` 3`,
|
||||||
|
` ],`,
|
||||||
|
` [`,
|
||||||
|
` 4,`,
|
||||||
|
` 5,`,
|
||||||
|
` 6`,
|
||||||
|
` ]`,
|
||||||
|
` ]`,
|
||||||
|
`}`,
|
||||||
|
}, strings.Split(v.Text, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
|
||||||
|
// a JSON document is shown exactly as it arrived.
|
||||||
|
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"a":1,`,
|
||||||
|
`{"a":1} {"b":2}`,
|
||||||
|
"plain text\n indented",
|
||||||
|
} {
|
||||||
|
assert.Equal(t, body, bodyView(body).Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
|
||||||
|
// more than 16 levels deep is shown as it arrived. 10 KB of nested
|
||||||
|
// arrays would indent to some 50 MB.
|
||||||
|
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nested := func(depth int) string {
|
||||||
|
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
|
||||||
|
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
|
||||||
|
|
||||||
|
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, body, bodyView(body).Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
|
||||||
|
// pretty-printing would make more than four times its size plus 1 KiB
|
||||||
|
// is shown as it arrived, however shallow: each short element eight
|
||||||
|
// levels deep gets a line indented sixteen spaces.
|
||||||
|
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
numbers := func(n int) string {
|
||||||
|
return strings.Repeat("[", 8) +
|
||||||
|
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
|
||||||
|
strings.Repeat("]", 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
|
||||||
|
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
|
||||||
|
// its full height up to 200 lines and in the scrolling box past
|
||||||
|
// them, counting the lines after formatting.
|
||||||
|
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(t, bodyView(lines(200)).Scroll)
|
||||||
|
assert.True(t, bodyView(lines(201)).Scroll)
|
||||||
|
|
||||||
|
// A final newline ends the last line rather than starting another.
|
||||||
|
assert.False(t, bodyView(lines(200)+"\n").Scroll)
|
||||||
|
assert.True(t, bodyView(lines(201)+"\n").Scroll)
|
||||||
|
|
||||||
|
// The page shows a carriage return, a line feed, or the two
|
||||||
|
// together as one line break.
|
||||||
|
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
|
||||||
|
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
|
||||||
|
|
||||||
|
// One line as received, 201 once formatted: the brackets and
|
||||||
|
// 199 elements.
|
||||||
|
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
|
||||||
|
|
||||||
|
assert.NotContains(t, numbers, "\n")
|
||||||
|
assert.True(t, bodyView(numbers).Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
|
||||||
|
// cap of the lists of events is shown in the scrolling box
|
||||||
|
// however few lines it has, on the event's own page as in the
|
||||||
|
// lists.
|
||||||
|
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
|
||||||
|
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_BinaryNotShown proves a body that is not text
|
||||||
|
// is never shown: one that is not valid UTF-8, or that holds a
|
||||||
|
// control character other than tab, line feed and carriage return.
|
||||||
|
func TestNewBodyView_BinaryNotShown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
"\xff\xfe\xfd",
|
||||||
|
"a\x00b",
|
||||||
|
// A small protobuf message: valid UTF-8, but control bytes.
|
||||||
|
"\x08\x01\x12\x03abc",
|
||||||
|
"\x1b[31mred\x1b[0m",
|
||||||
|
"a\x7fb",
|
||||||
|
} {
|
||||||
|
v := bodyView(body)
|
||||||
|
|
||||||
|
assert.True(t, v.Binary, "%q", body)
|
||||||
|
assert.Empty(t, v.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, bodyView("snow "+snowman).Binary)
|
||||||
|
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleEventDetail shows one event on its own page: its details,
|
||||||
|
// its whole body and every delivery of it. The page reads the
|
||||||
|
// event's body whole, which the receiver caps at 1 MB.
|
||||||
|
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []eventLogRow
|
||||||
|
|
||||||
|
err = webhookDB.Model(&database.Event{}).
|
||||||
|
Select(eventColumns).
|
||||||
|
Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
chi.URLParam(r, "eventID"), webhook.ID,
|
||||||
|
).
|
||||||
|
Limit(1).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load event", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := h.loadTargetMap(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
views, ok := h.eventLogViews(
|
||||||
|
w, r, webhookDB, webhook.ID, rows, targets,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "event_detail.html", map[string]any{
|
||||||
|
tmplKeyWebhook: &webhook,
|
||||||
|
"Event": views[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveEventPage runs the real event page handler as the test user
|
||||||
|
// for the given webhook and event ids.
|
||||||
|
func serveEventPage(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet,
|
||||||
|
"/hook/"+webhookID+"/events/"+eventID,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add(paramSourceID, webhookID)
|
||||||
|
rctx.URLParams.Add(paramEventID, eventID)
|
||||||
|
|
||||||
|
req = req.WithContext(
|
||||||
|
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleEventDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
|
||||||
|
// event's page shows its details, its whole body even past the cap
|
||||||
|
// of the lists of events, pretty-printed and in the scrolling box,
|
||||||
|
// and each delivery with its status and attempts.
|
||||||
|
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
|
||||||
|
const sentinel = "TAIL-SENTINEL-5b2e"
|
||||||
|
|
||||||
|
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
|
||||||
|
`","tail":"` + sentinel + `"}`
|
||||||
|
event := f.event(t, contentTypeJSON, body, time.Now())
|
||||||
|
f.attempt(t, f.delivery(
|
||||||
|
t, event, target.ID, database.DeliveryStatusFailed,
|
||||||
|
), http.StatusBadGateway, time.Second)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
|
||||||
|
assert.Contains(t, page, event.ID)
|
||||||
|
assert.Contains(t, page, contentTypeJSON)
|
||||||
|
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
||||||
|
assert.Contains(t, page, "{\n "pad": "xxx")
|
||||||
|
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
||||||
|
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
||||||
|
assert.NotContains(t, page, "Showing the first")
|
||||||
|
assert.Contains(t, page, target.Name)
|
||||||
|
assert.Contains(t, page, ">failed</span>")
|
||||||
|
assert.Contains(t, page, "Status: 502")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
|
||||||
|
// page links to its original's page, and the original's page says
|
||||||
|
// it was resubmitted.
|
||||||
|
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
original := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||||
|
|
||||||
|
cp := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: "{}",
|
||||||
|
ContentType: contentTypeJSON,
|
||||||
|
ResubmittedFromID: &original.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(),
|
||||||
|
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "as 1 new event<")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
|
||||||
|
// 404 for an event that does not exist and for one that belongs to
|
||||||
|
// another webhook.
|
||||||
|
func TestHandleEventDetail_UnknownEventNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
mine := seedWebhook(t, db)
|
||||||
|
theirs := seedWebhook(t, db)
|
||||||
|
|
||||||
|
seedEventWithBody(t, dbMgr, mine.ID, "{}")
|
||||||
|
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
|
||||||
|
|
||||||
|
for _, id := range []string{"no-such-event", elsewhere.ID} {
|
||||||
|
w := serveEventPage(t, h, sess, mine.ID, id)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,6 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxRenderedBodyBytes caps how many bytes of a stored event
|
|
||||||
// body reach the event log page. Bodies come from the
|
|
||||||
// unauthenticated receiver under the 1 MB ingest cap and
|
|
||||||
// renderTemplate buffers a whole page before writing it, so
|
|
||||||
// an uncapped page of paginationPerPage events is tens of
|
|
||||||
// megabytes of resident memory per concurrent viewer.
|
|
||||||
const maxRenderedBodyBytes = 8192
|
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
// blob are load-bearing: they make substr and length count
|
// blob are load-bearing: they make substr and length count
|
||||||
// bytes rather than characters, so the cap bounds the page in
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
@@ -24,27 +16,23 @@ const eventLogColumns = "id, created_at, method, content_type, " +
|
|||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
|
// eventColumns is eventLogColumns for the event's own page, which
|
||||||
|
// shows the whole body.
|
||||||
|
const eventColumns = "id, created_at, method, content_type, " +
|
||||||
|
"resubmitted_from_id, " +
|
||||||
|
"cast(body as blob) AS body, " +
|
||||||
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
// EventLogView is the display-safe projection of an event for
|
// EventLogView is the display-safe projection of an event for
|
||||||
// the event log page, alongside DeliveryView and TargetView.
|
// the event log page and the event's own page, alongside
|
||||||
// It carries a capped body plus the true stored size, so the
|
// DeliveryView and TargetView.
|
||||||
// page can mark a body as truncated without ever holding the
|
|
||||||
// whole thing.
|
|
||||||
type EventLogView struct {
|
type EventLogView struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
|
|
||||||
// Body holds at most maxRenderedBodyBytes bytes of the
|
Body BodyView
|
||||||
// stored body.
|
|
||||||
Body string
|
|
||||||
|
|
||||||
// BodyBytes is the true size of the stored body.
|
|
||||||
BodyBytes int64
|
|
||||||
|
|
||||||
// BodyTruncated reports that the stored body was larger
|
|
||||||
// than the cap, so the page owes the reader a marker.
|
|
||||||
BodyTruncated bool
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
@@ -65,16 +53,10 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
return v.ResubmittedFromID != ""
|
return v.ResubmittedFromID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// BodyShownBytes is how many body bytes the page is actually
|
// eventLogRow is one row of the event log projection, or of
|
||||||
// rendering, which the truncation marker reports beside the
|
// eventColumns. In the event log its body column arrives
|
||||||
// true size.
|
// already cut to the cap by SQLite, with the true size beside
|
||||||
func (v EventLogView) BodyShownBytes() int {
|
// it.
|
||||||
return len(v.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection. Its
|
|
||||||
// body column arrives already cut to the cap by SQLite, with
|
|
||||||
// the true size beside it.
|
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -85,31 +67,22 @@ type eventLogRow struct {
|
|||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row for rendering.
|
// view projects a loaded row of the webhook's events for
|
||||||
func (r *eventLogRow) view() EventLogView {
|
// rendering.
|
||||||
body := r.Body
|
func (r *eventLogRow) view(webhookID string) EventLogView {
|
||||||
truncated := r.BodyBytes > int64(len(body))
|
|
||||||
|
|
||||||
// Only a cut body can have been left mid-sequence by
|
|
||||||
// this query. A whole body is passed through exactly as
|
|
||||||
// stored, however malformed.
|
|
||||||
if truncated {
|
|
||||||
body = trimPartialRune(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
}
|
}
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
Body: string(body),
|
Body: newBodyView(
|
||||||
BodyBytes: r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
BodyTruncated: truncated,
|
),
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bodyCap is the number of body bytes the event log page is
|
// bodyCap is the number of body bytes the lists of events are
|
||||||
// allowed to render for one event.
|
// allowed to render for one event.
|
||||||
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func seedAndProject(
|
|||||||
|
|
||||||
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
||||||
// page is bounded by the cap rather than by the stored payload:
|
// page is bounded by the cap rather than by the stored payload:
|
||||||
// the body here is 64 times the cap, and the ingest path would
|
// the body here is 16 times the cap, and the ingest path would
|
||||||
// accept twice as much again.
|
// accept twice as much again.
|
||||||
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
|||||||
// The marker states the true stored size, not the cut one.
|
// The marker states the true stored size, not the cut one.
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, page,
|
t, page,
|
||||||
"showing "+strconv.Itoa(bodyCap)+
|
"Showing the first "+strconv.Itoa(bodyCap)+
|
||||||
" of "+strconv.Itoa(storedBytes)+" bytes",
|
" of "+strconv.Itoa(storedBytes)+" bytes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,34 +152,35 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
|
|||||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, page, ""kept"")
|
assert.Contains(t, page, ""kept"")
|
||||||
assert.NotContains(t, page, "Body truncated for display")
|
assert.NotContains(t, page, "Showing the first")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
||||||
// by the byte-wise cut is dropped rather than surfaced as a
|
// by the byte-wise cut is dropped rather than surfaced as a
|
||||||
// mojibake tail.
|
// mojibake tail, which would also make the text look binary.
|
||||||
func TestEventLogView_CutMidRune(t *testing.T) {
|
func TestEventLogView_CutMidRune(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
body := strings.Repeat(snowman, 4096)
|
body := strings.Repeat(snowman, bodyCap)
|
||||||
view := seedAndProject(t, body)
|
view := seedAndProject(t, body)
|
||||||
|
|
||||||
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
||||||
// of the next one; those two are dropped.
|
// of the next one; those two are dropped.
|
||||||
whole := bodyCap / len(snowman)
|
whole := bodyCap / len(snowman)
|
||||||
|
|
||||||
assert.True(t, view.BodyTruncated)
|
assert.True(t, view.Body.Cut)
|
||||||
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
assert.False(t, view.Body.Binary)
|
||||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||||
assert.True(t, utf8.ValidString(view.Body))
|
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
|
||||||
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
assert.True(t, utf8.ValidString(view.Body.Text))
|
||||||
|
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
|
||||||
|
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
|
// TestEventLogView_BinaryBodyNotShown proves a body that is not
|
||||||
// payload is passed through byte for byte. Its tail is invalid
|
// text is left out rather than shown as replacement characters,
|
||||||
// UTF-8 however the cut falls, so repairing it would misreport
|
// whether it is cut or not.
|
||||||
// what the sender delivered.
|
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
||||||
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
raw := make([]byte, bodyCap+808)
|
raw := make([]byte, bodyCap+808)
|
||||||
@@ -188,12 +189,21 @@ func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
|||||||
raw[i] = 0x80 | byte(i%0x40)
|
raw[i] = 0x80 | byte(i%0x40)
|
||||||
}
|
}
|
||||||
|
|
||||||
view := seedAndProject(t, string(raw))
|
for name, body := range map[string][]byte{
|
||||||
|
"cut": raw,
|
||||||
|
"whole": raw[:2048],
|
||||||
|
"NUL": []byte("text\x00text"),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
assert.True(t, view.BodyTruncated)
|
view := seedAndProject(t, string(body))
|
||||||
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
|
||||||
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
assert.True(t, view.Body.Binary)
|
||||||
assert.False(t, utf8.ValidString(view.Body))
|
assert.Empty(t, view.Body.Text)
|
||||||
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTrimPartialRune covers the distinction the cut repair
|
// TestTrimPartialRune covers the distinction the cut repair
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -11,43 +10,19 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resubmitOutcomeParam is the query parameter the resubmit POST
|
// The outcomes of a resubmit POST, as the notice codes its redirect
|
||||||
// redirects with and the event log page reads its banner from.
|
// carries. noticeFor holds the line each one shows.
|
||||||
const resubmitOutcomeParam = "resubmit"
|
|
||||||
|
|
||||||
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
|
||||||
// carries one of these fixed codes rather than a message, so nothing a
|
|
||||||
// client submits can reach the rendered page through it.
|
|
||||||
type resubmitOutcomeCode string
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// resubmitQueued reports that a new event was stored and its
|
// resubmitQueued reports that a new event was stored and its
|
||||||
// deliveries handed to the delivery engine.
|
// deliveries handed to the delivery engine.
|
||||||
resubmitQueued resubmitOutcomeCode = "queued"
|
resubmitQueued noticeCode = "resubmit-queued"
|
||||||
|
|
||||||
// resubmitNoTargets reports a source with no active targets. The
|
// resubmitNoTargets reports a source with no active targets. The
|
||||||
// new event is stored either way, exactly as a received event
|
// new event is stored either way, exactly as a received event
|
||||||
// with no targets is.
|
// with no targets is.
|
||||||
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resubmitOutcome returns the banner the event log page shows for an
|
|
||||||
// outcome code, and whether the resubmit was queued. An unrecognised
|
|
||||||
// code yields no banner.
|
|
||||||
func resubmitOutcome(code string) (string, bool) {
|
|
||||||
switch resubmitOutcomeCode(code) {
|
|
||||||
case resubmitQueued:
|
|
||||||
return "Resubmitted: a new event was created from the stored " +
|
|
||||||
"one and queued to every active target.", true
|
|
||||||
case resubmitNoTargets:
|
|
||||||
return "Resubmitted: a new event was created, but this " +
|
|
||||||
"source has no active targets, so nothing was queued.",
|
|
||||||
true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||||
// read as bytes rather than as a string so the copy is byte-identical
|
// read as bytes rather than as a string so the copy is byte-identical
|
||||||
// to what was received, whatever the payload's encoding.
|
// to what was received, whatever the payload's encoding.
|
||||||
@@ -99,7 +74,7 @@ func (h *Handlers) HandleEventResubmit() 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 {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,20 +95,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
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, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +122,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load event to resubmit", err)
|
h.serverError(w, r, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +130,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -170,8 +145,9 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// per-webhook database files — a sibling webhook's event is not in the
|
// per-webhook database files — a sibling webhook's event is not in the
|
||||||
// database being queried at all — and is there so the scoping survives
|
// database being queried at all — and is there so the scoping survives
|
||||||
// any future change that puts more than one webhook's events in one
|
// any future change that puts more than one webhook's events in one
|
||||||
// file. Going through Model applies GORM's soft-delete scope, which is
|
// file. A reaped event is not found because the retention reaper
|
||||||
// what stops a reaped event being resubmitted.
|
// deletes its row outright rather than marking it deleted; see
|
||||||
|
// deleteEvents in internal/database/retention.go.
|
||||||
func loadResubmitSource(
|
func loadResubmitSource(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
webhookID, eventID string,
|
webhookID, eventID string,
|
||||||
@@ -207,7 +183,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to query targets", err)
|
h.serverError(w, r, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,7 +201,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to store resubmitted event", err)
|
h.serverError(w, r, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,29 +221,5 @@ func (h *Handlers) queueResubmit(
|
|||||||
code = resubmitNoTargets
|
code = resubmitNoTargets
|
||||||
}
|
}
|
||||||
|
|
||||||
h.finishResubmit(w, r, webhook, code)
|
redirectToEventLog(w, r, webhook, code)
|
||||||
}
|
|
||||||
|
|
||||||
// finishResubmit redirects back to the event log the resubmit was
|
|
||||||
// triggered from, carrying the outcome code the page turns into a
|
|
||||||
// banner and the page number the form submitted.
|
|
||||||
func (h *Handlers) finishResubmit(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
webhook database.Webhook,
|
|
||||||
code resubmitOutcomeCode,
|
|
||||||
) {
|
|
||||||
dest := "/hook/" + webhook.ID + "/events?" +
|
|
||||||
resubmitOutcomeParam + "=" + string(code)
|
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
|
||||||
// this is a POST, and its query string is what logs and Referer
|
|
||||||
// headers record.
|
|
||||||
if page := pageOrFirst(
|
|
||||||
r.PostFormValue("page"),
|
|
||||||
); page > 1 {
|
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(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?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(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?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -17,10 +19,16 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
|
|||||||
s.log = log
|
s.log = log
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
|
||||||
// to the handlers_test package.
|
// of events to the handlers_test package.
|
||||||
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
||||||
|
|
||||||
|
// NewBodyViewForTest exposes newBodyView for use in the
|
||||||
|
// handlers_test package.
|
||||||
|
func NewBodyViewForTest(body []byte, size int64) BodyView {
|
||||||
|
return newBodyView("/hook/w/events/e", body, size)
|
||||||
|
}
|
||||||
|
|
||||||
// MaxRenderedResponseBytesForTest exposes the event log's
|
// MaxRenderedResponseBytesForTest exposes the event log's
|
||||||
// delivery response cap to the handlers_test package.
|
// delivery response cap to the handlers_test package.
|
||||||
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
||||||
@@ -34,6 +42,15 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
|||||||
// the handlers enforce rather than a number copied beside it.
|
// the handlers enforce rather than a number copied beside it.
|
||||||
const MaxTargetRetriesForTest = maxTargetRetries
|
const MaxTargetRetriesForTest = maxTargetRetries
|
||||||
|
|
||||||
|
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
|
||||||
|
// messages the webhook delete handler logs when a file of the event
|
||||||
|
// database is left on disk, so a test checking that one is absent
|
||||||
|
// checks for the handler's own wording.
|
||||||
|
const (
|
||||||
|
EventDBLeftMsgForTest = eventDBLeftMsg
|
||||||
|
SidecarLeftMsgForTest = sidecarLeftMsg
|
||||||
|
)
|
||||||
|
|
||||||
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func PageOrFirstForTest(s string) int {
|
func PageOrFirstForTest(s string) int {
|
||||||
@@ -65,7 +82,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, webhook, nil, page,
|
w, newRequestForTest(), webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
@@ -94,6 +111,14 @@ func FinishedByTargetForTest(
|
|||||||
return finishedByTarget(webhookDB, since)
|
return finishedByTarget(webhookDB, since)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRequestForTest is the request the helpers here pass on for
|
||||||
|
// callers that have none: it is used only to render the error page.
|
||||||
|
func newRequestForTest() *http.Request {
|
||||||
|
return httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
@@ -118,22 +143,20 @@ func (s *Handlers) RenderTemplateForTest(
|
|||||||
// BuildSlackTargetConfigForTest exposes
|
// BuildSlackTargetConfigForTest exposes
|
||||||
// buildSlackTargetConfig for use in the handlers_test package.
|
// buildSlackTargetConfig for use in the handlers_test package.
|
||||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
return s.buildSlackTargetConfig(ctx, targetURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
||||||
// for use in the handlers_test package, taking the form fields
|
// for use in the handlers_test package, taking the form fields
|
||||||
// an HTTP target's configuration is built from.
|
// an HTTP target's configuration is built from.
|
||||||
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL, headers, timeout string,
|
targetURL, headers, timeout string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildHTTPTargetConfig(w, r, targetFormInput{
|
return s.buildHTTPTargetConfig(ctx, targetFormInput{
|
||||||
URL: targetURL,
|
URL: targetURL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -143,9 +166,8 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
|||||||
// BuildDatabaseTargetConfigForTest exposes
|
// BuildDatabaseTargetConfigForTest exposes
|
||||||
// buildDatabaseTargetConfig for use in the handlers_test
|
// buildDatabaseTargetConfig for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
func BuildDatabaseTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, expiry)
|
return buildDatabaseTargetConfig(expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
+161
-41
@@ -10,9 +10,12 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"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/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -56,6 +59,7 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -64,6 +68,8 @@ type HandlersParams struct {
|
|||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Archives delivery.Archives
|
Archives delivery.Archives
|
||||||
SSRFGuard *delivery.Guard
|
SSRFGuard *delivery.Guard
|
||||||
|
Metrics *metrics.Set
|
||||||
|
Registry *prometheus.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -86,6 +92,15 @@ type Handlers struct {
|
|||||||
// is one delivery will actually attempt.
|
// is one delivery will actually attempt.
|
||||||
ssrf *delivery.Guard
|
ssrf *delivery.Guard
|
||||||
|
|
||||||
|
// renameMu makes the webhook edit, the target edit and target
|
||||||
|
// creation run one at a time, each held from loading the stored
|
||||||
|
// names through the archive rename, the save and any move back.
|
||||||
|
// Interleaved, one could rename an archive between another's
|
||||||
|
// rename and save, leaving the file named for one edit and the
|
||||||
|
// stored names from the other. An archive download holds it while
|
||||||
|
// it reads the stored names and opens the file they give.
|
||||||
|
renameMu sync.Mutex
|
||||||
|
|
||||||
// dummyVerifications counts the equivalent-cost verifications
|
// dummyVerifications counts the equivalent-cost verifications
|
||||||
// charged for usernames that do not exist. It exists so a test
|
// charged for usernames that do not exist. It exists so a test
|
||||||
// can prove that path runs without measuring wall-clock time.
|
// can prove that path runs without measuring wall-clock time.
|
||||||
@@ -94,10 +109,10 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates, and with any further files
|
// base, htmlheader, navbar and notice templates, and with any further
|
||||||
// the page includes. The page file must be listed first so that its
|
// files the page includes. The page file must be listed first so that
|
||||||
// root action ({{template "base" .}}) becomes the template set's entry
|
// its root action ({{template "base" .}}) becomes the template set's
|
||||||
// point.
|
// entry point.
|
||||||
func parsePageTemplate(
|
func parsePageTemplate(
|
||||||
pageFile string, included ...string,
|
pageFile string, included ...string,
|
||||||
) *template.Template {
|
) *template.Template {
|
||||||
@@ -106,6 +121,7 @@ func parsePageTemplate(
|
|||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
|
"notice.html",
|
||||||
}, included...)
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
@@ -129,19 +145,28 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.archives = params.Archives
|
s.archives = params.Archives
|
||||||
s.mtr = metrics.Default()
|
s.mtr = params.Metrics
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"settings.html": parsePageTemplate("settings.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_detail.html": parsePageTemplate(
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_detail.html", "webhook_stats.html", "event_body.html",
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
),
|
||||||
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
|
"source_logs.html": parsePageTemplate(
|
||||||
|
"source_logs.html", "event_body.html", "delivery_attempts.html",
|
||||||
|
),
|
||||||
|
"event_detail.html": parsePageTemplate(
|
||||||
|
"event_detail.html", "event_body.html", "delivery_attempts.html",
|
||||||
|
),
|
||||||
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
"error.html": parsePageTemplate("error.html"),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -153,6 +178,16 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleErrorPage returns a handler that answers every request with
|
||||||
|
// the error page for status. The router uses it for unknown paths, the
|
||||||
|
// CSRF middleware for a refused form, and each admin page route
|
||||||
|
// group's recoverer for a panic.
|
||||||
|
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderError(w, r, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -170,15 +205,78 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and sends a 500 response.
|
// serverError logs an error and answers with the 500 error page.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, msg string, err error,
|
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
}
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
// renderError answers with status and the error page: the normal
|
||||||
|
// layout, one fixed line explaining the status, and a link back to the
|
||||||
|
// webhook list, or to sign-in when nobody is signed in.
|
||||||
|
//
|
||||||
|
// It renders the page itself rather than through renderTemplate,
|
||||||
|
// whose own failure comes here. If the error page cannot render
|
||||||
|
// either, the answer is the same status in plain text: never a second
|
||||||
|
// attempt, and never a different status.
|
||||||
|
func (s *Handlers) renderError(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
|
// The page names the signed-in user, and some error pages are
|
||||||
|
// served outside the routes where NoCache runs.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
// No notice: one would say an action worked above a page saying
|
||||||
|
// the request failed.
|
||||||
|
data := s.pageData(r, map[string]any{
|
||||||
|
"Status": status,
|
||||||
|
"StatusText": http.StatusText(status),
|
||||||
|
"Message": errorPageText(status),
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := s.templates["error.html"].Execute(&buf, data)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to render error page", "error", err)
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = buf.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write error page", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorPageText is the line the error page shows for status. It is
|
||||||
|
// fixed per status, so the page tells the reader no more than the
|
||||||
|
// plain-text answers it replaced did.
|
||||||
|
func errorPageText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
return "The request could not be read."
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return "The request was refused. If it came from a form " +
|
||||||
|
"left open for a long time, reload the page and try " +
|
||||||
|
"again."
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return "There is nothing here. It may have been deleted, " +
|
||||||
|
"or the address may be wrong."
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
return "The server is busy. Please try again in a moment."
|
||||||
|
default: // http.StatusInternalServerError
|
||||||
|
return "Something went wrong on the server. Please try " +
|
||||||
|
"again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -192,6 +290,7 @@ type templateDataWrapper struct {
|
|||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Version string
|
Version string
|
||||||
|
Notice *notice
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +317,26 @@ func (s *Handlers) getUserInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a pre-parsed template with common
|
// renderTemplate renders a pre-parsed template with common
|
||||||
// data
|
// data and answers 200.
|
||||||
func (s *Handlers) renderTemplate(
|
func (s *Handlers) renderTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
pageTemplate string,
|
pageTemplate string,
|
||||||
data any,
|
data any,
|
||||||
|
) {
|
||||||
|
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTemplateStatus is renderTemplate answering with status, for a
|
||||||
|
// form shown again with an error. Call it instead of WriteHeader
|
||||||
|
// followed by renderTemplate: the status is written only once the page
|
||||||
|
// has rendered, so a failed render can still answer 500.
|
||||||
|
func (s *Handlers) renderTemplateStatus(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
pageTemplate string,
|
||||||
|
data any,
|
||||||
|
status int,
|
||||||
) {
|
) {
|
||||||
tmpl, ok := s.templates[pageTemplate]
|
tmpl, ok := s.templates[pageTemplate]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -231,14 +344,22 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(
|
||||||
|
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageData adds the fields the shared layout renders to a page's own
|
||||||
|
// data. The layout shows the notice, when there is one, above the
|
||||||
|
// page.
|
||||||
|
func (s *Handlers) pageData(
|
||||||
|
r *http.Request, data any, pageNotice *notice,
|
||||||
|
) any {
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -252,33 +373,34 @@ func (s *Handlers) renderTemplate(
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
s.executeTemplate(w, tmpl, m)
|
m["Notice"] = pageNotice
|
||||||
|
|
||||||
return
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper := templateDataWrapper{
|
return templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
Notice: pageNotice,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, tmpl, wrapper)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes status
|
||||||
// the response only once rendering has fully succeeded. Executing
|
// and the page to the response only once rendering has fully
|
||||||
// straight into the ResponseWriter commits a partial body and a 200
|
// succeeded. Executing straight into the ResponseWriter commits a
|
||||||
// status before a mid-render error can be reported, leaving no way
|
// partial body and the status before a mid-render error can be
|
||||||
// to serve a 500. Buffering makes a page's rendered size resident
|
// reported, leaving no way to serve a 500. Buffering makes a page's
|
||||||
// memory per concurrent viewer, so every page owes it a bound: the
|
// rendered size resident memory per concurrent viewer, so every page
|
||||||
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
// owes it a bound: the lists of events cap each stored body at
|
||||||
// this reason.
|
// maxRenderedBodyBytes for exactly this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
|
status int,
|
||||||
) {
|
) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
@@ -287,15 +409,13 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
_, err = buf.WriteTo(w)
|
_, err = buf.WriteTo(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -20,6 +21,7 @@ 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/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
@@ -54,19 +56,28 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
|
|||||||
// recordingArchives is a delivery.Archives that records what it
|
// recordingArchives is a delivery.Archives that records what it
|
||||||
// was asked to do, so a test can prove that a deletion or rename
|
// was asked to do, so a test can prove that a deletion or rename
|
||||||
// path reached the delivery engine. After FailRenames, every
|
// path reached the delivery engine. After FailRenames, every
|
||||||
// rename fails with the given error.
|
// rename of that target fails with the given error. After
|
||||||
|
// BlockNextRename, the next rename is recorded and then waits.
|
||||||
type recordingArchives struct {
|
type recordingArchives struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
evicted []string
|
evicted []string
|
||||||
evictedTargets []string
|
evictedTargets []string
|
||||||
renames []archiveRename
|
renames []archiveRename
|
||||||
renameErr error
|
renameErrs map[string]error
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// errInjectedRename is the failure a test hands FailRenames.
|
// errInjectedRename is the failure a test hands FailRenames.
|
||||||
var errInjectedRename = errors.New("injected rename failure")
|
var errInjectedRename = errors.New("injected rename failure")
|
||||||
|
|
||||||
// archiveRename is one recorded RenameArchive call.
|
// errNameTaken is what the delivery engine returns when a file
|
||||||
|
// already has an archive's new name, here archive-taken.db.
|
||||||
|
var errNameTaken = fmt.Errorf(
|
||||||
|
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveRename is one recorded Rename call.
|
||||||
type archiveRename struct {
|
type archiveRename struct {
|
||||||
TargetID string
|
TargetID string
|
||||||
WebhookName string
|
WebhookName string
|
||||||
@@ -87,27 +98,54 @@ func (r *recordingArchives) EvictTarget(targetID string) {
|
|||||||
r.evictedTargets = append(r.evictedTargets, targetID)
|
r.evictedTargets = append(r.evictedTargets, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingArchives) RenameArchive(
|
func (r *recordingArchives) Rename(
|
||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
r.renames = append(r.renames, archiveRename{
|
r.renames = append(r.renames, archiveRename{
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
WebhookName: webhookName,
|
WebhookName: webhookName,
|
||||||
TargetName: targetName,
|
TargetName: targetName,
|
||||||
})
|
})
|
||||||
|
err := r.renameErrs[targetID]
|
||||||
|
entered, release := r.entered, r.release
|
||||||
|
r.entered, r.release = nil, nil
|
||||||
|
|
||||||
return r.renameErr
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if entered != nil {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FailRenames makes every later rename fail with err.
|
// BlockNextRename makes the next rename, once recorded, wait until
|
||||||
func (r *recordingArchives) FailRenames(err error) {
|
// the returned release is called. The returned channel is closed
|
||||||
|
// when that rename starts waiting.
|
||||||
|
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.entered, r.release = entered, release
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
return entered, func() { close(release) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailRenames makes every later rename of targetID fail with err.
|
||||||
|
func (r *recordingArchives) FailRenames(targetID string, err error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
r.renameErr = err
|
if r.renameErrs == nil {
|
||||||
|
r.renameErrs = map[string]error{}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.renameErrs[targetID] = err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evicted returns a copy of the recorded webhook ids.
|
// Evicted returns a copy of the recorded webhook ids.
|
||||||
@@ -143,22 +181,40 @@ func (r *recordingArchives) Renames() []archiveRename {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestApp returns an app whose RequireStart fails the test when
|
||||||
|
// starting takes longer than fx's default start timeout of 15s. That
|
||||||
|
// limit catches a start that hangs, not a busy host: measured with make
|
||||||
|
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
|
||||||
|
// package's starts took 0.49s.
|
||||||
func newTestApp(
|
func newTestApp(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
targets ...any,
|
targets ...any,
|
||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestAppWithConfig(
|
||||||
|
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||||
|
func newTestAppWithConfig(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
targets ...any,
|
||||||
|
) *fxtest.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return fxtest.New(
|
return 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,
|
||||||
func() *config.Config {
|
func() *config.Config { return cfg },
|
||||||
return &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
@@ -175,6 +231,8 @@ func newTestApp(
|
|||||||
func(r *recordingArchives) delivery.Archives {
|
func(r *recordingArchives) delivery.Archives {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
|
metrics.NewRegistry,
|
||||||
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -256,16 +314,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://93.184.216.34/services/T00/B00/xxx",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Empty(t, errMsg)
|
||||||
assert.Contains(t, cfg, "webhookUrl")
|
assert.Contains(t, cfg, "webhookUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,17 +333,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://169.254.169.254/latest/meta-data/",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://169.254.169.254/latest/meta-data/",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, errMsg, "Invalid target URL")
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderTemplate(t *testing.T) {
|
func TestRenderTemplate(t *testing.T) {
|
||||||
@@ -373,38 +423,35 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.NotContains(
|
||||||
t, "Internal server error\n", w.Body.String(),
|
t, w.Body.String(), partialPageMarker,
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(), "500 Internal Server Error",
|
||||||
|
"a failed render must answer with the error page",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// Empty expiry: the keep-forever default, empty config.
|
// Empty expiry: the keep-forever default, empty config.
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
|
|
||||||
// Explicit never is stored as config.
|
// Explicit never is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||||
|
|
||||||
// A positive duration is stored as config.
|
// A positive duration is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,22 +460,14 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
|
||||||
|
|
||||||
require.Error(t, err, "expiry %q", bad)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, cfg)
|
assert.Contains(
|
||||||
assert.Equal(
|
t, errMsg, "Invalid archive expiry",
|
||||||
t, http.StatusBadRequest, w.Code,
|
"expiry %q should be refused", bad,
|
||||||
"expiry %q should be rejected with 400", bad,
|
|
||||||
)
|
)
|
||||||
|
assert.Empty(t, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleMetrics returns the Prometheus scrape handler for the
|
||||||
|
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
|
||||||
|
// runtime and process collectors register on. It is what
|
||||||
|
// promhttp.Handler builds for the global default registry, including
|
||||||
|
// the promhttp_metric_handler_* series that count scrapes, pointed at
|
||||||
|
// that registry instead.
|
||||||
|
func (s *Handlers) HandleMetrics() http.HandlerFunc {
|
||||||
|
reg := s.params.Registry
|
||||||
|
|
||||||
|
return promhttp.InstrumentMetricHandler(
|
||||||
|
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
|
||||||
|
).ServeHTTP
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// noticeParam is the query parameter an action's redirect carries its
|
||||||
|
// notice code in.
|
||||||
|
const noticeParam = "notice"
|
||||||
|
|
||||||
|
// noticeCode names one of the fixed lines noticeFor knows. An action
|
||||||
|
// redirects with the code rather than the line, so nothing a client
|
||||||
|
// puts in the URL reaches the page: a code noticeFor does not know
|
||||||
|
// shows nothing.
|
||||||
|
type noticeCode string
|
||||||
|
|
||||||
|
// The codes of the actions on the webhook pages and of signing out.
|
||||||
|
// Replay's codes, with the reasons a replay can be refused, and
|
||||||
|
// resubmit's codes are defined beside those actions.
|
||||||
|
const (
|
||||||
|
webhookCreated noticeCode = "webhook-created"
|
||||||
|
webhookSaved noticeCode = "webhook-saved"
|
||||||
|
webhookDeleted noticeCode = "webhook-deleted"
|
||||||
|
entrypointAdded noticeCode = "entrypoint-added"
|
||||||
|
entrypointSaved noticeCode = "entrypoint-saved"
|
||||||
|
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||||
|
entrypointActivated noticeCode = "entrypoint-activated"
|
||||||
|
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||||
|
targetAdded noticeCode = "target-added"
|
||||||
|
targetSaved noticeCode = "target-saved"
|
||||||
|
targetDeleted noticeCode = "target-deleted"
|
||||||
|
targetActivated noticeCode = "target-activated"
|
||||||
|
targetDeactivated noticeCode = "target-deactivated"
|
||||||
|
signedOut noticeCode = "signed-out"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notice is the line templates/notice.html shows above a page to say
|
||||||
|
// what an action did.
|
||||||
|
type notice struct {
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// Failed shows the line as an error: the action was refused.
|
||||||
|
Failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeFor returns the notice the request's URL names, or nil when it
|
||||||
|
// names none or an unknown code.
|
||||||
|
func noticeFor(r *http.Request) *notice {
|
||||||
|
n, ok := map[noticeCode]notice{
|
||||||
|
webhookCreated: {Text: "Webhook created."},
|
||||||
|
webhookSaved: {Text: "Webhook saved."},
|
||||||
|
webhookDeleted: {Text: "Webhook deleted."},
|
||||||
|
entrypointAdded: {Text: "Entrypoint added."},
|
||||||
|
entrypointSaved: {Text: "Entrypoint description saved."},
|
||||||
|
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||||
|
entrypointActivated: {Text: "Entrypoint activated."},
|
||||||
|
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||||
|
targetAdded: {Text: "Target added."},
|
||||||
|
targetSaved: {Text: "Target saved."},
|
||||||
|
targetDeleted: {Text: "Target deleted."},
|
||||||
|
targetActivated: {Text: "Target activated."},
|
||||||
|
targetDeactivated: {Text: "Target deactivated."},
|
||||||
|
signedOut: {Text: "Signed out."},
|
||||||
|
|
||||||
|
replayQueued: {
|
||||||
|
Text: "Replay queued: a new delivery was created " +
|
||||||
|
"against the target's current configuration.",
|
||||||
|
},
|
||||||
|
replayTargetDeleted: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"has been deleted. Recreate the target, then replay.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayTargetMissing: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"no longer exists.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayTargetInactive: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"is deactivated. Activate it, then replay.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayNotTerminal: {
|
||||||
|
Text: "Not replayed: this delivery has not finished yet.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayInFlight: {
|
||||||
|
Text: "Not replayed: a delivery of this event to this " +
|
||||||
|
"target is already in flight.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
resubmitQueued: {
|
||||||
|
Text: "Resubmitted: a new event was created from the " +
|
||||||
|
"stored one and queued to every active target.",
|
||||||
|
},
|
||||||
|
resubmitNoTargets: {
|
||||||
|
Text: "Resubmitted: a new event was created, but this " +
|
||||||
|
"source has no active targets, so nothing was queued.",
|
||||||
|
},
|
||||||
|
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// withNotice returns path with code added as its notice.
|
||||||
|
func withNotice(path string, code noticeCode) string {
|
||||||
|
return path + "?" + noticeParam + "=" + string(code)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() 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)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
r.Context(),
|
|
||||||
w,
|
w,
|
||||||
|
r,
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes a
|
// to display on the profile page. On an internal failure it writes the
|
||||||
// 500 response itself and returns handled=false, signalling the caller
|
// error page itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
ctx context.Context,
|
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(ctx)
|
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusServiceUnavailable)
|
||||||
w,
|
|
||||||
"The server is busy verifying credentials. "+
|
|
||||||
"Please try again.",
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to load user for password change", err,
|
w, r, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to verify password", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to hash new password", err)
|
h.serverError(w, r, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update password", err)
|
h.serverError(w, r, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get session", err)
|
h.serverError(w, r, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
h.renderError(w, r, http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess)
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &log, &cfg, &sess, &h)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF())
|
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// recentEventColumns is the recent events list's projection. It
|
// recentEventColumns is the recent events list's projection. It
|
||||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
|
||||||
// and reads its size from body_bytes, recorded when the event was
|
// does, and its size from body_bytes, recorded when the event was
|
||||||
// stored.
|
// stored.
|
||||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, body_bytes"
|
"resubmitted_from_id, body_bytes, " +
|
||||||
|
"substr(cast(body as blob), 1, ?) AS body"
|
||||||
|
|
||||||
// recentAttemptColumns is the part of a recorded attempt the list
|
// recentAttemptColumns is the part of a recorded attempt the list
|
||||||
// uses. The event log's deliveryResultColumns also reads response
|
// uses. The event log's deliveryResultColumns also reads response
|
||||||
@@ -50,6 +51,9 @@ type RecentEventView struct {
|
|||||||
// unless the webhook has exactly one HTTP target.
|
// unless the webhook has exactly one HTTP target.
|
||||||
Status string
|
Status string
|
||||||
StatusClass string
|
StatusClass string
|
||||||
|
|
||||||
|
// Body is what the row shows when it is expanded.
|
||||||
|
Body BodyView
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentEventRow is one row of recentEventColumns.
|
// recentEventRow is one row of recentEventColumns.
|
||||||
@@ -60,6 +64,7 @@ type recentEventRow struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
BodyBytes uint64
|
BodyBytes uint64
|
||||||
|
Body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||||
@@ -100,7 +105,7 @@ func loadRecentEvents(
|
|||||||
var rows []recentEventRow
|
var rows []recentEventRow
|
||||||
|
|
||||||
err := webhookDB.Model(&database.Event{}).
|
err := webhookDB.Model(&database.Event{}).
|
||||||
Select(recentEventColumns).
|
Select(recentEventColumns, maxRenderedBodyBytes).
|
||||||
Where("webhook_id = ?", webhookID).
|
Where("webhook_id = ?", webhookID).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Limit(recentEventLimit).
|
Limit(recentEventLimit).
|
||||||
@@ -145,7 +150,7 @@ func loadRecentEvents(
|
|||||||
views := make([]RecentEventView, len(rows))
|
views := make([]RecentEventView, len(rows))
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
views[i] = rows[i].view(
|
views[i] = rows[i].view(
|
||||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,14 +187,20 @@ func loadRecentAttempts(
|
|||||||
return byDelivery, nil
|
return byDelivery, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row for rendering. deliveries is the
|
// view projects a loaded row of the webhook's events for
|
||||||
// event's deliveries, oldest first, and attempts their recorded
|
// rendering. deliveries is the event's deliveries, oldest first,
|
||||||
// attempts keyed by delivery ID.
|
// and attempts their recorded attempts keyed by delivery ID.
|
||||||
func (r *recentEventRow) view(
|
func (r *recentEventRow) view(
|
||||||
|
webhookID string,
|
||||||
deliveries []database.Delivery,
|
deliveries []database.Delivery,
|
||||||
attempts map[string][]recentAttemptRow,
|
attempts map[string][]recentAttemptRow,
|
||||||
statusTargetID string,
|
statusTargetID string,
|
||||||
) RecentEventView {
|
) RecentEventView {
|
||||||
|
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
|
||||||
|
body := newBodyView(
|
||||||
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
|
||||||
|
)
|
||||||
|
|
||||||
v := RecentEventView{
|
v := RecentEventView{
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
@@ -197,6 +208,7 @@ func (r *recentEventRow) view(
|
|||||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
Size: humanize.Bytes(r.BodyBytes),
|
Size: humanize.Bytes(r.BodyBytes),
|
||||||
ProcessingTime: processingTime(deliveries, attempts),
|
ProcessingTime: processingTime(deliveries, attempts),
|
||||||
|
Body: body,
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -301,6 +302,73 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
|
||||||
|
// links to its event's own page and expands to show its body, and
|
||||||
|
// that only the newest row starts expanded.
|
||||||
|
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
older := f.event(
|
||||||
|
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
|
||||||
|
)
|
||||||
|
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
|
||||||
|
for _, e := range []*database.Event{older, newer} {
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
|
||||||
|
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
||||||
|
|
||||||
|
open := strings.Index(body, " data-open>")
|
||||||
|
newerBody := strings.Index(body, ""which": "newer"")
|
||||||
|
olderBody := strings.Index(body, ""which": "older"")
|
||||||
|
|
||||||
|
assert.Less(t, open, newerBody, "the newest row is not the open one")
|
||||||
|
assert.Less(t, newerBody, olderBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
|
||||||
|
// the cap is shown whole and pretty-printed, and a larger one only
|
||||||
|
// its first bodyCap bytes, as received, with links to the whole
|
||||||
|
// body on the event's page and to the download.
|
||||||
|
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// A JSON document of n bytes.
|
||||||
|
document := func(n int) string {
|
||||||
|
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
whole := f.event(
|
||||||
|
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
|
||||||
|
)
|
||||||
|
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(body, "{\n "pad": "))
|
||||||
|
assert.Contains(t, body, "{"pad":"xxx")
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
|
||||||
|
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
|
||||||
|
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||||
// body's size in bytes, not characters, with the event it stores.
|
// body's size in bytes, not characters, with the event it stores.
|
||||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notSet is what the Settings page shows for a value that is empty.
|
||||||
|
const notSet = "not set"
|
||||||
|
|
||||||
|
// settingRow is one line of the Settings page: an environment
|
||||||
|
// variable, what it controls, and the value the server loaded for it.
|
||||||
|
type settingRow struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettings returns a handler for the read-only Settings page,
|
||||||
|
// which lists the configuration the server started with.
|
||||||
|
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||||
|
"Settings": settingRows(h.params.Config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingRows lists every field of cfg under the environment variable
|
||||||
|
// it is read from, with the description the README's configuration
|
||||||
|
// table gives it (less its pointers to other README sections), in the
|
||||||
|
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
|
||||||
|
// their values never reach the page: only whether they are set.
|
||||||
|
func settingRows(cfg *config.Config) []settingRow {
|
||||||
|
metricsUsername := cfg.MetricsUsername
|
||||||
|
if metricsUsername == "" {
|
||||||
|
metricsUsername = notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return []settingRow{
|
||||||
|
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||||
|
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||||
|
{
|
||||||
|
"BIND_ADDRESS",
|
||||||
|
"IP address the HTTP listener binds. Loopback by default, " +
|
||||||
|
"so the cleartext listener is not published on every " +
|
||||||
|
"interface. The Docker image ships 0.0.0.0 instead",
|
||||||
|
cfg.BindAddress,
|
||||||
|
},
|
||||||
|
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
|
||||||
|
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
|
||||||
|
{
|
||||||
|
"METRICS_USERNAME",
|
||||||
|
"Basic auth username for /metrics. Must be set together " +
|
||||||
|
"with METRICS_PASSWORD; one without the other fails " +
|
||||||
|
"startup",
|
||||||
|
metricsUsername,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"METRICS_PASSWORD",
|
||||||
|
"Basic auth password for /metrics. Must be set together " +
|
||||||
|
"with METRICS_USERNAME; one without the other fails " +
|
||||||
|
"startup",
|
||||||
|
setOrNotSet(cfg.MetricsPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SENTRY_DSN",
|
||||||
|
"Sentry error reporting DSN. Unset leaves error reporting " +
|
||||||
|
"off; a value the Sentry SDK cannot parse fails startup " +
|
||||||
|
"rather than serving with reporting silently off",
|
||||||
|
setOrNotSet(cfg.SentryDSN),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
"How often the retention reaper and archive sweeper run " +
|
||||||
|
"(Go duration, must be positive). A value that does " +
|
||||||
|
"not parse, or is zero or negative, fails startup",
|
||||||
|
cfg.RetentionSweepInterval.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SESSION_IDLE_TIMEOUT",
|
||||||
|
"Idle session timeout (Go duration)",
|
||||||
|
cfg.SessionIdleTimeout.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
"Receiver requests/minute per IP per entrypoint " +
|
||||||
|
"(10x that per IP across the route)",
|
||||||
|
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TRUSTED_PROXIES",
|
||||||
|
"CIDRs whose forwarded headers are trusted. A set value " +
|
||||||
|
"replaces the default. If any client can reach webhooker, " +
|
||||||
|
"or the proxy in front of it, from an RFC 1918 source " +
|
||||||
|
"address, set it to the proxy's address alone",
|
||||||
|
cidrList(cfg.TrustedProxies),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ALLOWED_EGRESS_CIDRS",
|
||||||
|
"CIDRs that delivery targets may reach despite the " +
|
||||||
|
"SSRF blocklist",
|
||||||
|
cidrList(cfg.AllowedEgressCIDRs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||||
|
// has a value, never the value itself.
|
||||||
|
func setOrNotSet(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return "set"
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidrList renders a CIDR list setting for the Settings page.
|
||||||
|
func cidrList(prefixes []netip.Prefix) string {
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||||
|
// and returns the value it shows for each variable name, plus the
|
||||||
|
// whole page.
|
||||||
|
func settingsShown(
|
||||||
|
t *testing.T, cfg *config.Config,
|
||||||
|
) (map[string]string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/settings", nil,
|
||||||
|
)
|
||||||
|
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSettings().ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
row := regexp.MustCompile(
|
||||||
|
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
shown := map[string]string{}
|
||||||
|
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||||
|
shown[match[1]] = html.UnescapeString(match[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return shown, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
|
||||||
|
// only one of the three set in one of the content tests, so each
|
||||||
|
// row is checked against its own field.
|
||||||
|
cfg := &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Debug: true,
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
MetricsUsername: "scraper",
|
||||||
|
MetricsPassword: "",
|
||||||
|
Port: 9123,
|
||||||
|
SentryDSN: "",
|
||||||
|
BindAddress: "192.0.2.10",
|
||||||
|
RetentionSweepInterval: 17 * time.Minute,
|
||||||
|
SessionIdleTimeout: 3 * time.Hour,
|
||||||
|
ReceiverRateLimit: 77,
|
||||||
|
TrustedProxies: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.1.0.0/16"),
|
||||||
|
},
|
||||||
|
AllowedEgressCIDRs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.5.0/24"),
|
||||||
|
netip.MustParsePrefix("fd00::/8"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||||
|
"PORT": "9123",
|
||||||
|
"BIND_ADDRESS": "192.0.2.10",
|
||||||
|
"DATA_DIR": cfg.DataDir,
|
||||||
|
"DEBUG": "true",
|
||||||
|
"METRICS_USERNAME": "scraper",
|
||||||
|
"METRICS_PASSWORD": "not set",
|
||||||
|
"SENTRY_DSN": "not set",
|
||||||
|
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||||
|
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||||
|
"RECEIVER_RATE_LIMIT": "77",
|
||||||
|
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||||
|
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||||
|
}, shown)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `href="/settings"`,
|
||||||
|
"the navigation bar links to the page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const metricsPassword = "metrics-password-1f9a"
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
MetricsPassword: metricsPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||||
|
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
||||||
|
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
||||||
|
assert.NotContains(t, body, metricsPassword)
|
||||||
|
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||||
|
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
sentryKey = "dsnkey7c2e"
|
||||||
|
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||||
|
)
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
SentryDSN: sentryDSN,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||||
|
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
||||||
|
assert.NotContains(t, body, sentryKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// submitCreateForm posts the new webhook form and returns the
|
||||||
|
// recorder.
|
||||||
|
func submitCreateForm(
|
||||||
|
env *sourceTestEnv, form url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNothingCreated checks that the main database holds no webhook,
|
||||||
|
// entrypoint or target.
|
||||||
|
func assertNothingCreated(t *testing.T, db *database.Database) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, model := range []any{
|
||||||
|
&database.Webhook{}, &database.Entrypoint{}, &database.Target{},
|
||||||
|
} {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Model(model).Count(&count).Error)
|
||||||
|
assert.Zerof(t, count, "%T rows were created", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new
|
||||||
|
// webhook form with the HTTP target URL filled in or empty, and with
|
||||||
|
// the archive checkbox off or on with each pruning choice. The webhook
|
||||||
|
// gets an HTTP target only for a URL and a database target only for a
|
||||||
|
// checked archive. The pruning choice is always submitted, as the
|
||||||
|
// browser submits it while it is hidden, and is ignored when archive
|
||||||
|
// is off.
|
||||||
|
func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
// Each value the archive pruning choice submits, after an empty
|
||||||
|
// one that stands for the archive checkbox left off.
|
||||||
|
expiries := []string{
|
||||||
|
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, httpURL := range []string{"", editOriginalURL} {
|
||||||
|
for _, expiry := range expiries {
|
||||||
|
name := "url=" + httpURL + " archive=" + expiry
|
||||||
|
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", name)
|
||||||
|
form.Set("http_url", httpURL)
|
||||||
|
form.Set("archive_expiry", "720h")
|
||||||
|
|
||||||
|
if expiry != "" {
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", expiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("name = ?", name).First(&webhook).Error)
|
||||||
|
|
||||||
|
byType := map[database.TargetType]database.Target{}
|
||||||
|
for _, target := range targetsForWebhook(t, env.db, webhook.ID) {
|
||||||
|
byType[target.Type] = target
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCount := 0
|
||||||
|
|
||||||
|
if httpURL != "" {
|
||||||
|
wantCount++
|
||||||
|
|
||||||
|
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
|
||||||
|
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
|
||||||
|
byType[database.TargetTypeHTTP].Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiry != "" {
|
||||||
|
wantCount++
|
||||||
|
|
||||||
|
assert.Equal(t, "Archive",
|
||||||
|
byType[database.TargetTypeDatabase].Name)
|
||||||
|
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
|
||||||
|
byType[database.TargetTypeDatabase].Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, byType, wantCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the
|
||||||
|
// new webhook form for an invalid HTTP target URL and for an invalid
|
||||||
|
// retention, each with archive on. Nothing is created, and the form
|
||||||
|
// comes back with the reason and every value entered: name,
|
||||||
|
// description, retention, URL, the checked archive box and the pruning
|
||||||
|
// choice.
|
||||||
|
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const badURL = "Invalid target URL"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
retention string
|
||||||
|
httpURL string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"blocked url", "7", editBlockedURL, badURL},
|
||||||
|
{"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL},
|
||||||
|
{"bad retention", "-5", editOriginalURL, "Retention must be"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "kept name")
|
||||||
|
form.Set("description", "kept description")
|
||||||
|
form.Set("retention_days", tc.retention)
|
||||||
|
form.Set("http_url", tc.httpURL)
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", "2160h")
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
assert.Contains(t, page, tc.reason)
|
||||||
|
assert.Contains(t, page, `value="kept name"`)
|
||||||
|
assert.Contains(t, page, `>kept description</textarea>`)
|
||||||
|
assert.Contains(t, page, `value="`+tc.retention+`"`)
|
||||||
|
assert.Contains(t, page,
|
||||||
|
`value="`+html.EscapeString(tc.httpURL)+`"`)
|
||||||
|
assert.Contains(t, page, `name="archive" value="on" checked`)
|
||||||
|
assert.Contains(t, page, `x-data="collapsible" data-open`)
|
||||||
|
assert.Contains(t, page, `<option value="2160h" selected>`)
|
||||||
|
|
||||||
|
assertNothingCreated(t, env.db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errInjectedTargetCreate is the failure a test makes the insert of a
|
||||||
|
// target report.
|
||||||
|
var errInjectedTargetCreate = errors.New("injected target create failure")
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
|
||||||
|
// inserting a target fail after the webhook and its entrypoint were
|
||||||
|
// inserted, and checks that neither is left behind.
|
||||||
|
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Callback().Create().
|
||||||
|
Before("gorm:create").
|
||||||
|
Register("test:fail_target_create", func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == "targets" {
|
||||||
|
_ = tx.AddError(errInjectedTargetCreate)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "rolled back")
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", "never")
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
assertNothingCreated(t, env.db)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -80,12 +82,17 @@ func seedTarget(
|
|||||||
// from a delete statement.
|
// from a delete statement.
|
||||||
var errInjectedDelete = errors.New("injected delete failure")
|
var errInjectedDelete = errors.New("injected delete failure")
|
||||||
|
|
||||||
// seedEntrypoint inserts an entrypoint for a webhook.
|
// errInjectedSave is the failure failSaveOnTable reports from a
|
||||||
|
// save of an existing row.
|
||||||
|
var errInjectedSave = errors.New("injected save failure")
|
||||||
|
|
||||||
|
// seedEntrypoint inserts an active entrypoint for a webhook and
|
||||||
|
// returns it.
|
||||||
func seedEntrypoint(
|
func seedEntrypoint(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.Database,
|
db *database.Database,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) {
|
) *database.Entrypoint {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
ep := &database.Entrypoint{
|
||||||
@@ -98,6 +105,8 @@ func seedEntrypoint(
|
|||||||
t,
|
t,
|
||||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return ep
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRows counts the live (not soft-deleted) rows of a model
|
// countRows counts the live (not soft-deleted) rows of a model
|
||||||
@@ -147,6 +156,28 @@ func failDeleteOnTable(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// failSaveOnTable is failDeleteOnTable for saves: every update of
|
||||||
|
// an existing row in the named table fails.
|
||||||
|
func failSaveOnTable(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
table string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Callback().Update().
|
||||||
|
Before("gorm:update").
|
||||||
|
Register(
|
||||||
|
"test:fail_save_"+table,
|
||||||
|
func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == table {
|
||||||
|
_ = tx.AddError(errInjectedSave)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// archivePathFor returns the archive database path the
|
// archivePathFor returns the archive database path the
|
||||||
// delivery engine would use for a database target: beside the
|
// delivery engine would use for a database target: beside the
|
||||||
// webhook's event database in the data directory.
|
// webhook's event database in the data directory.
|
||||||
@@ -414,7 +445,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
assert.Equal(
|
||||||
|
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
@@ -438,6 +471,121 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
|
||||||
|
// database file is removed but a sidecar beside it is not, the
|
||||||
|
// operator is told the events are gone, never that the event
|
||||||
|
// database file is still there.
|
||||||
|
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
mgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
logs := new(bytes.Buffer)
|
||||||
|
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
||||||
|
// Closing removes the sidecars, so the -wal below is the only
|
||||||
|
// one there.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
// A non-empty directory in the -wal file's place, which
|
||||||
|
// os.Remove cannot remove whoever runs the test.
|
||||||
|
eventDBPath := mgr.DBPath(wh.ID)
|
||||||
|
require.NoError(t, os.MkdirAll(
|
||||||
|
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
|
||||||
|
))
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/hook/"+wh.ID+"/delete",
|
||||||
|
cookies,
|
||||||
|
map[string]string{paramSourceID: wh.ID},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.NoFileExists(t, eventDBPath)
|
||||||
|
assert.Contains(t, logs.String(), "its events are gone")
|
||||||
|
assert.Contains(t, logs.String(), eventDBPath+"-wal")
|
||||||
|
assert.NotContains(
|
||||||
|
t, logs.String(), handlers.EventDBLeftMsgForTest,
|
||||||
|
"the events are gone, so the operator must not be told "+
|
||||||
|
"the event database file survived",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
|
||||||
|
// event database file itself cannot be removed, the operator is told
|
||||||
|
// it is still on disk, never that its events are gone.
|
||||||
|
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
mgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
logs := new(bytes.Buffer)
|
||||||
|
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
// A non-empty directory in the database file's place, which
|
||||||
|
// os.Remove cannot remove whoever runs the test.
|
||||||
|
eventDBPath := mgr.DBPath(wh.ID)
|
||||||
|
require.NoError(t, os.MkdirAll(
|
||||||
|
filepath.Join(eventDBPath, "keep"), 0o700,
|
||||||
|
))
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/hook/"+wh.ID+"/delete",
|
||||||
|
cookies,
|
||||||
|
map[string]string{paramSourceID: wh.ID},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, logs.String(), "event database file is still on disk",
|
||||||
|
)
|
||||||
|
assert.Contains(t, logs.String(), eventDBPath)
|
||||||
|
assert.NotContains(
|
||||||
|
t, logs.String(), handlers.SidecarLeftMsgForTest,
|
||||||
|
"the database file is still on disk, so the operator must "+
|
||||||
|
"not be told its events are gone",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
||||||
// database target releases that target's archive writer and no
|
// database target releases that target's archive writer and no
|
||||||
// other: the webhook's other database target keeps its own.
|
// other: the webhook's other database target keeps its own.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user