2 Commits
Author SHA1 Message Date
clawbot 16ad6b5680 Reformat the Markdown with make fmt
check / check (push) Successful in 3m21s
Output of make fmt alone, so make fmt-check starts green. It changes
line wrapping, table padding, list markers and emphasis markers, and no
words. REPO_POLICIES.md was already formatted and is unchanged.

Model: opus-5-5
2026-10-03 04:25:13 +00:00
clawbot 9e2a51feaf Format the Markdown with prettier in make fmt and make fmt-check (closes #215)
make fmt and make fmt-check covered only Go, so Markdown formatting was
checked by eye. prettier, pinned in package.json and yarn.lock beside
ESLint and installed by the same js-deps stage, now formats every
Markdown file with the settings in .prettierrc (4-space tabs, prose
wrapped). It runs only in Docker: make fmt writes the formatted files
back from the markdown-output stage, and make fmt-check and the image
build run the markdown-check stage. The Dockerfile's lint stage now
runs the gofmt check itself, since make fmt-check needs a docker daemon.

Model: opus-5-5
2026-10-03 04:25:13 +00:00
107 changed files with 2175 additions and 2594 deletions
+29 -83
View File
@@ -1,84 +1,30 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with # .git is sent so the build can derive the version it stamps into the binary
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # (script/version). Its config, which can hold a remote URL carrying a
# `/` and an unprefixed pattern is anchored at the context root. Every # credential and which `git describe` does not need, is left out of a
# depth-independent pattern therefore needs `**/`, or `config/.env` and # directory context. A context sent as a tar is not filtered by this file, so
# `certs/server.key` still ship while this file reads as solved. Only # it carries .git/config unless its sender leaves it out.
# genuinely root-anchored entries go unprefixed. Never transplant these .git/config
# into .gitignore, where `**/` is wrong.
# No tracked file may be listed here: git in the build would see it as
# deleted and mark the version -dirty.
# #
# Matching is case-sensitive, so secrets use character ranges rather # .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# than an ALL-CAPS twin, which would still miss `Server.Key`. # that keeps the check stages from replaying a cached pass. See the lint
# # stage of the Dockerfile.
# Extend with this repo's own host-built artifacts, written anchored: bin/
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# deletes the package directory from the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js
# .git is sent without its config. Without a VERSION build argument the # The js-deps stage installs ESLint and prettier; a host copy would overwrite
# stage that compiles runs `git describe --tags --always` on .git, which # them at the `COPY . .` of the stages built on it.
# does not need .git/config; that file can hold a credential, such as a node_modules/
# password in a remote URL or the token the CI checkout step stores there. .env
# Each submodule keeps a config with the same exposure in its git directory .env.*
# under .git/modules/, nested again for a submodule's own submodules, or in *.db
# its own .git directory when it keeps one. *.sqlite
# KNOWN GAP: a submodule whose name has a `config` segment (`config`, *.sqlite3
# `deploy/config`, `config/lib`) loses its whole git directory, because .DS_Store
# `**/.git/modules/**/config` also matches that segment's directory .idea/
# under .git/modules/. Go's version stamping then fails the build; .vscode/
# nothing leaks. Name such a submodule without that segment: tmp/
# `git submodule add --name`. temp/
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own host-built artifacts: the binary `make build`
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the
# build extracts its own).
/bin
/static/js/alpine.min.js
# SQLite databases, which hold the session key and webhook payloads, at
# any depth.
**/*.db
**/*.sqlite
**/*.sqlite3
-3
View File
@@ -10,6 +10,3 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
[*.go]
indent_style = tab
+32 -4
View File
@@ -1,9 +1,37 @@
name: check name: check
on: [push]
on:
push:
branches:
- '**'
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 - name: Checkout
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
- run: script/cibuild with:
# The superseded-status step needs history to walk ancestors (it
# aborts on a shallow clone).
fetch-depth: 0
- name: Mark superseded run statuses
# Gitea cancels the in-flight run when another commit is pushed to the
# same branch and records the cancellation as `failure`, so a commit
# that was never tested reads as a test result. The script rewrites
# those statuses to say what happened. See its header for why the
# state stays `failure` and not `skipped`.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: script/ci-mark-superseded
- name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which
# invalidates the `COPY . .` layer of every check stage: a commit
# that was never linted, format-checked, stylesheet-checked, tested
# and built cannot report success from cache.
run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build)
run: script/cibuild
+21 -50
View File
@@ -1,53 +1,3 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, after the shared content above.
# Binaries # Binaries
*.exe *.exe
*.dll *.dll
@@ -65,6 +15,24 @@ bin/
# Go vendor directory # Go vendor directory
vendor/ vendor/
# ESLint, prettier and their dependencies, installed from yarn.lock
node_modules/
# IDE specific files
.idea/
*.swp
*.swo
*~
.vscode/
# OS specific files
.DS_Store
Thumbs.db
# Environment and config files
.env
.env.local
# Data directory (SQLite databases) # Data directory (SQLite databases)
data/ data/
*.db *.db
@@ -78,6 +46,9 @@ data/
tmp/ tmp/
temp/ temp/
# CI cache barrier, written into the build context by the check workflow
.ci-fingerprint
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is # Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed. # what is committed.
/static/js/alpine.min.js /static/js/alpine.min.js
+2 -73
View File
@@ -10,21 +10,14 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -35,70 +28,6 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/webhooker/internal/config/configtest
desc: test support; a file that is not a test must not import it
- pkg: sneak.berlin/go/webhooker/internal/database/databasetest
desc: test support; a file that is not a test must not import it
- pkg: sneak.berlin/go/webhooker/internal/middleware/middlewaretest
desc: test support; a file that is not a test must not import it
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
-2
View File
@@ -1,2 +0,0 @@
node_modules/
yarn.lock
-3
View File
@@ -1,3 +0,0 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
+61 -121
View File
@@ -1,3 +1,34 @@
# Lint stage
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, which
# holds the hash of the commit being checked (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore.
COPY . .
# Run the Go formatting check and the linter. gofmt and golangci-lint are
# invoked directly rather than through `make fmt-check` and `make lint`: this
# stage is already the pinned linter image, and both scripts build docker
# stages, so calling them here would need a docker daemon inside the build.
# The Markdown half of `make fmt-check` is the markdown-check stage below.
# Keep the golangci-lint steps in step with Dockerfile.lint, including
# --network=none (see its header for why).
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned # Stylesheet stages. static/css/tailwind.css is generated, by this pinned
# tailwindcss, from static/css/input.css and the files its @source lines # 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. # name. `make css` (script/css) writes it out from the css-output stage.
@@ -36,17 +67,15 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock # JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and # pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# prettier for the Markdown stages below. The lint phase below runs js-lint. # prettier for the Markdown stages below, and stays cached until those two
# # files change. script/lint forces only js-lint to re-run, and the build stage
# The image's own corepack runs the yarn that package.json's packageManager # below runs it too. COPY . . brings in the CI cache barrier described in the
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the # lint stage above.
# hash there. The image also ships yarn 1, which `corepack enable yarn` # node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
# replaces.
# node:24.21.0-alpine (LTS), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src WORKDIR /src
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock ./
RUN corepack enable yarn && yarn install --immutable --mode=skip-build RUN yarn install --frozen-lockfile --ignore-scripts
FROM js-deps AS js-lint FROM js-deps AS js-lint
COPY . . COPY . .
@@ -70,116 +99,23 @@ FROM js-deps AS markdown-check
COPY . . COPY . .
RUN --network=none node_modules/.bin/prettier --check '**/*.md' RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# Lint phase: the Go formatting check and golangci-lint over the Go code,
# and ESLint over static/js/ through the copy from js-lint at the end.
# `make lint` (script/lint) builds this stage alone; the build stage below
# depends on it.
#
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# gofmt and golangci-lint are invoked directly rather than through `make
# fmt-check` and `make lint`, which are themselves docker builds and would
# need a docker daemon inside this one. The Markdown half of `make
# fmt-check` is the markdown-check stage above.
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
# 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
# The golangci-lint steps run with --network=none. `golangci-lint config
# verify` is documented as fetching its JSON schema over HTTPS; this pinned
# image resolves the schema without any network, and --network=none enforces
# that. It also proves no linter reaches out at analysis time.
#
# `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml
# --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Nothing is wanted from js-lint; the copy is what makes this phase run it.
COPY --from=js-lint /src/yarn.lock /dev/null
# Test phase. -race needs cgo and so a C compiler, which the Debian Go image
# ships and the alpine one does not. `make test` (script/test) builds this
# stage alone; the build stage below depends on it.
#
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/.
RUN script/assets
# -timeout applies to each package on its own, so 90s has only to clear the
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
# four test binaries build or run at once, each with at most eight parallel
# tests. Under -race every test binary and every link costs a few hundred MB,
# so the defaults (one per core) add up to several GB on a many-core host.
#
# The first run has no -v: go test then prints one result line per package,
# with its coverage, and for a package that fails, everything its tests
# wrote. Verbose output from the whole suite passes the 2 MiB at which the
# Docker build cuts off a step's log, so on a failure only the tests that
# failed run again, with -v. go test reports a failed test as a line starting
# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with
# its parent) and a failed package as "FAIL<tab>package/path<tab>...". A
# failure that names no test, such as a build error or a timeout, is already
# shown in full, so there is nothing to rerun. The step fails after the rerun
# whatever its result: the first run already showed the suite is broken.
#
# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite
# waits for the disk at every commit, and on a busy host that waiting was
# about 40% of the slowest package's run time. GOTMPDIR keeps go's own
# build files, the test binaries among them, on disk.
#
# bash with pipefail, so that the first run's status is go test's, not tee's.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \
tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \
packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \
if [ -n "$tests" ]; then \
echo "--- Rerunning the failed tests with -v for details ---"; \
go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \
fi; \
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
# Using Debian-based image because gorm.io/driver/sqlite pulls in # Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
# ships git and make, which the version step below uses.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Nothing is wanted from the lint and test phases or from the stylesheet and # Depend on the lint, stylesheet check, JavaScript lint and Markdown check
# Markdown checks; the copies are what make BuildKit build them first, so # stages passing
# this stage cannot run unless they all passed.
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=js-lint /src/yarn.lock /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null COPY --from=markdown-check /src/yarn.lock /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
# A build context sent as a tar archive keeps its files' owners, and git # 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 # refuses to read a checkout owned by another user. Trust this one
# whoever owns it. # whoever owns it.
@@ -191,27 +127,31 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code, including the .ci-fingerprint cache barrier described in
# the lint stage above.
COPY . . COPY . .
# Run tests and build. Both first run script/assets, which extracts Alpine.js
# from its tarball in 3p/.
RUN make test
# Version stamped into the binary: the VERSION build arg when one is # Version stamped into the binary: the VERSION build arg when one is
# given, otherwise what script/version derives from the .git the build # given, otherwise what script/version derives from the .git the build
# context carries, so any `docker build .` of a clone stamps its commit. # context carries, so any `docker build .` of a clone stamps its commit.
# With neither, as from a source tarball, it is "unknown". # With neither, as from a source tarball, it is "unknown".
#
# Declared here, below the test step, so a changed version does not
# invalidate its cached layer.
ARG VERSION ARG VERSION
# A context that carries .git must not stamp an empty version, "dev" or # A context that carries .git must not stamp "unknown": that means git is
# "unknown": that means git is missing here or could not read the # missing here or could not read the checkout, and the image could not be
# checkout, and the image could not be traced back to its commit. # traced back to its commit.
RUN version="$(make version VERSION="$VERSION")"; \ RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
if [ -e .git ]; then \ echo "version is unknown although the build context carries .git" >&2; \
case "$version" in ""|dev|unknown) \ exit 1; \
echo "version is '$version' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi fi
# Builds through the Makefile's build target, which runs script/assets
# (Alpine.js, extracted from its tarball in 3p/) first.
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
# Rebuild with static linking for Alpine runtime. # Rebuild with static linking for Alpine runtime.
+1 -1
View File
@@ -16,7 +16,7 @@ COPY . .
# The test binary embeds the templates and static files, so the browser # 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 # stage needs nothing else. -p 4 keeps the compile's memory down, as in
# the test phase of Dockerfile. # script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server 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 # chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
+43
View File
@@ -0,0 +1,43 @@
# Lint-only image, built by script/lint. golangci-lint is never installed on
# the host: the repo is COPYed into the pinned image and linted as a build
# step, so a successful build IS a clean lint. This works even when the docker
# daemon is remote and bind mounts are impossible.
#
# script/lint passes --no-cache-filter=lint. Without it an unchanged tree
# replays the lint stage from cache and the build succeeds in under a second
# having run no linter at all. Do not drop that flag.
#
# The lint steps run with --network=none. `golangci-lint config verify` is
# documented as fetching its JSON schema over HTTPS, which would make linting
# depend on an unpinned remote artifact; this pinned image resolves the schema
# without any network, and --network=none enforces that rather than trusting
# it. It also proves no linter reaches out at analysis time. If a future image
# bump makes either step need the network, this build fails loudly instead of
# quietly acquiring an unpinned dependency.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
# Copy go mod files first for better layer caching. This stage is cacheable;
# only the lint stage below is forced to re-execute.
COPY go.mod go.sum ./
RUN go mod download
FROM deps AS lint
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml
# --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+1 -5
View File
@@ -19,10 +19,6 @@ override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
# Extra linker flags for the build target. The static relink in the # Extra linker flags for the build target. The static relink in the
# Dockerfile adds -extldflags here rather than passing its own -ldflags, # Dockerfile adds -extldflags here rather than passing its own -ldflags,
# so composing flags cannot drop the version stamp. # so composing flags cannot drop the version stamp.
#
# The build target itself always passes -trimpath and -s -w, as the Go
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
# debug information in the binary.
GO_LDFLAGS ?= GO_LDFLAGS ?=
bootstrap: bootstrap:
@@ -53,7 +49,7 @@ check:
@script/check @script/check
build: assets build: assets
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
+164 -163
View File
@@ -17,14 +17,14 @@ before deploying one.
### Prerequisites ### Prerequisites
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for `make test`, `make lint`, `make fmt` and `make css`, and so for - Docker (for `make lint`, `make fmt` and `make css`, and so for `make check`,
`make check`, for the browser test in `make test-browser`, for the CI gate, for the browser test in `make test-browser`, for the CI gate, and for
and for containerized deployment) containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the host: golangci-lint is not a prerequisite and must not be installed on the host:
`script/bootstrap` does not install it, and `make lint` runs the digest-pinned `script/bootstrap` does not install it, and `make lint` runs the digest-pinned
linter image in the Dockerfile's `lint` phase. The same holds for tailwindcss linter image via `Dockerfile.lint`. The same holds for tailwindcss (see
(see [Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not [Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not
prerequisites either, and `make lint` and `make fmt` never use a host copy of prerequisites either, and `make lint` and `make fmt` never use a host copy of
them (see [Linting](#linting)). them (see [Linting](#linting)).
@@ -43,9 +43,8 @@ make check
# Run the server from the clone. DATA_DIR defaults to # Run the server from the clone. DATA_DIR defaults to
# /var/lib/webhooker in every environment, so set it (in .env or the # /var/lib/webhooker in every environment, so set it (in .env or the
# shell) to a writable directory outside the clone: the databases hold # shell) to a writable directory.
# the session key. DATA_DIR=./data make dev
DATA_DIR=../webhooker-data make dev
# Build Docker image # Build Docker image
make docker make docker
@@ -56,11 +55,11 @@ make docker
```bash ```bash
make bootstrap # Install all dependencies (idempotent) make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook make setup # Bootstrap + install git pre-commit hook
make assets # Extract Alpine.js from 3p/ (build and dev run it) make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker) make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker)
make fmt-check # Fail if gofmt or prettier would change anything (writes nothing) make fmt-check # Fail if gofmt or prettier would change anything (writes nothing)
make lint # Run golangci-lint and ESLint in Docker make lint # Run golangci-lint and ESLint in Docker
make test # Run tests with race detection, in Docker make test # Run tests with race detection
make test-browser # Run the browser test in Docker (Dockerfile.browser) make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check + css-check (CI gate) make check # test + lint + fmt-check + css-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped) make build # Build binary to bin/webhooker (version-stamped)
@@ -1096,9 +1095,7 @@ field), in the UI footer, and in the startup log line (`msg=starting`,
The value is stamped in at build time by the linker; it is not read from a file The value is stamped in at build time by the linker; it is not read from a file
at runtime, so it identifies the build itself. at runtime, so it identifies the build itself.
`script/version` produces the value for `make build`, and the image build runs `script/version` produces the value and both build paths use it:
`make build` too; `script/docker` and `script/cibuild` run the same
`git describe --tags --always --dirty` on the host. All of them report:
| Build | What it reports | | Build | What it reports |
| ----------------------- | --------------------------------------------------- | | ----------------------- | --------------------------------------------------- |
@@ -1113,19 +1110,18 @@ carries, so any `docker build .` of a clone, with no build arguments, stamps the
commit it was built from; a shallow clone of one branch has no tags and stamps commit it was built from; a shallow clone of one branch has no tags and stamps
the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any
tracked file, which git in the build would see as deleted, marking the version tracked file, which git in the build would see as deleted, marking the version
`-dirty`. It does leave out every git `config` (`**/.git/config`, `-dirty`. It does leave `.git/config`, which can hold a remote URL carrying a
`**/.git/modules/**/config`), which can hold a remote URL carrying a credential credential and which `git describe` does not need, out of a directory context. A
and which `git describe` does not need, from a directory context. A context sent context sent as a tar is not filtered by `.dockerignore`, so it carries
as a tar is not filtered by `.dockerignore`, so it carries `.git/config` unless `.git/config` unless its sender leaves it out; for upaas, that is
its sender leaves it out; for upaas, that is
https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout
whoever owns its files, since a context sent as a tar archive keeps the sender's whoever owns its files, since a context sent as a tar archive keeps the sender's
owners and git otherwise refuses a checkout owned by another user. A `VERSION` owners and git otherwise refuses a checkout owned by another user. A `VERSION`
build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so
`make docker`) and `script/cibuild` pass the one they resolve on the host, or `make docker`) passes the one `script/version` resolves on the host. The image
`unknown` where git gives none. The image build fails if its context carries build fails if its context carries `.git` and the version still comes out
`.git` and the version still comes out empty, `dev` or `unknown`, which means `unknown`, which means git is missing from the build or could not read the
git is missing from the build or could not read the checkout. checkout.
`unknown` is what a source tarball, or a `docker build` with no `.git` in its `unknown` is what a source tarball, or a `docker build` with no `.git` in its
context and no `VERSION` build arg, reports. A build that reports `unknown` is a context and no `VERSION` build arg, reports. A build that reports `unknown` is a
@@ -1139,9 +1135,7 @@ to a commit.
Nothing that varies between two builds of the same commit is stamped — no Nothing that varies between two builds of the same commit is stamped — no
timestamp, no hostname, no builder identity — so two builds of one commit still timestamp, no hostname, no builder identity — so two builds of one commit still
produce a byte-identical binary. `make build` passes `-trimpath`, so the produce a byte-identical binary.
directory it builds in is not recorded either, and `-s -w`, which leave out the
symbol table and debug information.
### Backups contain secrets ### Backups contain secrets
@@ -1219,14 +1213,11 @@ commands with no script behind them, though `build`, `run` and `dev` first run
`script/assets`, and `build` and `version` both take their value from `script/assets`, and `build` and `version` both take their value from
`script/version`. `script/version`.
`make build` and `make dev` each run `script/assets` first, which writes the `script/test`, `make build` and `make dev` each run `script/assets` first, which
uncommitted `static/js/alpine.min.js` (see writes the ignored `static/js/alpine.min.js` (see
[Third-party browser assets](#third-party-browser-assets)), so they work on a [Third-party browser assets](#third-party-browser-assets)), so `make test`,
fresh clone without a separate step. The Docker stages that compile the code run `make check` and the pre-commit hook work on a fresh clone without a separate
it themselves. step.
Every `docker build` in `script/` passes `--no-cache`: a check served from the
build cache is a check that did not run.
We provide: We provide:
@@ -1236,12 +1227,10 @@ We provide:
- `script/projectname` — output the project name ("webhooker") - `script/projectname` — output the project name ("webhooker")
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see - `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets)) [Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite: builds the Dockerfile's `test` phase, - `script/test` — run the test suite
tagged `webhooker-test`
- `script/test-browser` — run the browser test in Docker (see - `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets)) [Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run the `gofmt` check, golangci-lint and ESLint: builds the - `script/lint` — run golangci-lint and ESLint in Docker (see Linting below)
Dockerfile's `lint` phase, tagged `webhooker-lint` (see Linting below)
- `script/fmt` — format the Go code and, in Docker, the Markdown (writes) - `script/fmt` — format the Go code and, in Docker, the Markdown (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see - `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see
@@ -1252,11 +1241,11 @@ We provide:
- `script/version` — output the version to stamp into the binary (see - `script/version` — output the version to stamp into the binary (see
[Version stamping](#version-stamping)) [Version stamping](#version-stamping))
- `script/docker` — build the Docker image tagged via `script/projectname`, - `script/docker` — build the Docker image tagged via `script/projectname`,
passing the version `git describe` gives on the host in as the `VERSION` build passing `script/version`'s output in as the `VERSION` build arg
arg - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`, checks, so a green build implies a green repo)
then the same image build as `script/docker`, whose gate phases run again (see - `script/ci-mark-superseded` — CI helper: mark the commits whose run a newer
[CI gate honesty](#ci-gate-honesty)) push cancelled (see [CI gate honesty](#ci-gate-honesty))
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then - `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`) `script/check`)
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
@@ -1291,9 +1280,7 @@ the event log only the newest starts expanded, and an event there expands and
collapses when its row's caret or its ID is clicked, and from the keyboard, but collapses when its row's caret or its ID is clicked, and from the keyboard, but
not when its ID is selected with the mouse, and a delivery's attempts inside it not when its ID is selected with the mouse, and a delivery's attempts inside it
expand and collapse; and at phone width the menu button opens and closes the expand and collapse; and at phone width the menu button opens and closes the
mobile menu, and neither the webhook page nor the event log, with a delivery's mobile menu. It also fails if the browser reports a console warning or error, an
attempts open, scrolls sideways or cuts anything off at the page's or a card's
edge. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after built only with the `browser` build tag). Run it with `make test-browser` after
@@ -1309,12 +1296,12 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
`script/assets` (`make assets`) extracts the browser build, `script/assets` (`make assets`) extracts the browser build,
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where `package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where
`go:embed` picks it up. `make build` and `make dev` run it first, and so do the `go:embed` picks it up. `script/test`, `make build` and `make dev` run it first,
Dockerfile's lint, test and build stages, so nothing downloads Alpine.js. The and the Dockerfile builds through `make test` and `make build`, so nothing
extracted file is not committed, and `.dockerignore` keeps any host copy out of downloads Alpine.js. The extracted file is not committed, and `.dockerignore`
the build context. `static/static.go` names every file it embeds, so a build keeps any host copy out of the build context. `static/static.go` names every
that skips the extraction, such as a bare `go build`, fails with an error naming file it embeds, so a build that skips the extraction, such as a bare `go build`,
`js/alpine.min.js`. fails with an error naming `js/alpine.min.js`.
To move to a new version: download To move to a new version: download
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
@@ -1650,18 +1637,10 @@ URL, custom headers, timeout settings).
**`http` target configuration:** **`http` target configuration:**
| Key | Type | Description | | Key | Type | Description |
| -------------- | ------------- | ----------------------------------------------------------------------------------------- | | --------- | ------------- | -------------------------------------------------------------------------------------- |
| `url` | string | Destination the event is POSTed to | | `url` | string | Destination the event is POSTed to |
| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | | `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers |
| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | | `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout |
| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not |
`forwardQuery` is off by default, and the target URL is then sent exactly as
configured. On, each delivery appends the event's query string to the target
URL, joined with `&` when the URL already has a query string of its own; a
replayed delivery and a resubmitted event's deliveries do the same. Both target
forms offer it as "Pass the query string on to this target", and the target list
shows it when it is on.
`timeout` is capped at **300 seconds**, and the form rejects anything above it `timeout` is capped at **300 seconds**, and the form rejects anything above it
rather than substituting the cap. A delivery attempt holds one of the bounded rather than substituting the cap. A delivery attempt holds one of the bounded
@@ -1723,7 +1702,6 @@ auditing, for replay, and for resubmission.
| `webhook_id` | UUID | Foreign key → Webhook | | `webhook_id` | UUID | Foreign key → Webhook |
| `entrypoint_id` | UUID | Foreign key → Entrypoint | | `entrypoint_id` | UUID | Foreign key → Entrypoint |
| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | | `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created |
| `raw_query` | text | The query string of the captured request, as sent, without the leading `?`; empty when there was none. A resubmitted copy carries its original's |
| `headers` | JSON | Complete request headers | | `headers` | JSON | Complete request headers |
| `body` | text | Raw request body | | `body` | text | Raw request body |
| `content_type` | string | Content-Type header value | | `content_type` | string | Content-Type header value |
@@ -1732,15 +1710,9 @@ auditing, for replay, and for resubmission.
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries. **Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries.
When a request arrives at an entrypoint, the full request (method, query string, When a request arrives at an entrypoint, the full request (method, headers,
headers, body) is captured as an Event. The event is then queued for delivery to body) is captured as an Event. The event is then queued for delivery to every
every active target configured on the parent webhook. active target configured on the parent webhook.
The event log and the event's own page show the query string with the rest of
the request. The event log leaves out one larger than 32 KiB, as it does request
headers, and links to the event's page, which shows it whole. The `database` and
`log` targets carry it with the rest of the event. An `http` target receives it
only when its `forwardQuery` setting is on.
#### Delivery #### Delivery
@@ -1786,14 +1758,14 @@ the webhook's currently active targets.
**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT. **Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT.
The event log offers a per-event **Resubmit** action that stores a NEW event The event log offers a per-event **Resubmit** action that stores a NEW event
copying the stored one's `method`, `raw_query`, `headers`, `body` and copying the stored one's `method`, `headers`, `body` and `content_type`
`content_type` verbatim, then fans it out to the webhook's currently **active** verbatim, then fans it out to the webhook's currently **active** targets —
targets — resolved fresh by the same query the receiver uses, so a target resolved fresh by the same query the receiver uses, so a target created long
created long after the original event arrived receives it. That is the after the original event arrived receives it. That is the difference that
difference that matters: a target added to test a backend under development has matters: a target added to test a backend under development has no prior
no prior delivery, so there is nothing to replay to it, while a resubmit reaches delivery, so there is nothing to replay to it, while a resubmit reaches it like
it like any other active target. Inactive targets are skipped, exactly as the any other active target. Inactive targets are skipped, exactly as the receiver
receiver skips them. skips them.
The new event is a first-class event in the log with its own deliveries, not a The new event is a first-class event in the log with its own deliveries, not a
marker on the one it came from, and the original's deliveries are left marker on the one it came from, and the original's deliveries are left
@@ -2464,8 +2436,7 @@ in front of them, so a query on a fixed 200 URL would otherwise buy the same
amplification as an invented path. Nothing debuggable is lost: the only query amplification as an invented path. Nothing debuggable is lost: the only query
parameters this service reads are the sign-in page's `next`, the page to return parameters this service reads are the sign-in page's `next`, the page to return
to, `notice`, which names the line a page shows after an action, and the event to, `notice`, which names the line a page shows after an action, and the event
log's `show`, which picks the events it lists. A query string sent to an log's `show`, which picks the events it lists.
entrypoint is not lost either: the event stores it, and the event log shows it.
Client-supplied request content does not leave the host by the other route Client-supplied request content does not leave the host by the other route
either. The Sentry SDK attaches the request to every event it captures, either. The Sentry SDK attaches the request to every event it captures,
@@ -2928,7 +2899,7 @@ page that was asked for.
| `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook | | `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | | `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its query string, its request headers, its whole body and every delivery of it | | `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | | `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
@@ -2980,11 +2951,13 @@ webhooker/
├── internal/ ├── internal/
│ ├── banner/ │ ├── banner/
│ │ └── banner.go # Ruled block for the one credential shown in the clear │ │ └── banner.go # Ruled block for the one credential shown in the clear
│ ├── ciscript/
│ │ └── doc.go # Tests for the CI shell scripts in script/; no runtime code
│ ├── resetpw/ │ ├── resetpw/
│ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only │ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only
│ ├── config/ │ ├── config/
│ │ ├── config.go # Configuration loading from environment variables │ │ ├── config.go # Configuration loading from environment variables
│ │ └── configtest/ # Test support: ClearEnv, an empty environment for one test │ │ └── testing.go # ClearEnvForTest: an empty environment for one test
│ ├── database/ │ ├── database/
│ │ ├── base_model.go # BaseModel with UUID primary keys │ │ ├── base_model.go # BaseModel with UUID primary keys
│ │ ├── database.go # GORM connection, migrations, admin seed │ │ ├── database.go # GORM connection, migrations, admin seed
@@ -3001,8 +2974,8 @@ webhooker/
│ │ ├── model_apikey.go # APIKey entity │ │ ├── model_apikey.go # APIKey entity
│ │ ├── password.go # Argon2id hashing and verification │ │ ├── password.go # Argon2id hashing and verification
│ │ ├── retention.go # Retention reaper (per-webhook event expiry) │ │ ├── retention.go # Retention reaper (per-webhook event expiry)
│ │ ├── webhook_db_manager.go # Per-webhook DB lifecycle manager │ │ ├── testing.go # NewTestDatabase: wrapper for tests, no fx lifecycle
│ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages │ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager
│ ├── datadir/ │ ├── datadir/
│ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance) │ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance)
│ ├── globals/ │ ├── globals/
@@ -3063,7 +3036,7 @@ webhooker/
│ │ ├── csrf.go # CSRF protection middleware (gorilla/csrf) │ │ ├── csrf.go # CSRF protection middleware (gorilla/csrf)
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages │ │ └── testing.go # NewForTest: Middleware without the fx lifecycle
│ ├── reqtls/ │ ├── reqtls/
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
│ ├── server/ │ ├── server/
@@ -3071,7 +3044,8 @@ webhooker/
│ │ ├── http.go # HTTP server setup with timeouts │ │ ├── http.go # HTTP server setup with timeouts
│ │ └── routes.go # All route definitions │ │ └── routes.go # All route definitions
│ ├── session/ │ ├── session/
│ │ └── session.go # Cookie-based session management │ │ ├── session.go # Cookie-based session management
│ │ └── testing.go # NewForTest: Session without the fx lifecycle
│ └── versionscript/ │ └── versionscript/
│ └── doc.go # Tests for script/version and the build files that use it │ └── doc.go # Tests for script/version and the build files that use it
├── static/ ├── static/
@@ -3083,15 +3057,14 @@ webhooker/
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Stages: stylesheet, JavaScript lint, Markdown, lint, test, build, Alpine runtime ├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, Markdown, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint
├── Dockerfile.browser # Browser test image built by script/test-browser ├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── Makefile # 13 of 19 targets shim script/; 6 are inline
├── go.mod / go.sum ├── go.mod / go.sum
├── package.json / yarn.lock # ESLint, prettier and yarn, pinned, for the JavaScript lint and Markdown stages ├── package.json / yarn.lock # ESLint and prettier, pinned, for the JavaScript lint and Markdown stages
├── .yarnrc.yml # yarn settings: install into node_modules/
├── eslint.config.mjs # ESLint configuration for static/js/ ├── eslint.config.mjs # ESLint configuration for static/js/
├── .prettierrc # prettier settings for the Markdown ├── .prettierrc # prettier settings for the Markdown
├── .prettierignore # Files prettier skips
└── .golangci.yml # golangci-lint configuration └── .golangci.yml # golangci-lint configuration
``` ```
@@ -3347,36 +3320,43 @@ Two operational consequences follow from bounding the sequence:
### Linting ### Linting
golangci-lint never runs on the host. `script/lint` builds the Dockerfile's golangci-lint never runs on the host. `script/lint` builds `Dockerfile.lint`,
`lint` phase, which copies the repo into the digest-pinned golangci-lint image which copies the repo into the digest-pinned golangci-lint image and lints as a
and lints as a build step, so a successful build is a clean lint. A host binary build step, so a successful build is a clean lint. A host binary would share one
would share one cache and one lock with every other checkout on the machine, cache and one lock with every other checkout on the machine, which has produced
which has produced both invented findings attributed to other worktrees and both invented findings attributed to other worktrees and unearned passes.
unearned passes.
Two properties are load-bearing: Three properties are load-bearing:
- `script/lint` passes `--no-cache`. Without it an unchanged tree replays the - `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged tree
lint layer from cache and the build exits 0 in under a second having linted replays the lint layer from cache and the build exits 0 in under a second
nothing. Never prune the shared build cache instead. having linted nothing. The `deps` stage stays cacheable, so module downloads
- Both golangci-lint steps use `RUN --network=none`. are not repeated. Invalidation is scoped to the one stage; never prune the
`golangci-lint config verify` is documented as fetching its JSON schema over shared build cache.
HTTPS, which would be an unpinned remote dependency; the pinned image resolves - `script/lint` does not trust that flag. Docker silently ignores
the schema without network access, and `--network=none` enforces that instead `--no-cache-filter` for a stage name that does not match, so a stage rename or
of trusting it. Verify is worth keeping because `golangci-lint run` silently a one-character typo would restore the cached false green with no warning and
ignores config keys it does not recognize, so a typo would disable a setting a fast exit 0. The script therefore tees the build output and treats a run as
with no warning. a pass only if golangci-lint's own summary line (`N issues.` / `N issues:`)
appears in it: no summary, no lint, whatever the exit code says.
- Both lint steps use `RUN --network=none`. `golangci-lint config verify` is
documented as fetching its JSON schema over HTTPS, which would be an unpinned
remote dependency; the pinned image resolves the schema without network
access, and `--network=none` enforces that instead of trusting it. Verify is
worth keeping because `golangci-lint run` silently ignores config keys it does
not recognize, so a typo would disable a setting with no warning.
ESLint never runs on the host either. It lints `static/js/` (not the extracted ESLint never runs on the host either. It lints `static/js/` (not the extracted
Alpine.js) in the Dockerfile's `js-lint` stage. The `lint` phase copies a file Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after
from it, so `make lint` and the image build both run ESLint. Its version is `Dockerfile.lint` and the image build runs before the builder stage. Its version
pinned in `package.json` and every package's hash in `yarn.lock`. The `js-deps` is pinned in `package.json` and every package's hash in `yarn.lock`. The
stage before it installs ESLint with `yarn install --immutable`, which fails `js-deps` stage before it installs ESLint and stays cached until either file
rather than change `yarn.lock`. The yarn it runs is the one the `packageManager` changes, so only the lint step re-runs and ESLint is not downloaded again.
field in `package.json` pins by version and hash, which the node image's own `eslint.config.mjs` turns on the rules of the JavaScript styleguide
corepack fetches and checks. `eslint.config.mjs` turns on the rules of the `REPO_POLICIES.md` links to that a linter can check: `no-var` and
JavaScript styleguide `REPO_POLICIES.md` links to that a linter can check: `prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no summary
`no-var` and `prefer-const`. line to look for; it names the stage once for both `--target` and
`--no-cache-filter`, and `--target` fails on a name that matches no stage.
prettier formats the Markdown, and it never runs on the host either. It is prettier formats the Markdown, and it never runs on the host either. It is
pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same
@@ -3388,81 +3368,102 @@ on any Markdown file prettier would change.
### Docker ### Docker
The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the
lint phase and the Go stages are separate images so the linter's version is lint and builder stages are separate images so the linter's version is fixed
fixed independently of the compiler's: independently of the compiler's:
1. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone 1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — downloads
dependencies, copies the source, and runs the `gofmt` check, then
`script/assets` to extract Alpine.js from `3p/`, then
`golangci-lint config verify` and `golangci-lint run`, both with
`--network=none`.
2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone
CLI pinned by version and sha256, one binary per architecture) — generate CLI pinned by version and sha256, one binary per architecture) — generate
`static/css/tailwind.css` from `static/css/input.css` and the files its `static/css/tailwind.css` from `static/css/input.css` and the files its
`@source` lines name. `css-check` fails when the committed file differs from `@source` lines name. `css-check` fails when the committed file differs from
the generated one, and `make css` writes the generated file out from the generated one, and `make css` writes the generated file out from
`css-output` (see [Stylesheet](#stylesheet)). `css-output` (see [Stylesheet](#stylesheet)).
2. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn 3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — `js-deps`
`package.json` pins, run through the image's corepack) — `js-deps` installs installs ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over
ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over
`static/js/` (see [Linting](#linting)). `static/js/` (see [Linting](#linting)).
3. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the 4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the
Markdown and fails on any file it would change, and `make fmt` writes the Markdown and fails on any file it would change, and `make fmt` writes the
formatted files out from `markdown-output`. formatted files out from `markdown-output`.
4. **Lint phase** (`lint`, `golangci/golangci-lint:v2.14.0`, Debian-based) — 5. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint,
downloads dependencies, copies the source, and runs the `gofmt` check, then `css-check`, `js-lint` and `markdown-check` stages passing (it copies a file
`script/assets` to extract Alpine.js from `3p/`, then from each), runs `make test` and `make build` (both extract Alpine.js from
`golangci-lint config verify` and `golangci-lint run`, both with `3p/` first), and finally rebuilds the binary with `CGO_ENABLED=1` and static
`--network=none`, and depends on `js-lint` (it copies a file from it). linking so it runs on musl. Both builds go through `make build`, the relink
`make lint` builds this stage alone. adding its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
5. **Test phase** (`test`, `golang:1.26.1-bookworm`, whose C compiler `-race` stamps the version. The version is the `VERSION` build arg if one is given,
needs) — extracts Alpine.js, then runs `go test -race -cover` at most four
packages and eight tests at a time, with a 90-second timeout per package. On
a failure it runs only the failed tests again with `-v`, since verbose output
from the whole suite would pass the 2 MiB at which the Docker build cuts off
a step's log, and then fails. `make test` builds this stage alone.
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
phases and the `css-check` and `markdown-check` stages passing (it copies a
file from each), runs `make build` (which extracts Alpine.js from `3p/`
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
so it runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
the version. The version is the `VERSION` build arg if one is given,
otherwise derived from the `.git` in the context, and the stage fails if a otherwise derived from the `.git` in the context, and the stage fails if a
context with `.git` would stamp an empty version, `dev` or `unknown` (see context with `.git` would stamp `unknown` (see
[Version stamping](#version-stamping)). [Version stamping](#version-stamping)).
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and 6. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
all SQLite databases, exposes port 8080, and includes a health check against all SQLite databases, exposes port 8080, and includes a health check against
`/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts `/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts
as root, sets the data directory's owner and mode, and runs the app as the as root, sets the data directory's owner and mode, and runs the app as the
non-root `webhooker` user (UID 1000) through `su-exec`. non-root `webhooker` user (UID 1000) through `su-exec`.
The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly The lint stage invokes `gofmt` and `golangci-lint` directly rather than
rather than `make fmt-check`, `make lint` and `make test`: those targets build `make fmt-check` and `make lint`: it is already the pinned linter image, and
docker stages, which would need a docker daemon inside this build. both targets build docker stages, which would need a docker daemon inside this
build.
The lint phase and the Go stages use Debian rather than Alpine because The lint and builder stages use Debian rather than Alpine because
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does `gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does
not compile against musl. Only the final binary is statically linked, which is not compile against musl. Only the final binary is statically linked, which is
what lets it run on the Alpine runtime image. what lets it run on the Alpine runtime image.
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then `script/cibuild` — `docker build .` — is the CI gate: the checks run inside the
`script/check`, then builds the image, whose build runs the lint and test phases image, so a build that succeeds is a repo that is formatted, linted, tested and
and the stylesheet and Markdown checks again. A build that succeeds is a repo compiled, with a current stylesheet. `script/lint` also uses Docker
that is formatted, linted, tested and compiled, with a current stylesheet. (`Dockerfile.lint` and the `js-lint` stage, see Linting above), so `make lint`
`make check` runs the same stages the gate does; of its steps, only the `gofmt` and `make check` run the same pinned linter versions the gate does; of the steps
check in `script/fmt-check` runs on the host. `make check` runs, only `script/test` and the `gofmt` check in
`script/fmt-check` run on the host.
#### CI gate honesty #### CI gate honesty
A layer cache lets `docker build .` exit 0 in seconds with the lint and test A layer cache lets `docker build .` exit 0 in seconds with the lint and test
stages replayed rather than executed, which would make a green check stages replayed rather than executed, which would make a green check
meaningless. Every `docker build` in `script/` therefore passes `--no-cache`, so meaningless. The `check` workflow therefore writes `.ci-fingerprint` into the
on every run the `gofmt` check, `golangci-lint`, ESLint, the stylesheet check, build context before building. Its value is the hash of the commit being
the Markdown check, `go test` and `make build` really execute. A run that checked, so every commit, docs-only ones and a squash merge whose tree matches
reports success ran them. A bare `docker build .` carries no such guarantee. an already-built branch included, gets a new fingerprint, invalidates the
`COPY . .` layer of every check stage, and really runs the `gofmt` check,
`golangci-lint`, the stylesheet check, ESLint, the Markdown check, `make test`,
and `make build`. A run that reports success ran them.
The `check` workflow is the shared one from `REPO_POLICIES.md`: it checks out The module download layer sits above `COPY . .` and stays cached.
the repository and runs `script/cibuild`, nothing else. Gitea cancels an
in-flight run when a newer commit lands on the same branch and records that as A separate workflow step, run before the fingerprint is written, covers a second
`failure` / `Has been cancelled`: nothing was verified about that commit, so way the gate lied: Gitea cancels an in-flight run when a newer commit lands on
test the commit itself before concluding anything about it. the same branch and records that cancellation as a `failure` status, so a commit
nothing ever tested reads as a test result. Cancellation is unconditional
server-side for push events, so the superseding run calls
`script/ci-mark-superseded`, which rewrites that exact status to `failure` /
`Superseded by a newer commit; never tested`.
The state stays `failure` on purpose: Gitea's combined status folds `skipped`
into `success`, so marking a never-tested commit `skipped` made the status API
report green for it, indistinguishable from a commit that passed. Reading a
commit's status on this repo therefore goes:
- `success` / `Successful in ...` — the checks ran and passed.
- `failure` / `Failing after ...` — the checks ran and failed.
- `failure` / `Superseded by a newer commit; never tested` — the run was
cancelled, by a newer push or by hand, and nothing was verified about this
commit. Test the commit itself before concluding anything about it.
Genuine failures and successes are never touched, and no status is left
`pending`, which would block the commit indefinitely. The step derives its
context string from the workflow name, the job **id** and the event. That is
deliberately not byte-identical to Gitea's own rule, which uses the job's
display `name:` where the runner exports the id, so giving the job a `name:` —
or renaming the workflow — makes the derived context stop matching. The step
fails loudly when no status on the commit carries that context, so no rename can
silently disable the rewrite.
## TODO ## TODO
+86 -349
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-04 last_modified: 2026-08-07
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,28 +60,17 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image repo root and runs `docker build .`; the Gitea workflow calls it. Four further
with the version; the Gitea workflow calls it. **`script/cibuild` runs scripts are our own extensions to the standard: `script/check` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
nothing else, while `script/fmt-check` runs the formatter on the host: on a what the git pre-commit hook runs, and it calls `script/check`;
pristine checkout with nothing installed the run dies there, after the `script/install-precommit` installs the git pre-commit hook (the `make hooks`
containerised gates have passed. **The bootstrap alone is not enough**: target shims to it); and `script/projectname` (literally that filename) simply
`script/bootstrap` installs node and yarn under nvm and leaves neither on the outputs the project's name. Scripts that need the name call
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host `script/projectname` — e.g. `script/docker` assembles its image tag from it —
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore so those scripts stay byte-identical across all repos. Repo-type-specific
source nvm for the pinned node version before invoking it, exactly as pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/bootstrap`'s own install step does. A runner carrying nothing but `script/precommit`, not in the hook itself. Model scripts are at
docker and git then gets through `script/check`. Four further scripts are our
own extensions to the standard: `script/check` runs `script/test`,
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -100,198 +89,87 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
`lint` phase and a `test` phase, with the final stage depending on both so the as a build step so the build fails if the branch is not green. For non-server
image cannot be built unless they pass. For non-server repos the final stage repos, the Dockerfile should bring up a development environment and run
brings up a development environment; for server repos it is the runtime image. `make check`. For server repos, `make check` should run as an early build
The gate phases and the build stage start from their pinned base images and stage before the final image is assembled. Dockerfiles install development
install what those images lack either inline, as the canonical Go `Dockerfile` prerequisites by running `script/bootstrap` rather than duplicating installs
below does for `git`, or by running `script/bootstrap`, as the `prompts` inline; COPY `script/` and the dependency manifests (`package.json` +
repo's own `Dockerfile` does for its yarn packages. The development `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
environment stage installs development prerequisites by running layer stays cached until dependencies change.
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
no separate lint file. `script/lint` and `script/test` each build one phase repos use a multistage build where linting runs in an independent stage based
and nothing else: on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
```sh The standard pattern for a Go repo Dockerfile is:
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
```dockerfile ```dockerfile
# Lint phase # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN make fmt-check
RUN make lint
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Build stage
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test
# The VERSION build arg when one is given, otherwise ARG VERSION=dev
# `git describe --tags --always` on the .git in the build context. With RUN CGO_ENABLED=0 go build -trimpath \
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/ -o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has - The lint stage uses the `golangci/golangci-lint` image directly (it
both Go and the linter), so nothing needs installing. includes both Go and the linter), so there is no need to install the
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only linter separately.
purpose is the ordering edge. BuildKit runs stages in parallel by default, - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
and a stage nothing depends on is not built at all, so without these two a stage dependency. BuildKit runs stages in parallel by default; without
lines a red gate would not fail the build. this line, the build stage would not wait for lint to finish and a lint
- Keep the runtime stage last, and if you add a stage after it, give it the failure might not fail the overall build.
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them The lint stage should not depend on the actual build output — it exists to
in the lint phase. The `golangci/golangci-lint` image is Debian-based and fail fast.
has no `apk`, so install with `apt-get` under the Debian package name - If the project requires CGO or system libraries for linting (e.g.
(`libvips-dev`, where alpine says `vips-dev`), and delete the package `vips-dev`), install them in the lint stage with `apk add`.
lists in the same `RUN`, so the layer does not keep them: - The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
```dockerfile artifacts or heavier dependencies.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` (which runs `docker build .`) on push. Since the
That script bootstraps, runs the gate phases, and then builds the image, so a Dockerfile already runs `make check`, a successful build implies all checks
successful run means every check passed; a bare `docker build .` does not pass.
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -315,17 +193,15 @@ style conventions are in separate documents:
suite that exceeds it fails. Under 20 seconds is the target. A suite between suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an 20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately
hard cap so that it catches a genuinely hung test rather than a merely slow sits above the hard cap so that it catches a genuinely hung test rather than a
one. merely slow one.
- **The test command should use the conditional verbose rerun pattern.** Run - **`make test` should use the conditional verbose rerun pattern.** Run tests
tests without `-v` (verbose) first. If tests fail, automatically rerun with without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
`-v` to show full output. This keeps CI logs and `docker build` output clean show full output. This keeps CI logs and `docker build` output clean on
on success (just package/suite summaries) while providing full diagnostic success (just package/suite summaries) while providing full diagnostic detail
detail on failure (every test case, every assertion). The command lives in the on failure (every test case, every assertion). The general shell pattern:
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile ```makefile
test: test:
@@ -338,26 +214,11 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -count=1 -timeout 90s -race -cover ./... || \ @go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
```makefile ```makefile
@@ -383,84 +244,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` Fetch the standard `.gitignore` from
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
setting up a new repo. These patterns are written to `.gitignore`'s own a new repo.
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -476,56 +263,12 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
_NEVER_ be modified by an agent: fetch it from manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
byte-identical, so that no repo can quietly loosen its own linting. Linter canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
configuration changes are made to the canonical copy in the `prompts` repo and commit-pinned via
reach consuming repos by re-vendoring; an agent may open a PR against `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -639,14 +382,12 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
and language-specific config). Everything else goes in a subdirectory. language-specific config). Everything else goes in a subdirectory. Canonical
Canonical subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples - `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform) - `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root) - `docs/` — documentation and markdown (README.md stays in root)
@@ -673,7 +414,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
-4
View File
@@ -4,7 +4,6 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"time" "time"
@@ -188,9 +187,6 @@ func newApp() *fx.App {
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
// The plain logger the session, the middleware and the
// webhook database manager take.
func(l *logger.Logger) *slog.Logger { return l.Get() },
config.New, config.New,
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
+3 -3
View File
@@ -14,7 +14,7 @@ import (
"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/configtest" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -37,7 +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) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout() got := newApp().StopTimeout()
@@ -75,7 +75,7 @@ func freePort(t *testing.T) int {
// anything is built, and the run of logger.New, which happens before // anything is built, and the run of logger.New, which happens before
// the configuration sets the level. // the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) { func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t))) t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
+1 -1
View File
@@ -22,6 +22,7 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
modernc.org/sqlite v1.28.0 modernc.org/sqlite v1.28.0
@@ -58,7 +59,6 @@ require (
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
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect
@@ -0,0 +1,387 @@
package ciscript_test
import (
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
const (
// supersededDesc is the description script/ci-mark-superseded
// writes, and the one an earlier revision of it wrote alongside a
// `skipped` state.
supersededDesc = "Superseded by a newer commit; never tested"
// liveContext is the commit-status context Gitea uses for this
// repository's runs, as seen in its API. The script derives it from
// the workflow and job names rather than hardcoding it; the
// derivation is checked against this value below.
liveContext = "check / check (push)"
scriptPath = "../../script/ci-mark-superseded"
workflow = "../../.gitea/workflows/check.yml"
// failure is the only state that neither folds into a combined
// `success` (as `skipped` does) nor blocks the commit forever (as
// `pending` does).
failure = "failure"
)
// repo is a throwaway git history: parent is the commit a run would be
// cancelled on, head the commit that superseded it.
type repo struct {
dir string
head string
parent string
}
// scriptEnv is the run identity the Gitea runner exports and the script
// builds its context string from.
type scriptEnv struct {
workflow string
job string
event string
}
func defaultEnv() scriptEnv {
return scriptEnv{workflow: "check", job: "check", event: "push"}
}
func cancelled() commitStatus {
return commitStatus{
Context: liveContext,
Status: failure,
Description: "Has been cancelled",
}
}
func running() commitStatus {
return commitStatus{
Context: liveContext,
Status: "pending",
Description: "Has started running",
}
}
func TestMarkSuperseded(t *testing.T) {
t.Parallel()
cases := map[string]struct {
parent commitStatus
wantMark bool
}{
"a cancelled run is marked": {
parent: cancelled(),
wantMark: true,
},
"a laundered skipped status is marked": {
parent: commitStatus{
Context: liveContext,
Status: "skipped",
Description: supersededDesc,
},
wantMark: true,
},
"a genuine failure is left alone": {
parent: commitStatus{
Context: liveContext,
Status: failure,
Description: "Failing after 3m1s",
},
wantMark: false,
},
"a passing run is left alone": {
parent: commitStatus{
Context: liveContext,
Status: "success",
Description: "Successful in 2m52s",
},
wantMark: false,
},
"another context is left alone": {
parent: commitStatus{
Context: "other / other (push)",
Status: failure,
Description: "Has been cancelled",
},
wantMark: false,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
requireTools(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, tc.parent)
out, err := runScript(t, history, api, defaultEnv())
require.NoError(t, err, out)
posted := fake.postedFor(history.parent)
if !tc.wantMark {
require.Empty(t, posted)
return
}
require.Equal(t, []postedStatus{{
Context: liveContext,
// Not `skipped`: Gitea's combined status folds
// that into `success`, which is what made a
// never-tested commit read green.
State: failure,
Description: supersededDesc,
}}, posted)
})
}
}
// A second run must not rewrite what the first one wrote, or every
// later push would post a duplicate status.
func TestMarkSupersededIsIdempotent(t *testing.T) {
t.Parallel()
requireTools(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
for range 2 {
out, err := runScript(t, history, api, defaultEnv())
require.NoError(t, err, out)
}
require.Len(t, fake.postedFor(history.parent), 1)
}
// Renaming the workflow or the job changes the context string Gitea
// uses. The script must say so instead of quietly matching nothing.
func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) {
t.Parallel()
requireTools(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
env := defaultEnv()
env.job = "renamed"
out, err := runScript(t, history, api, env)
require.Error(t, err)
require.Contains(t, out, "renamed")
require.Contains(t, out, liveContext)
require.Empty(t, fake.postedFor(history.parent))
}
// ANCESTOR_LIMIT is a documented knob. A value that is set but unusable
// must abort: handing it to git and discarding the exit status left the
// walk empty and the step green, marking nothing.
func TestMarkSupersededRejectsAnUnparseableAncestorLimit(t *testing.T) {
t.Parallel()
requireTools(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
out, err := runScript(
t, history, api, defaultEnv(), "ANCESTOR_LIMIT=twenty",
)
require.Error(t, err)
require.Contains(t, out, "ANCESTOR_LIMIT")
require.Contains(t, out, "twenty")
require.Empty(t, fake.postedFor(history.parent))
}
// A status read that fails is not the same as a commit with nothing to
// do. Losing curl's exit status through a pipe made the two identical
// and left a laundered commit laundered with no signal.
func TestMarkSupersededFailsOnAnUnreadableAncestorStatus(t *testing.T) {
t.Parallel()
requireTools(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
fake.failStatusRead(history.parent)
out, err := runScript(t, history, api, defaultEnv())
require.Error(t, err)
require.Contains(t, out, history.parent)
require.Contains(t, out, "cannot read commit statuses")
require.Empty(t, fake.postedFor(history.parent))
}
// A shallow clone cannot resolve the parent, so it is indistinguishable
// from a root commit to rev-parse and the walk would exit 0 having
// marked nothing. It must abort instead: dropping `fetch-depth: 0` from
// the checkout step is one edit, and a silent no-op there restores the
// false-green bug this script exists to prevent.
func TestMarkSupersededRejectsAShallowRepository(t *testing.T) {
t.Parallel()
requireTools(t)
history := shallowClone(t, newRepo(t))
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
out, err := runScript(t, history, api, defaultEnv())
require.Error(t, err)
require.Contains(t, out, "shallow repository")
require.Empty(t, fake.postedFor(history.parent))
require.Empty(t, fake.postedFor(history.head))
}
// shallowClone returns the same history as a depth-1 clone. The `file://`
// URL is required: git ignores --depth for a plain local path.
func shallowClone(t *testing.T, history repo) repo {
t.Helper()
dir := t.TempDir()
//nolint:gosec // fixed argv, arguments are test-local paths
cmd := exec.CommandContext(t.Context(), "git", "clone", "-q",
"--depth=1", "file://"+history.dir, dir)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
return repo{dir: dir, head: history.head, parent: history.parent}
}
// The derived context must equal the one Gitea actually uses, which is
// built from the same workflow and job names.
func TestDerivedContextMatchesGitea(t *testing.T) {
t.Parallel()
requireTools(t)
name, job := workflowIdentity(t)
history := newRepo(t)
fake, api := newFakeGitea(t)
fake.setStatus(history.head, running())
fake.setStatus(history.parent, cancelled())
out, err := runScript(t, history, api, scriptEnv{
workflow: name,
job: job,
event: "push",
})
require.NoError(t, err, out)
posted := fake.postedFor(history.parent)
require.Len(t, posted, 1)
require.Equal(t, liveContext, posted[0].Context)
}
// workflowIdentity reads the workflow name and its single job id out of
// the checked-in workflow file.
func workflowIdentity(t *testing.T) (string, string) {
t.Helper()
raw, err := os.ReadFile(workflow)
require.NoError(t, err)
var parsed struct {
Name string `yaml:"name"`
Jobs map[string]any `yaml:"jobs"`
}
require.NoError(t, yaml.Unmarshal(raw, &parsed))
jobs := slices.Collect(maps.Keys(parsed.Jobs))
require.Len(t, jobs, 1)
return parsed.Name, jobs[0]
}
func runScript(
t *testing.T, history repo, api string, env scriptEnv,
extra ...string,
) (string, error) {
t.Helper()
script, err := filepath.Abs(scriptPath)
require.NoError(t, err)
//nolint:gosec // fixed argv, repo-local script under test
cmd := exec.CommandContext(t.Context(), "sh", script)
cmd.Dir = history.dir
cmd.Env = append(os.Environ(),
"GITHUB_API_URL="+api,
"GITHUB_REPOSITORY=sneak/webhooker",
"GITHUB_SHA="+history.head,
"GITHUB_WORKFLOW="+env.workflow,
"GITHUB_JOB="+env.job,
"GITHUB_EVENT_NAME="+env.event,
"GITEA_TOKEN=test-token",
)
cmd.Env = append(cmd.Env, extra...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func newRepo(t *testing.T) repo {
t.Helper()
dir := t.TempDir()
git := func(args ...string) string {
//nolint:gosec // fixed argv, arguments are test constants
cmd := exec.CommandContext(t.Context(), "git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
return strings.TrimSpace(string(out))
}
commit := func(message string) string {
git(
"-c", "user.email=ci@example.invalid",
"-c", "user.name=ci",
"-c", "commit.gpgsign=false",
"commit", "-q", "--allow-empty", "-m", message,
)
return git("rev-parse", "HEAD")
}
git("init", "-q", "-b", "main")
parent := commit("parent")
head := commit("head")
return repo{dir: dir, head: head, parent: parent}
}
func requireTools(t *testing.T) {
t.Helper()
for _, tool := range []string{"sh", "git", "curl", "jq"} {
_, err := exec.LookPath(tool)
if err != nil {
t.Skipf("%s is not installed: %v", tool, err)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package ciscript holds the tests for the repository's CI shell
// scripts in script/. It carries no runtime code: the scripts run on
// the CI runner, not inside the binary, but their behaviour still has
// to be verified by the test suite.
//
// The scripts under test are outside the Go build graph, so `go test`'s
// result cache serves a stale PASS when only a script changed: run the
// container build, or GOFLAGS=-count=1, to trust a result here after
// editing script/.
package ciscript
+162
View File
@@ -0,0 +1,162 @@
package ciscript_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// commitStatus is the part of an entry in Gitea's combined-status
// response that script/ci-mark-superseded reads.
type commitStatus struct {
Context string `json:"context"`
Status string `json:"status"`
Description string `json:"description"`
}
// postedStatus is the part of a create-status request body the script
// writes.
type postedStatus struct {
Context string `json:"context"`
State string `json:"state"`
Description string `json:"description"`
}
// fakeGitea serves the two endpoints the script talks to. Like Gitea,
// the newest status for a context replaces the previous one, so a
// second run of the script sees what the first one wrote.
type fakeGitea struct {
mu sync.Mutex
statuses map[string][]commitStatus
posted map[string][]postedStatus
// failRead is a commit whose combined-status read answers HTTP
// 500, standing in for a status API that is down.
failRead string
}
// newFakeGitea returns the fake and the base URL to hand the script as
// GITHUB_API_URL.
func newFakeGitea(t *testing.T) (*fakeGitea, string) {
t.Helper()
fake := &fakeGitea{
mu: sync.Mutex{},
statuses: map[string][]commitStatus{},
posted: map[string][]postedStatus{},
failRead: "",
}
srv := httptest.NewServer(fake.routes())
t.Cleanup(srv.Close)
return fake, srv.URL
}
func (f *fakeGitea) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(
"GET /repos/{owner}/{repo}/commits/{sha}/status",
f.handleCombined,
)
mux.HandleFunc(
"POST /repos/{owner}/{repo}/statuses/{sha}",
f.handleCreate,
)
return mux
}
func (f *fakeGitea) handleCombined(
w http.ResponseWriter, r *http.Request,
) {
f.mu.Lock()
defer f.mu.Unlock()
sha := r.PathValue("sha")
if f.failRead != "" && f.failRead == sha {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
body := struct {
Statuses []commitStatus `json:"statuses"`
}{Statuses: f.statuses[sha]}
payload, err := json.Marshal(body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(payload)
}
func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) {
var got postedStatus
err := json.NewDecoder(r.Body).Decode(&got)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sha := r.PathValue("sha")
f.mu.Lock()
defer f.mu.Unlock()
f.posted[sha] = append(f.posted[sha], got)
f.replaceLocked(sha, commitStatus{
Context: got.Context,
Status: got.State,
Description: got.Description,
})
w.WriteHeader(http.StatusCreated)
}
// failStatusRead makes the combined-status read for one commit answer
// HTTP 500.
func (f *fakeGitea) failStatusRead(sha string) {
f.mu.Lock()
defer f.mu.Unlock()
f.failRead = sha
}
// setStatus gives a commit its latest status for a context.
func (f *fakeGitea) setStatus(sha string, status commitStatus) {
f.mu.Lock()
defer f.mu.Unlock()
f.replaceLocked(sha, status)
}
// postedFor returns the statuses the script created for a commit.
func (f *fakeGitea) postedFor(sha string) []postedStatus {
f.mu.Lock()
defer f.mu.Unlock()
return append([]postedStatus(nil), f.posted[sha]...)
}
// replaceLocked requires f.mu.
func (f *fakeGitea) replaceLocked(sha string, status commitStatus) {
for i, existing := range f.statuses[sha] {
if existing.Context == status.Context {
f.statuses[sha][i] = status
return
}
}
f.statuses[sha] = append(f.statuses[sha], status)
}
+10 -11
View File
@@ -11,7 +11,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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/config/configtest"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -71,7 +70,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if tt.envValue != "" { if tt.envValue != "" {
t.Setenv( t.Setenv(
@@ -197,7 +196,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
@@ -336,7 +335,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
@@ -389,7 +388,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if env != "" { if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env) t.Setenv("WEBHOOKER_ENVIRONMENT", env)
@@ -434,7 +433,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if set != "" { if set != "" {
t.Setenv("DATA_DIR", set) t.Setenv("DATA_DIR", set)
@@ -499,7 +498,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
@@ -615,7 +614,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
@@ -726,7 +725,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
@@ -798,7 +797,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed != "" { if tt.allowed != "" {
@@ -934,7 +933,7 @@ 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.
configtest.ClearEnv(t) 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)
+7 -8
View File
@@ -8,7 +8,6 @@ import (
"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/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
) )
// dotEnvKey is a throwaway variable name the .env tests write and // dotEnvKey is a throwaway variable name the .env tests write and
@@ -40,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 // ClearEnv uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
configtest.ClearEnv(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))
@@ -55,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 // ClearEnv uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) { func TestLoadDotEnv_AppliesValues(t *testing.T) {
configtest.ClearEnv(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")
@@ -83,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 // ClearEnv uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
path := writeDotEnv( path := writeDotEnv(
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
@@ -133,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) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
+5 -6
View File
@@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -121,7 +120,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
@@ -170,7 +169,7 @@ func runEnvIntCases(
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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
@@ -311,7 +310,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
@@ -477,7 +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.
configtest.ClearEnv(t) 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)
@@ -639,7 +638,7 @@ 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) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
cfg, err := buildConfig(t) cfg, err := buildConfig(t)
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"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/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
) )
// envKeySentryDSN is the variable envSentryDSN reads in production. // envKeySentryDSN is the variable envSentryDSN reads in production.
@@ -101,7 +100,7 @@ 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.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(envKeySentryDSN, tt.value) t.Setenv(envKeySentryDSN, tt.value)
@@ -1,6 +1,4 @@
// Package configtest holds test support for code that reads the package config
// process environment.
package configtest
import ( import (
"os" "os"
@@ -8,12 +6,12 @@ import (
"testing" "testing"
) )
// ClearEnv unsets every variable in the process environment // ClearEnvForTest unsets every variable in the process environment
// for the rest of the test, so a test sees only the variables it sets // 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 // itself, not whatever the developer's shell exports. When the test
// ends it leaves the environment exactly as it found it: each variable // ends it leaves the environment exactly as it found it: each variable
// it unset is put back, and any variable added since is removed. // it unset is put back, and any variable added since is removed.
func ClearEnv(t *testing.T) { func ClearEnvForTest(t *testing.T) {
t.Helper() t.Helper()
present := make(map[string]bool) present := make(map[string]bool)
@@ -7,22 +7,21 @@ import (
"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/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
) )
// TestClearEnv_RemovesAddedVariables pins that a variable set // TestClearEnvForTest_RemovesAddedVariables pins that a variable set
// after the clear other than through t.Setenv, as a test's .env file // 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 // sets one, is gone once the test ends, so it cannot reach the tests
// that run after it. // that run after it.
// //
//nolint:paralleltest // ClearEnv uses t.Setenv. //nolint:paralleltest // ClearEnvForTest uses t.Setenv.
func TestClearEnv_RemovesAddedVariables(t *testing.T) { func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
// The outer clear keeps a value of the key exported in the shell // 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. // from making it a variable the inner clear has to put back.
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Run("loads a .env file after the clear", func(t *testing.T) { t.Run("loads a .env file after the clear", func(t *testing.T) {
configtest.ClearEnv(t) config.ClearEnvForTest(t)
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n") path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path)) require.NoError(t, config.LoadDotEnvFileForTest(path))
@@ -1,55 +0,0 @@
// Package databasetest builds a WebhookDBManager for tests in other
// packages.
package databasetest
import (
"log/slog"
"os"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
)
// NewWebhookDBManager creates a WebhookDBManager backed by the given
// data directory, logging at DEBUG to standard error.
func NewWebhookDBManager(
t *testing.T, dataDir string,
) *database.WebhookDBManager {
t.Helper()
return NewWebhookDBManagerWithLogger(
t,
dataDir,
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
)
}
// NewWebhookDBManagerWithLogger is NewWebhookDBManager with the
// logger supplied by the caller. The per-webhook databases this manager
// opens hand that logger to gormlog, so a test that needs to see the SQL
// the service emits can capture it.
//
// It is built through database.NewWebhookDBManager on a lifecycle that
// is never started, so nothing closes its databases but the caller.
func NewWebhookDBManagerWithLogger(
t *testing.T, dataDir string, log *slog.Logger,
) *database.WebhookDBManager {
t.Helper()
mgr, err := database.NewWebhookDBManager(
fxtest.NewLifecycle(t),
database.WebhookDBManagerParams{
Config: &config.Config{DataDir: dataDir},
Logger: log,
},
)
require.NoError(t, err)
return mgr
}
+11 -12
View File
@@ -13,7 +13,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
) )
// testDataDirPerm is the mode the test data directory is created // testDataDirPerm is the mode the test data directory is created
@@ -134,7 +133,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
// Create the file the way the application does, so the targets // Create the file the way the application does, so the targets
// table has exactly the shape AutoMigrate gives it, then write // table has exactly the shape AutoMigrate gives it, then write
// a leaked row into it the way the association upsert did. // a leaked row into it the way the association upsert did.
initial := databasetest.NewWebhookDBManager(t, dataDir) initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID) _, err := initial.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -157,7 +156,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
clearEventDBSweptMarker(t, seed) clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close()) require.NoError(t, seed.Close())
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID) _, err = mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -173,7 +172,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
// Idempotent: a second open leaves it at zero and does not // Idempotent: a second open leaves it at zero and does not
// error. // error.
again := databasetest.NewWebhookDBManager(t, dataDir) again := database.NewTestWebhookDBManager(dataDir)
_, err = again.GetDB(webhookID) _, err = again.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -196,7 +195,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
webhookID := uuid.New().String() webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String() credential := "T00000000/B00000000/" + uuid.New().String()
initial := databasetest.NewWebhookDBManager(t, dataDir) initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID) _, err := initial.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -231,7 +230,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
"seeded credential is not in the file, so this test proves nothing", "seeded credential is not in the file, so this test proves nothing",
) )
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID) _, err = mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -259,7 +258,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
webhookID := uuid.New().String() webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String() credential := "T00000000/B00000000/" + uuid.New().String()
initial := databasetest.NewWebhookDBManager(t, dataDir) initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID) _, err := initial.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -299,7 +298,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
"test proves nothing", "test proves nothing",
) )
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID) _, err = mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -326,7 +325,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) {
dataDir := eventDBDataDir(t) dataDir := eventDBDataDir(t)
webhookID := uuid.New().String() webhookID := uuid.New().String()
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
_, err := mgr.GetDB(webhookID) _, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -348,7 +347,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, marked.Close()) require.NoError(t, marked.Close())
again := databasetest.NewWebhookDBManager(t, dataDir) again := database.NewTestWebhookDBManager(dataDir)
_, err = again.GetDB(webhookID) _, err = again.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -379,7 +378,7 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, seed.Close()) require.NoError(t, seed.Close())
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
db, err := mgr.GetDB(webhookID) db, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
@@ -397,7 +396,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
dataDir := eventDBDataDir(t) dataDir := eventDBDataDir(t)
webhookID := uuid.New().String() webhookID := uuid.New().String()
mgr := databasetest.NewWebhookDBManager(t, dataDir) mgr := database.NewTestWebhookDBManager(dataDir)
db, err := mgr.GetDB(webhookID) db, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
+1 -3
View File
@@ -30,10 +30,8 @@ type Event struct {
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data. RawQuery is the receiving request's query string // Request data
// as sent, without the leading "?".
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
RawQuery string `gorm:"type:text" json:"rawQuery"`
Headers string `gorm:"type:text" json:"headers"` // JSON Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
+1 -1
View File
@@ -51,7 +51,7 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv {
mgr, err := database.NewWebhookDBManager( mgr, err := database.NewWebhookDBManager(
lc, lc,
database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()}, database.WebhookDBManagerParams{Config: cfg, Logger: l},
) )
require.NoError(t, err) require.NoError(t, err)
+47
View File
@@ -0,0 +1,47 @@
package database
import (
"log/slog"
"os"
"gorm.io/gorm"
)
// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB.
// Intended for use in tests that need a *database.Database without the
// full fx lifecycle. The caller is responsible for closing the underlying
// sql.DB connection.
func NewTestDatabase(db *gorm.DB) *Database {
return &Database{
db: db,
log: slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
}
}
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given
// data directory. Intended for use in tests without the fx lifecycle.
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
return NewTestWebhookDBManagerWithLogger(
dataDir,
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
)
}
// NewTestWebhookDBManagerWithLogger is NewTestWebhookDBManager with the
// logger supplied by the caller. The per-webhook databases this manager
// opens hand that logger to gormlog, so a test that needs to see the SQL
// the service emits can capture it.
func NewTestWebhookDBManagerWithLogger(
dataDir string, log *slog.Logger,
) *WebhookDBManager {
return &WebhookDBManager{
dataDir: dataDir,
log: log,
}
}
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger"
) )
// WebhookDBManagerParams holds the fx dependencies for // WebhookDBManagerParams holds the fx dependencies for
@@ -23,7 +24,7 @@ type WebhookDBManagerParams struct {
fx.In fx.In
Config *config.Config Config *config.Config
Logger *slog.Logger Logger *logger.Logger
} }
// errInvalidCachedDBType indicates a type assertion failure // errInvalidCachedDBType indicates a type assertion failure
@@ -69,7 +70,7 @@ func NewWebhookDBManager(
) (*WebhookDBManager, error) { ) (*WebhookDBManager, error) {
m := &WebhookDBManager{ m := &WebhookDBManager{
dataDir: params.Config.DataDir, dataDir: params.Config.DataDir,
log: params.Logger, log: params.Logger.Get(),
} }
// Create data directory if it doesn't exist. datadir.DirPerm is the // Create data directory if it doesn't exist. datadir.DirPerm is the
+3 -6
View File
@@ -18,7 +18,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -51,7 +50,7 @@ func setupTestWebhookDBManager(
lc, lc,
database.WebhookDBManagerParams{ database.WebhookDBManagerParams{
Config: cfg, Config: cfg,
Logger: l.Get(), Logger: l,
}, },
) )
require.NoError(t, err) require.NoError(t, err)
@@ -118,8 +117,7 @@ func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) {
var logs bytes.Buffer var logs bytes.Buffer
mgr := databasetest.NewWebhookDBManagerWithLogger( mgr := database.NewTestWebhookDBManagerWithLogger(
t,
t.TempDir(), t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)), slog.New(slog.NewTextHandler(&logs, nil)),
) )
@@ -309,8 +307,7 @@ func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
var logs bytes.Buffer var logs bytes.Buffer
mgr := databasetest.NewWebhookDBManagerWithLogger( mgr := database.NewTestWebhookDBManagerWithLogger(
t,
t.TempDir(), t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)), slog.New(slog.NewTextHandler(&logs, nil)),
) )
+18 -4
View File
@@ -21,7 +21,6 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
@@ -60,14 +59,29 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
dataDir := t.TempDir() dataDir := t.TempDir()
log := archiveTestLogger() log := archiveTestLogger()
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler)) sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf(
"file:%s?mode=rwc",
filepath.Join(dataDir, "main.db"),
),
)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = mainDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
mainDB := database.NewTestDatabase(gdb)
require.NoError(t, mainDB.Migrate())
eng := delivery.NewTestEngineWithDB( eng := delivery.NewTestEngineWithDB(
mainDB, mainDB,
databasetest.NewWebhookDBManager(t, dataDir), database.NewTestWebhookDBManager(dataDir),
log, log,
&http.Client{Timeout: 5 * time.Second}, &http.Client{Timeout: 5 * time.Second},
1, 1,
-3
View File
@@ -110,7 +110,6 @@ type Task struct {
MaxRetries int MaxRetries int
Method string Method string
RawQuery string
Headers string Headers string
ContentType string ContentType string
Body *string Body *string
@@ -1753,7 +1752,6 @@ func buildEventFromTask(task *Task) database.Event {
event := database.Event{ event := database.Event{
EntrypointID: task.EntrypointID, EntrypointID: task.EntrypointID,
Method: task.Method, Method: task.Method,
RawQuery: task.RawQuery,
Headers: task.Headers, Headers: task.Headers,
ContentType: task.ContentType, ContentType: task.ContentType,
} }
@@ -2104,7 +2102,6 @@ func buildRecoveryTask(
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: bodyPtr, Body: bodyPtr,
+40 -26
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -18,11 +19,12 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
// iSetup holds common integration test dependencies. // iSetup holds common integration test dependencies.
@@ -43,12 +45,12 @@ func newISetup(t *testing.T) iSetup {
wDB := iSeedWebhookDB(t, dbMgr, wID) wDB := iSeedWebhookDB(t, dbMgr, wID)
return iSetup{ return iSetup{
MainDB: mainDB.DB(), MainDB: mainDB,
DBMgr: dbMgr, DBMgr: dbMgr,
WebhookID: wID, WebhookID: wID,
WebhookDB: wDB, WebhookDB: wDB,
Engine: delivery.NewTestEngineWithDB( Engine: delivery.NewTestEngineWithDB(
mainDB, database.NewTestDatabase(mainDB),
dbMgr, dbMgr,
slog.New(slog.NewTextHandler( slog.New(slog.NewTextHandler(
os.Stderr, os.Stderr,
@@ -62,16 +64,35 @@ func newISetup(t *testing.T) iSetup {
} }
} }
// iMainDB opens a main database through database.Open, the way the func iMainDB(t *testing.T) *gorm.DB {
// service opens it, so these tests cannot pass against journal and
// locking settings production does not use.
func iMainDB(t *testing.T) *database.Database {
t.Helper() t.Helper()
db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler)) dbPath := filepath.Join(
t.TempDir(), "main-test.db",
)
// Opened the way the service opens the main database, so these
// tests cannot pass against journal and locking settings
// production does not use.
sqlDB, err := database.OpenSQLite(
dbPath, database.SQLiteModeCreate,
)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Webhook{},
&database.Target{},
&database.User{},
&database.Setting{},
))
return db return db
} }
@@ -81,7 +102,7 @@ func iDBManager(
) *database.WebhookDBManager { ) *database.WebhookDBManager {
t.Helper() t.Helper()
return databasetest.NewWebhookDBManager(t, t.TempDir()) return database.NewTestWebhookDBManager(t.TempDir())
} }
func iSeedWebhookDB( func iSeedWebhookDB(
@@ -652,11 +673,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
t, s.WebhookDB, s.WebhookID, targetID, 3, t, s.WebhookDB, s.WebhookID, targetID, 3,
) )
// A recovered delivery still carries its event's query string.
require.NoError(t, s.WebhookDB.Model(&database.Event{}).
Where("webhook_id = ?", s.WebhookID).
Update("raw_query", eventQuery).Error)
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, context.Background(), s.WebhookDB,
s.WebhookID, s.WebhookID,
@@ -671,8 +687,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
database.TargetTypeLog, database.TargetTypeLog,
task.TargetType, task.TargetType,
) )
assert.Equal(t, eventQuery, task.RawQuery)
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatalf("expected task %d", i) t.Fatalf("expected task %d", i)
} }
@@ -1133,17 +1147,17 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
mainDB := iMainDB(t) mainDB := iMainDB(t)
webhookID := uuid.New().String() webhookID := uuid.New().String()
iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database") iCreateWebhook(t, mainDB, webhookID, "lost-database")
var logs bytes.Buffer var logs bytes.Buffer
dbMgr := databasetest.NewWebhookDBManagerWithLogger( dbMgr := database.NewTestWebhookDBManagerWithLogger(
t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
) )
t.Cleanup(func() { _ = dbMgr.CloseAll() }) t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB( engine := delivery.NewTestEngineWithDB(
mainDB, dbMgr, database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler), slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1, &http.Client{Timeout: 5 * time.Second}, 1,
) )
@@ -1167,14 +1181,14 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
mainDB := iMainDB(t) mainDB := iMainDB(t)
webhookID := uuid.New().String() webhookID := uuid.New().String()
iCreateWebhook(t, mainDB.DB(), webhookID, "deleted-during-recovery") iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
// The first query to return is recovery's read of the list of // The first query to return is recovery's read of the list of
// webhooks. Deleting the webhook right after it puts the delete // webhooks. Deleting the webhook right after it puts the delete
// between that read and the opening of the webhook's database. // between that read and the opening of the webhook's database.
deleted := false deleted := false
require.NoError(t, mainDB.DB().Callback().Query().After("gorm:query"). require.NoError(t, mainDB.Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) { Register("delete-after-list", func(*gorm.DB) {
if deleted { if deleted {
return return
@@ -1182,16 +1196,16 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
deleted = true deleted = true
require.NoError(t, mainDB.DB().Delete( require.NoError(t, mainDB.Delete(
&database.Webhook{}, "id = ?", webhookID, &database.Webhook{}, "id = ?", webhookID,
).Error) ).Error)
})) }))
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir()) dbMgr := database.NewTestWebhookDBManager(t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() }) t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB( engine := delivery.NewTestEngineWithDB(
mainDB, dbMgr, database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler), slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1, &http.Client{Timeout: 5 * time.Second}, 1,
) )
-6
View File
@@ -11,7 +11,6 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -1991,10 +1990,6 @@ func assertLogLineComplete(
"log line must contain the full request headers", "log line must contain the full request headers",
) )
assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery),
"log line must contain the query string",
)
assert.Contains(t, out, event.EntrypointID, assert.Contains(t, out, event.EntrypointID,
"log line must contain the entrypoint id", "log line must contain the entrypoint id",
) )
@@ -2017,7 +2012,6 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
event := seedEvent( event := seedEvent(
t, db, `{"log-body-marker":"abc123"}`, t, db, `{"log-body-marker":"abc123"}`,
) )
event.RawQuery = eventQuery
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, uuid.New().String(), t, db, event.ID, uuid.New().String(),
+3 -4
View File
@@ -48,9 +48,8 @@ func fSweepSetup(
// //
// Every caller drives the dispatch paths synchronously and has already // Every caller drives the dispatch paths synchronously and has already
// waited for them to return, so anything they queued is in the channel // waited for them to return, so anything they queued is in the channel
// by now, and nothing is waited for. A timer here would race the queued // by now. The short grace covers nothing but scheduler jitter, and is
// tasks: on a busy host it can be due by the time select looks, and // kept small because one of these tests runs the drain forty times.
// select picks at random among the cases that are ready.
func fDrain(e *delivery.Engine) []delivery.Task { func fDrain(e *delivery.Engine) []delivery.Task {
var out []delivery.Task var out []delivery.Task
@@ -60,7 +59,7 @@ func fDrain(e *delivery.Engine) []delivery.Task {
out = append(out, task) out = append(out, task)
case task := <-e.ExportRetryCh(): case task := <-e.ExportRetryCh():
out = append(out, task) out = append(out, task)
default: case <-time.After(25 * time.Millisecond):
return out return out
} }
} }
+26 -9
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -13,9 +14,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
// qdAggregateMarker identifies the queue-depth aggregate in the // qdAggregateMarker identifies the queue-depth aggregate in the
@@ -46,13 +49,27 @@ func (q *qdSyncBuf) String() string {
// qdMainDB opens a main database whose GORM logger is the service's // qdMainDB opens a main database whose GORM logger is the service's
// adapter, writing through log. // adapter, writing through log.
func qdMainDB(t *testing.T, log *slog.Logger) *database.Database { func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB {
t.Helper() t.Helper()
db, err := database.Open(t.TempDir(), log) sqlDB, err := database.OpenSQLite(
filepath.Join(t.TempDir(), "main-gormlog.db"),
database.SQLiteModeCreate,
)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(log)},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Webhook{},
&database.Target{},
))
return db return db
} }
@@ -89,18 +106,18 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
)) ))
mainDB := qdMainDB(t, log) mainDB := qdMainDB(t, log)
dbMgr := databasetest.NewWebhookDBManagerWithLogger( dbMgr := database.NewTestWebhookDBManagerWithLogger(
t, t.TempDir(), log, t.TempDir(), log,
) )
webhookID := uuid.New().String() webhookID := uuid.New().String()
webhookDB := iSeedWebhookDB(t, dbMgr, webhookID) webhookDB := iSeedWebhookDB(t, dbMgr, webhookID)
iCreateWebhook(t, mainDB.DB(), webhookID, "queue-depth-gormlog") iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog")
targetID := uuid.New().String() targetID := uuid.New().String()
iCreateTarget(t, mainDB.DB(), targetID, webhookID, iCreateTarget(t, mainDB, targetID, webhookID,
"queue-depth-gormlog-target", database.TargetTypeHTTP, "queue-depth-gormlog-target", database.TargetTypeHTTP,
iHTTPConfig("https://example.com/hook"), 3, iHTTPConfig("https://example.com/hook"), 3,
) )
@@ -119,7 +136,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
) )
engine := delivery.NewTestEngineWithDB( engine := delivery.NewTestEngineWithDB(
mainDB, database.NewTestDatabase(mainDB),
dbMgr, dbMgr,
log, log,
&http.Client{Timeout: 5 * time.Second}, &http.Client{Timeout: 5 * time.Second},
-4
View File
@@ -39,9 +39,6 @@ type TargetConfigForm struct {
// Timeout is the HTTP target's per-request timeout in seconds, // Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset. // empty when unset.
Timeout string Timeout string
// ForwardQuery is the HTTP target's setting that passes each
// event's query string on to it.
ForwardQuery bool
// Expiry is the database (archive) target's row expiry. // Expiry is the database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is the database (archive) target's rotation. // Rotation is the database (archive) target's rotation.
@@ -70,7 +67,6 @@ func NewTargetConfigForm(
URL: cfg.URL, URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers), Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout), Timeout: FormatTargetTimeout(cfg.Timeout),
ForwardQuery: cfg.ForwardQuery,
}, nil }, nil
case database.TargetTypeSlack: case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config) cfg, err := parseSlackConfig(t.Config)
-7
View File
@@ -171,13 +171,6 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
if cfg.ForwardQuery {
fields = append(fields, ConfigField{
Label: "Query string",
Value: "passed on to this target",
})
}
fields = append(fields, maxRetriesField(t)) fields = append(fields, maxRetriesField(t))
return fields return fields
+1 -3
View File
@@ -223,8 +223,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `",` + Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` + `"headers":{"Authorization":"Bearer sekrit"}}`,
`"forwardQuery":true}`,
MaxRetries: 5, MaxRetries: 5,
}) })
@@ -236,7 +235,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
"Query string": "passed on to this target",
viewMaxRetries: "5", viewMaxRetries: "5",
}, },
fields, fields,
-1
View File
@@ -184,7 +184,6 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID, EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method, Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers, Headers: d.Event.Headers,
Body: d.Event.Body, Body: d.Event.Body,
ContentType: d.Event.ContentType, ContentType: d.Event.ContentType,
@@ -101,7 +101,6 @@ type archivedEvent struct {
WebhookID string WebhookID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
Headers string Headers string
Body string Body string
ContentType string ContentType string
@@ -360,7 +360,6 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error {
"webhook_id": ev.WebhookID, "webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID, "entrypoint_id": ev.EntrypointID,
"method": ev.Method, "method": ev.Method,
"raw_query": ev.RawQuery,
"headers": ev.Headers, "headers": ev.Headers,
"body": ev.Body, "body": ev.Body,
"content_type": ev.ContentType, "content_type": ev.ContentType,
@@ -166,7 +166,6 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
WebhookID: exportWebhookID, WebhookID: exportWebhookID,
EntrypointID: "ep-1", EntrypointID: "ep-1",
Method: "POST", Method: "POST",
RawQuery: eventQuery,
Headers: `{"X-Test":["yes"]}`, Headers: `{"X-Test":["yes"]}`,
Body: body, Body: body,
ContentType: testContentType, ContentType: testContentType,
@@ -216,13 +215,12 @@ func assertExportedRow(
assert.Equal(t, row.WebhookID, ev["webhook_id"]) assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"]) assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"]) assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.RawQuery, ev["raw_query"])
assert.Equal(t, row.Headers, ev["headers"]) assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"]) assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody { if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"]) assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev) assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return return
} }
@@ -231,7 +229,7 @@ func assertExportedRow(
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, binaryBody, string(body)) assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"]) assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev) assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
} }
// TestArchiveExport_Empty proves an archive with nothing in it exports // TestArchiveExport_Empty proves an archive with nothing in it exports
@@ -460,13 +458,8 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
} }
// heapPeak is an io.Writer that discards what it is given and records // heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage twice before // the largest heap it saw at a write. It collects garbage before each
// each reading, so the heap it reads is what is still held. Once is not // reading, so the heap it reads is what is still held.
// enough: the libraries the export calls (regexp, under GORM's table
// names, and encoding/json among them) cache buffers in a sync.Pool,
// which keeps them through one collection, so after one the reading
// counts however many happen to be cached. That varies from run to run
// by about as much as the limit in TestArchiveExport_Streams.
type heapPeak struct { type heapPeak struct {
max uint64 max uint64
} }
@@ -474,7 +467,6 @@ type heapPeak struct {
func (p *heapPeak) Write(b []byte) (int, error) { func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats var m runtime.MemStats
runtime.GC()
runtime.GC() runtime.GC()
runtime.ReadMemStats(&m) runtime.ReadMemStats(&m)
p.max = max(p.max, m.HeapAlloc) p.max = max(p.max, m.HeapAlloc)
@@ -504,8 +496,6 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
export := listExport(t, path) export := listExport(t, path)
// Twice, for the reason heapPeak gives.
runtime.GC()
runtime.GC() runtime.GC()
var start runtime.MemStats var start runtime.MemStats
@@ -10,7 +10,6 @@ import (
"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/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -336,7 +335,7 @@ func TestArchivePathAt(t *testing.T) {
t.Parallel() t.Parallel()
dataDir := t.TempDir() dataDir := t.TempDir()
dbMgr := databasetest.NewWebhookDBManager(t, dataDir) dbMgr := database.NewTestWebhookDBManager(dataDir)
webhook := &database.Webhook{ webhook := &database.Webhook{
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders", BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
} }
@@ -85,7 +85,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) event := seedEvent(t, webhookDB, `{"archived":true}`)
event.RawQuery = eventQuery
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
@@ -114,7 +113,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.ID, rows[0].EventID)
assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.WebhookID, rows[0].WebhookID)
assert.Equal(t, event.Method, rows[0].Method) assert.Equal(t, event.Method, rows[0].Method)
assert.Equal(t, eventQuery, rows[0].RawQuery)
assert.JSONEq(t, `{"archived":true}`, rows[0].Body) assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
} }
-23
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"sort" "sort"
"sync" "sync"
"time" "time"
@@ -33,11 +32,6 @@ type HTTPTargetConfig struct {
URL string `json:"url"` URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"` Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"` Timeout int `json:"timeout,omitempty"`
// ForwardQuery passes each event's query string on to the target,
// appended to URL. Off, the target URL is sent exactly as
// configured.
ForwardQuery bool `json:"forwardQuery,omitempty"`
} }
// httpCore holds the retry, backoff, and circuit-breaker // httpCore holds the retry, backoff, and circuit-breaker
@@ -450,10 +444,6 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
if cfg.ForwardQuery {
appendQuery(req.URL, event.RawQuery)
}
originScoped := applyRequestHeaders( originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(), req, event, cfg, t.eng.userAgent(),
) )
@@ -484,19 +474,6 @@ func (t *httpTarget) doHTTPRequest(
return resp.StatusCode, string(body), dur, nil return resp.StatusCode, string(body), dur, nil
} }
// appendQuery adds an event's query string to a delivery's URL, joined
// with "&" to any query string the target URL already has.
func appendQuery(u *url.URL, rawQuery string) {
switch {
case rawQuery == "":
return
case u.RawQuery == "":
u.RawQuery = rawQuery
default:
u.RawQuery += "&" + rawQuery
}
}
// clientForRequest returns the client for one delivery attempt. // clientForRequest returns the client for one delivery attempt.
// originScoped is the header set applyRequestHeaders built for that // originScoped is the header set applyRequestHeaders built for that
// attempt; a request with neither a per-target timeout nor an // attempt; a request with neither a per-target timeout nor an
-172
View File
@@ -1,172 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// eventQuery is the query string the events in these tests arrived
// with.
const eventQuery = "a=1&b=2"
// httpTargetConfig is the stored configuration of an HTTP target at
// targetURL.
func httpTargetConfig(
t *testing.T, targetURL string, forwardQuery bool,
) string {
t.Helper()
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL, ForwardQuery: forwardQuery,
})
require.NoError(t, err)
return string(cfg)
}
// deliverWithQuery sends one event that arrived with eventQuery to an
// HTTP target configured with cfg, through the path a received event's
// delivery takes, and returns the attempt it recorded.
func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
t.Helper()
s := newISetup(t)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}")
d := iSeedDelivery(
t, s.WebhookDB, event.ID, uuid.NewString(),
database.DeliveryStatusPending,
)
task := iTask(
d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body,
)
task.RawQuery = eventQuery
s.Engine.ExportProcessNewTask(context.TODO(), &task)
var result database.DeliveryResult
require.NoError(t, s.WebhookDB.Where(
"delivery_id = ?", d.ID,
).First(&result).Error)
return result
}
// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to:
// with the target's setting off, the target URL exactly as configured;
// with it on, the event's query string appended, joined with "&" to a
// query string the target URL already has.
func TestDeliverHTTP_ForwardQuery(t *testing.T) {
t.Parallel()
// The target URL's path, without and with a query string of its
// own.
const (
plain = "/in"
withQuery = "/in?key=k"
)
tests := map[string]struct {
path string
forwardQuery bool
want string
}{
"off": {
path: plain, want: plain,
},
"off, the target URL has a query string": {
path: withQuery, want: withQuery,
},
"on": {
path: plain, forwardQuery: true, want: plain + "?" + eventQuery,
},
"on, the target URL has a query string": {
path: withQuery, forwardQuery: true,
want: withQuery + "&" + eventQuery,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusOK)
},
))
t.Cleanup(ts.Close)
result := deliverWithQuery(t, httpTargetConfig(
t, ts.URL+tc.path, tc.forwardQuery,
))
assert.True(t, result.Success)
require.Len(t, received, 1)
assert.Equal(t, tc.want, <-received)
})
}
}
// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the
// credential in a target URL's own query string stays masked once the
// event's query string is appended to it: in a response or error that
// echoes the URL the target was sent, as the event log's Redactor shows
// it, and in the error a failed connection stores.
func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusBadRequest)
},
))
t.Cleanup(ts.Close)
target := &database.Target{
Type: database.TargetTypeHTTP,
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
}
deliverWithQuery(t, target.Config)
require.Len(t, received, 1)
sent := <-received
require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent)
redactor := delivery.NewRedactor(target)
for _, echoed := range []string{sent, ts.URL + sent} {
shown := redactor.Redact("rejected " + echoed)
assert.NotContains(t, shown, secret, echoed)
assert.Contains(t, shown, delivery.RedactionMarker, echoed)
}
// Nothing listens on port 1.
failed := deliverWithQuery(t, httpTargetConfig(
t, "http://127.0.0.1:1/in?token="+secret, true,
))
require.NotEmpty(t, failed.Error)
assert.NotContains(t, failed.Error, secret)
assert.NotContains(t, failed.Error, eventQuery)
}
+3 -4
View File
@@ -9,9 +9,9 @@ import (
) )
// logTarget is a fire-and-forget target that logs the entire // logTarget is a fire-and-forget target that logs the entire
// inbound webhook — the full request body, query string and // inbound webhook — the full request body and headers, plus
// headers, plus the method, content type, and the webhook and // the method, content type, and the webhook and entrypoint
// entrypoint ids — then records a single successful attempt. // ids — then records a single successful attempt.
// //
// This is the one log call in the service that deliberately writes // This is the one log call in the service that deliberately writes
// unbounded client-chosen bytes, so it is the one exception to the // unbounded client-chosen bytes, so it is the one exception to the
@@ -46,7 +46,6 @@ func (t *logTarget) Deliver(
"webhook_id", d.Event.WebhookID, "webhook_id", d.Event.WebhookID,
"entrypoint_id", d.Event.EntrypointID, "entrypoint_id", d.Event.EntrypointID,
"method", d.Event.Method, "method", d.Event.Method,
"raw_query", d.Event.RawQuery,
"content_type", d.Event.ContentType, "content_type", d.Event.ContentType,
"headers", d.Event.Headers, "headers", d.Event.Headers,
"body", d.Event.Body, "body", d.Event.Body,
+16 -19
View File
@@ -162,22 +162,18 @@ func targetSecrets(t *database.Target) []string {
} }
// urlSecrets returns the substrings of a destination URL that // urlSecrets returns the substrings of a destination URL that
// must not survive into a rendered page: the whole URL; its // must not survive into a rendered page: the whole URL, the
// path, unless that is empty or "/"; its query string, and the // parts of it MaskURL elides, and any userinfo.
// request URI that carries it, which a remote echoing the
// request line shows even when the URL has no path; and its
// userinfo and password.
// //
// No length floor is applied to the path, the query string or // No length floor is applied to the path, and none to the
// the userinfo. A short path or a four-byte username is // userinfo. A short path or a four-byte username is treated as
// treated as a credential exactly like a long one, because the // a credential exactly like a long one, because the field takes
// field takes an arbitrary URL and no part of it can be // an arbitrary URL and no part of it can be assumed non-secret —
// assumed non-secret — the same rule MaskURL applies. // the same rule MaskURL applies. headerSecrets does carry a
// headerSecrets does carry a floor, and the difference is // floor, and the difference is deliberate: a header is picked
// deliberate: a header is picked out by a name-shaped guess // out by a name-shaped guess and its value may be ordinary
// and its value may be ordinary text, whereas a URL's path, // text, whereas a URL's path and userinfo are credential
// query string and userinfo are credential material by // material by position.
// position.
func urlSecrets(raw string) []string { func urlSecrets(raw string) []string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -192,11 +188,12 @@ func urlSecrets(raw string) []string {
} }
if parsed.Path != "" && parsed.Path != "/" { if parsed.Path != "" && parsed.Path != "/" {
secrets = append(secrets, parsed.EscapedPath()) requestURI := parsed.RequestURI()
} secrets = append(secrets, requestURI)
if parsed.RawQuery != "" { if escaped := parsed.EscapedPath(); escaped != requestURI {
secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery) secrets = append(secrets, escaped)
}
} }
if parsed.User != nil { if parsed.User != nil {
-42
View File
@@ -202,48 +202,6 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
} }
} }
// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an
// HTTP target URL whose credential is all in its query string.
// Written with or without the "/", the request line sends it
// as "/?token=…", and a target passing the event's query string
// on sends that after an "&". The event's part stays visible:
// the event's page shows it anyway.
func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
marker := delivery.RedactionMarker
// An echoed request line, and what the event log shows of it.
echoes := map[string]string{
"POST /?token=" + secret + " HTTP/1.1": "POST " + marker +
" HTTP/1.1",
"POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker +
" HTTP/1.1",
"POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " +
marker + "&a=1&b=2 HTTP/1.1",
"POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" +
marker + "&a=1&b=2 HTTP/1.1",
}
for _, dest := range []string{
"https://example.com/?token=" + secret,
"https://example.com?token=" + secret,
} {
r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + dest + `"}`,
})
for echoed, want := range echoes {
assert.Equal(
t, want, r.Redact(echoed), "%s: %s", dest, echoed,
)
}
}
}
// TestRedactor_LeavesUnrelatedTextAlone pins that the // TestRedactor_LeavesUnrelatedTextAlone pins that the
// redactor matches literally: it does not guess at what a // redactor matches literally: it does not guess at what a
// secret looks like, so ordinary response content survives. // secret looks like, so ordinary response content survives.
+1 -4
View File
@@ -3,7 +3,6 @@ package gormlog_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -14,7 +13,6 @@ import (
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
@@ -130,7 +128,7 @@ func readFirstBootSecrets(
func bootAtDebug(t *testing.T, dataDir string) string { func bootAtDebug(t *testing.T, dataDir string) string {
t.Helper() t.Helper()
configtest.ClearEnv(t) config.ClearEnvForTest(t)
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
t.Setenv("DATA_DIR", dataDir) t.Setenv("DATA_DIR", dataDir)
@@ -147,7 +145,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func(l *logger.Logger) *slog.Logger { return l.Get() },
config.New, config.New,
database.New, database.New,
session.New, session.New,
-1
View File
@@ -310,7 +310,6 @@ func createReplayDelivery(
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: replayBody(event.Body), Body: replayBody(event.Body),
@@ -26,9 +26,6 @@ const paramDeliveryID = "deliveryID"
// dispatches to it: the notifier is recorded, not run. // dispatches to it: the notifier is recorded, not run.
const replayTargetURL = "http://93.184.216.34/hook" const replayTargetURL = "http://93.184.216.34/hook"
// replayEventQuery is the query string a seeded event arrived with.
const replayEventQuery = "a=1&b=2"
// seedFailedDelivery records an event, a terminally failed delivery of // seedFailedDelivery records an event, a terminally failed delivery of
// it to the given target, and the attempt that failed. // it to the given target, and the attempt that failed.
func seedFailedDelivery( func seedFailedDelivery(
@@ -45,7 +42,6 @@ func seedFailedDelivery(
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID, EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost, Method: http.MethodPost,
RawQuery: replayEventQuery,
Headers: `{"X-Test":["yes"]}`, Headers: `{"X-Test":["yes"]}`,
Body: `{"replay":"me"}`, Body: `{"replay":"me"}`,
ContentType: contentTypeJSON, ContentType: contentTypeJSON,
@@ -300,10 +296,6 @@ func assertReplayTask(
"replay must use the target's current configuration", "replay must use the target's current configuration",
) )
assert.Equal(t, event.Method, task.Method) assert.Equal(t, event.Method, task.Method)
assert.Equal(
t, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType) assert.Equal(t, event.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum) assert.Equal(t, 1, task.AttemptNum)
+1 -2
View File
@@ -324,8 +324,7 @@ func loadEventLogRows(
var rows []eventLogRow var rows []eventLogRow
err = eventsWithStatus(webhookDB, webhookID, statuses).Select( err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents, err return rows, totalEvents, err
+7 -29
View File
@@ -17,23 +17,19 @@ import (
// bytes rather than characters, so the cap bounds the page in // bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite // bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an // rather than in Go is the point of the projection — an
// oversized body, query string or set of request headers never // oversized body or set of request headers never becomes a Go
// becomes a Go string at all. // string at all.
const eventLogColumns = "id, created_at, method, content_type, " + const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, " + "resubmitted_from_id, entrypoint_id, " +
"substr(cast(raw_query as blob), 1, ?) AS raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, " +
"substr(cast(headers as blob), 1, ?) AS headers, " + "substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " + "length(cast(headers as blob)) AS headers_bytes, " +
"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 // eventColumns is eventLogColumns for the event's own page, which
// shows the whole body, the whole query string and every request // shows the whole body and every request header.
// header.
const eventColumns = "id, created_at, method, content_type, " + const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, raw_query, " + "resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " + "length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " + "cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
@@ -61,13 +57,6 @@ type EventLogView struct {
// entrypoint's secret. // entrypoint's secret.
Entrypoint string Entrypoint string
// RawQuery is the query string the event arrived with.
// RawQueryCut reports one left out, RawQuery then empty, because
// it holds more than maxRenderedBodyBytes; only the event log
// leaves it out.
RawQuery string
RawQueryCut bool
// Headers is the event's request headers as text, one // Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut // "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than // reports headers left out because they hold more than
@@ -96,9 +85,9 @@ func (v EventLogView) ResubmittedFrom() bool {
} }
// eventLogRow is one row of the event log projection, or of // eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its query string, headers and // eventColumns. In the event log its headers and body columns
// body columns arrive already cut to the cap by SQLite, each with // arrive already cut to the cap by SQLite, each with its true
// its true size beside it. // size beside it.
type eventLogRow struct { type eventLogRow struct {
ID string ID string
CreatedAt time.Time CreatedAt time.Time
@@ -106,8 +95,6 @@ type eventLogRow struct {
ContentType string ContentType string
ResubmittedFromID *string ResubmittedFromID *string
EntrypointID string EntrypointID string
RawQuery string
RawQueryBytes int64
Headers string Headers string
HeadersBytes int64 HeadersBytes int64
Body []byte Body []byte
@@ -127,13 +114,6 @@ func (r *eventLogRow) view(
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
rawQuery := r.RawQuery
rawQueryCut := r.RawQueryBytes > int64(len(rawQuery))
if rawQueryCut {
rawQuery = ""
}
return EventLogView{ return EventLogView{
ID: r.ID, ID: r.ID,
Method: r.Method, Method: r.Method,
@@ -143,8 +123,6 @@ func (r *eventLogRow) view(
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
RawQuery: rawQuery,
RawQueryCut: rawQueryCut,
Headers: strings.Join(headers, "\n"), Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
+3 -75
View File
@@ -1,16 +1,13 @@
package handlers_test package handlers_test
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -20,15 +17,14 @@ import (
// arrivedAt is how a page names the entrypoint an event arrived at. // arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string { func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name + return `Arrived at <span class="text-gray-900">` + name + `</span>`
`</span>`
} }
// copiedRequestArrivedAt is how a page names, for a resubmitted copy, // copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at. // the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string { func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at ` + return `The request it copies arrived at <span class="text-gray-900">` +
`<span class="text-gray-900 wrap-anywhere">` + name + `</span>` name + `</span>`
} }
// headerBox is how a page shows an event's request header lines: as // headerBox is how a page shows an event's request header lines: as
@@ -119,7 +115,6 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper() t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender")) assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, "No query string.")
assert.Contains(t, page, headerBox( assert.Contains(t, page, headerBox(
"Accept: */*", "Accept: */*",
"User-Agent: shop/1 build\t7", "User-Agent: shop/1 build\t7",
@@ -335,70 +330,3 @@ func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
}) })
} }
} }
// TestHandleWebhook_StoresAndShowsTheQueryString posts to an
// entrypoint's URL with a query string and proves the event stores it
// as sent, and shows it escaped in the event log and on its own page,
// in a box like the one the request headers show in.
func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := seedEntrypoint(t, f.db, f.webhook.ID)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", ep.Path)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, "a=1&b=2", stored.RawQuery)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, headerBox("a=1&amp;b=2"))
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox("a=1&amp;b=2"))
}
// TestEventRequest_QueryStringOverTheLimit proves the event log leaves
// out a query string that holds more than it shows of a body, and links
// to the event's own page, which shows it whole.
func TestEventRequest_QueryStringOverTheLimit(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, `{}`, time.Now())
query := "q=" + strings.Repeat("x", bodyCap)
require.NoError(t, f.webhookDB.Model(event).Update(
"raw_query", query,
).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, `<a href="/hook/`+f.webhook.ID+`/events/`+
event.ID+`" class="btn-small">Show the query string</a>`)
assert.NotContains(t, page, "q=x")
assert.Less(t, len(page), 4*bodyCap)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox(query))
assert.NotContains(t, w.Body.String(), "Show the query string")
}
+1 -3
View File
@@ -30,7 +30,6 @@ type resubmitSource struct {
ID string ID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
Headers string Headers string
ContentType string ContentType string
Body []byte Body []byte
@@ -40,7 +39,7 @@ type resubmitSource struct {
// The cast to blob is what makes the driver hand back the stored bytes // The cast to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, the same reason eventBodyQuery // rather than a string conversion, the same reason eventBodyQuery
// casts. // casts.
const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " + const resubmitColumns = "id, entrypoint_id, method, headers, " +
"content_type, cast(body as blob) AS body" "content_type, cast(body as blob) AS body"
// HandleEventResubmit re-injects a stored event as a new undelivered // HandleEventResubmit re-injects a stored event as a new undelivered
@@ -194,7 +193,6 @@ func (h *Handlers) queueResubmit(
WebhookID: webhook.ID, WebhookID: webhook.ID,
EntrypointID: src.EntrypointID, EntrypointID: src.EntrypointID,
Method: src.Method, Method: src.Method,
RawQuery: src.RawQuery,
HeadersJSON: src.Headers, HeadersJSON: src.Headers,
ContentType: src.ContentType, ContentType: src.ContentType,
Body: src.Body, Body: src.Body,
+3 -10
View File
@@ -22,13 +22,9 @@ import (
// dispatches to it: the notifier is recorded, not run. // dispatches to it: the notifier is recorded, not run.
const resubmitTargetURL = "http://93.184.216.34/hook" const resubmitTargetURL = "http://93.184.216.34/hook"
// resubmitEventHeaders and resubmitEventQuery are the stored header // resubmitEventHeaders is the stored header JSON a seeded event
// JSON and query string a seeded event carries, so a test can prove the // carries, so a test can prove the copy takes it verbatim.
// copy takes them verbatim. const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
const (
resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
resubmitEventQuery = "a=1&b=2"
)
// seedStoredEvent records one event in a webhook's own database with // seedStoredEvent records one event in a webhook's own database with
// no deliveries at all, which is the state a captured event is in when // no deliveries at all, which is the state a captured event is in when
@@ -47,7 +43,6 @@ func seedStoredEvent(
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID, EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost, Method: http.MethodPost,
RawQuery: resubmitEventQuery,
Headers: resubmitEventHeaders, Headers: resubmitEventHeaders,
Body: body, Body: body,
ContentType: contentTypeJSON, ContentType: contentTypeJSON,
@@ -207,7 +202,6 @@ func assertEventCopy(
t.Helper() t.Helper()
assert.Equal(t, original.Method, fresh.Method) assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, resubmitEventQuery, fresh.RawQuery)
assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body) assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes) assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
@@ -242,7 +236,6 @@ func assertResubmitTask(
assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, fresh.Method, task.Method) assert.Equal(t, fresh.Method, task.Method)
assert.Equal(t, fresh.RawQuery, task.RawQuery)
assert.Equal(t, fresh.Headers, task.Headers) assert.Equal(t, fresh.Headers, task.Headers)
assert.Equal(t, fresh.ContentType, task.ContentType) assert.Equal(t, fresh.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum) assert.Equal(t, 1, task.AttemptNum)
-2
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
@@ -250,7 +249,6 @@ func newTestAppWithConfig(
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func(l *logger.Logger) *slog.Logger { return l.Get() },
func() *config.Config { return cfg }, func() *config.Config { return cfg },
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
+2 -2
View File
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -135,7 +135,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
mw := middlewaretest.New(t, log.Get(), cfg, sess) mw := middleware.NewForTest(log.Get(), cfg, sess)
var handlerReached bool var handlerReached bool
+2 -7
View File
@@ -173,9 +173,6 @@ type targetFormInput struct {
Headers string Headers string
// Timeout is an HTTP target's per-request timeout in seconds. // Timeout is an HTTP target's per-request timeout in seconds.
Timeout string Timeout string
// ForwardQuery is an HTTP target's checkbox that passes each
// event's query string on to it.
ForwardQuery bool
// MaxRetries is an HTTP or Slack target's max_retries. // MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string MaxRetries string
// Expiry is a database (archive) target's row expiry. // Expiry is a database (archive) target's row expiry.
@@ -203,7 +200,6 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
URL: r.PostFormValue("url"), URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"), Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"), Timeout: r.PostFormValue("timeout"),
ForwardQuery: r.PostFormValue("forward_query") != "",
MaxRetries: r.PostFormValue("max_retries"), MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"), Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"), Rotation: r.PostFormValue("rotation"),
@@ -236,8 +232,8 @@ func (h *Handlers) buildTargetConfig(
} }
// buildHTTPTargetConfig builds config JSON for an HTTP target: an // buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers, timeout and // SSRF-validated destination plus the optional headers and timeout
// query string setting the delivery path honours. // the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig( func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context, ctx context.Context,
in targetFormInput, in targetFormInput,
@@ -263,7 +259,6 @@ func (h *Handlers) buildHTTPTargetConfig(
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
ForwardQuery: in.ForwardQuery,
}) })
return configJSON, "", err return configJSON, "", err
@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"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/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/middleware"
) )
// targetSecretSegments are the path segments of an incoming-webhook // targetSecretSegments are the path segments of an incoming-webhook
@@ -65,8 +65,7 @@ func postTargetCreate(
t.Helper() t.Helper()
logBuf := new(bytes.Buffer) logBuf := new(bytes.Buffer)
mw := middlewaretest.New( mw := middleware.NewForTest(
t,
slog.New(slog.NewJSONHandler( slog.New(slog.NewJSONHandler(
logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}, logBuf, &slog.HandlerOptions{Level: slog.LevelInfo},
)), )),
+2 -3
View File
@@ -24,7 +24,7 @@ import (
"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/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/middleware"
) )
// errClientGone is the write failure of a client that has gone away. // errClientGone is the write failure of a client that has gone away.
@@ -310,8 +310,7 @@ func limitedServer(
const sendBuffer = 4 << 10 const sendBuffer = 4 << 10
logBuf := new(bytes.Buffer) logBuf := new(bytes.Buffer)
mw := middlewaretest.New( mw := middleware.NewForTest(
t,
slog.New(slog.NewJSONHandler(logBuf, nil)), slog.New(slog.NewJSONHandler(logBuf, nil)),
&config.Config{Environment: config.EnvironmentDev}, &config.Config{Environment: config.EnvironmentDev},
nil, nil,
-1
View File
@@ -81,7 +81,6 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
URL: cfg.URL, URL: cfg.URL,
Headers: cfg.Headers, Headers: cfg.Headers,
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery,
MaxRetries: strconv.Itoa(target.MaxRetries), MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry, Expiry: cfg.Expiry,
Rotation: cfg.Rotation, Rotation: cfg.Rotation,
-53
View File
@@ -442,59 +442,6 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
assert.Contains(t, page, `class="label">Archive rotation</label>`) assert.Contains(t, page, `class="label">Archive rotation</label>`)
} }
// TestHandleTarget_ForwardQuery covers the HTTP target's setting that
// passes each event's query string on to it: the add target form
// stores it checked, the edit form starts with it checked and turns it
// off when saved unchecked, and both forms come back with it checked
// when refused.
func TestHandleTarget_ForwardQuery(t *testing.T) {
t.Parallel()
const checkbox = `name="forward_query" value="on" checked`
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/hook/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "forwarding")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", editOriginalURL)
form.Set("forward_query", "on")
w := serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
edit := editForm(editOriginalURL, "", "")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
form.Set("url", editBlockedURL)
w = serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "data-forward-query")
}
// TestHandleTargetEditSubmit_Rejects covers every submission that // TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage. // must not reach storage.
// //
-4
View File
@@ -230,7 +230,6 @@ type eventSource struct {
WebhookID string WebhookID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
HeadersJSON string HeadersJSON string
ContentType string ContentType string
Body []byte Body []byte
@@ -246,7 +245,6 @@ func (s eventSource) event() *database.Event {
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
EntrypointID: s.EntrypointID, EntrypointID: s.EntrypointID,
Method: s.Method, Method: s.Method,
RawQuery: s.RawQuery,
Headers: s.HeadersJSON, Headers: s.HeadersJSON,
Body: string(s.Body), Body: string(s.Body),
BodyBytes: int64(len(s.Body)), BodyBytes: int64(len(s.Body)),
@@ -266,7 +264,6 @@ func requestEventSource(
WebhookID: entrypoint.WebhookID, WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID, EntrypointID: entrypoint.ID,
Method: r.Method, Method: r.Method,
RawQuery: r.URL.RawQuery,
HeadersJSON: string(headersJSON), HeadersJSON: string(headersJSON),
ContentType: r.Header.Get("Content-Type"), ContentType: r.Header.Get("Content-Type"),
Body: body, Body: body,
@@ -444,7 +441,6 @@ func buildDeliveryTasks(
TargetConfig: targets[i].Config, TargetConfig: targets[i].Config,
MaxRetries: targets[i].MaxRetries, MaxRetries: targets[i].MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: bodyPtr, Body: bodyPtr,
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
// floodRequests is the number of distinct invented paths each flood // floodRequests is the number of distinct invented paths each flood
@@ -84,7 +83,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
TrustedProxies: trustedProxies("192.0.2.1/32"), TrustedProxies: trustedProxies("192.0.2.1/32"),
} }
return middlewaretest.New(t, log, cfg, nil), buf return middleware.NewForTest(log, cfg, nil), buf
} }
// capturingTextMiddleware is capturingMiddleware for the other handler // capturingTextMiddleware is capturingMiddleware for the other handler
@@ -108,7 +107,7 @@ func capturingTextMiddleware(
TrustedProxies: trustedProxies("192.0.2.1/32"), TrustedProxies: trustedProxies("192.0.2.1/32"),
} }
return middlewaretest.New(t, log, cfg, nil), buf return middleware.NewForTest(log, cfg, nil), buf
} }
// accessLogRouter mirrors the production route shapes that an // accessLogRouter mirrors the production route shapes that an
+2 -3
View File
@@ -12,7 +12,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
const ( const (
@@ -134,8 +133,8 @@ func clientLogLines(
TrustedProxies: trustedProxies(trustedProxyCIDR), TrustedProxies: trustedProxies(trustedProxyCIDR),
} }
m := middlewaretest.New( m := middleware.NewForTest(
t, log, cfg, newTestSessionManager(t, cfg), log, cfg, newTestSessionManager(cfg, log, nil),
) )
handler := m.Logging()(site.build(m)) handler := m.Logging()(site.build(m))
+2 -3
View File
@@ -41,7 +41,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
// bodyLimitBytes is the MaxBodySize cap these tests install. Any // bodyLimitBytes is the MaxBodySize cap these tests install. Any
@@ -156,9 +155,9 @@ func capturingBoundMiddleware(
ReceiverRateLimit: receiverLimitPerMinute, ReceiverRateLimit: receiverLimitPerMinute,
} }
sess := newTestSessionManager(t, cfg) sess := newTestSessionManager(cfg, log, nil)
return middlewaretest.New(t, log, cfg, sess), buf return middleware.NewForTest(log, cfg, sess), buf
} }
// unreachable is a next-handler that fails the test if the middleware // unreachable is a next-handler that fails the test if the middleware
+3 -3
View File
@@ -236,9 +236,9 @@ func TestLoginGuard_SemaphoreBoundsConcurrentVerifications(
// rendezvousDeadlock is the deadlock guard described below. // rendezvousDeadlock is the deadlock guard described below.
// It is orders of magnitude longer than any scheduling delay, // It is orders of magnitude longer than any scheduling delay,
// so it never decides the result, and well inside the 90s // so it never decides the result, and well inside script/test's
// package timeout of the Dockerfile's test phase, so a wedge // 30s timeout, so a wedge fails on the assertion instead of
// fails on the assertion instead of blowing that timeout. // blowing the package timeout.
rendezvousDeadlock = 5 * time.Second rendezvousDeadlock = 5 * time.Second
) )
+1 -1
View File
@@ -151,7 +151,7 @@ var _ httpmetrics.Recorder = boundedLabelRecorder{}
// Metrics returns middleware that records Prometheus HTTP metrics // Metrics returns middleware that records Prometheus HTTP metrics
// with the Middleware's one recorder, which New builds on the registry // with the Middleware's one recorder, which New builds on the registry
// it is given: in the application, the one the /metrics route serves. // the /metrics route serves and NewForTest on a registry of its own.
// Every call reuses that recorder, so any number of routers can // Every call reuses that recorder, so any number of routers can
// install it. // install it.
func (s *Middleware) Metrics() func(http.Handler) http.Handler { func (s *Middleware) Metrics() func(http.Handler) http.Handler {
+8 -9
View File
@@ -16,7 +16,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
const ( const (
@@ -71,8 +70,8 @@ func metricsTestRouter(
Environment: "prod", Environment: "prod",
ReceiverRateLimit: receiverLimit, ReceiverRateLimit: receiverLimit,
} }
m := middlewaretest.New( m := middleware.NewForTest(
t, log, cfg, newTestSessionManager(t, cfg), log, cfg, newTestSessionManager(cfg, log, nil),
) )
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
@@ -456,11 +455,11 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
) )
} }
// TestMetrics_WorksOnMiddlewaretestNew pins that a Middleware built // TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
// by middlewaretest.New has a recorder of its own: its Metrics() // by NewForTest has a recorder of its own: its Metrics() serves a
// serves a request instead of panicking, and a second one does not // request instead of panicking, and a second one does not collide
// collide with the first. // with the first.
func TestMetrics_WorksOnMiddlewaretestNew(t *testing.T) { func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
t.Parallel() t.Parallel()
log := slog.New(slog.DiscardHandler) log := slog.New(slog.DiscardHandler)
@@ -470,7 +469,7 @@ func TestMetrics_WorksOnMiddlewaretestNew(t *testing.T) {
}) })
for range 2 { for range 2 {
h := middlewaretest.New(t, log, cfg, nil).Metrics()(ok) h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, okRoute, nil, t.Context(), http.MethodGet, okRoute, nil,
+9 -6
View File
@@ -22,6 +22,7 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -154,7 +155,7 @@ const (
type MiddlewareParams struct { type MiddlewareParams struct {
fx.In fx.In
Logger *slog.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Session *session.Session Session *session.Session
@@ -168,10 +169,12 @@ type Middleware struct {
params *MiddlewareParams params *MiddlewareParams
session *session.Session session *session.Session
// metricsRecorder records the inbound HTTP metrics on // metricsRecorder records the inbound HTTP metrics. New builds
// params.Registry. It is built once per Middleware and Metrics // it on the registry /metrics serves, NewForTest on a registry
// reuses it, because building it registers its collectors, and a // of its own. Either way it is built once per Middleware and
// second registration on the same registry panics. // Metrics reuses it, because building it registers its
// collectors, and a second registration on the same registry
// panics.
metricsRecorder httpmetrics.Recorder metricsRecorder httpmetrics.Recorder
// loginGuard counts failed credential verifications and bounds // loginGuard counts failed credential verifications and bounds
@@ -190,7 +193,7 @@ func New(
) (*Middleware, error) { ) (*Middleware, error) {
s := new(Middleware) s := new(Middleware)
s.params = &params s.params = &params
s.log = params.Logger s.log = params.Logger.Get()
s.session = params.Session s.session = params.Session
s.metricsRecorder = prommetrics.NewRecorder( s.metricsRecorder = prommetrics.NewRecorder(
prommetrics.Config{Registry: params.Registry}, prommetrics.Config{Registry: params.Registry},
+71 -87
View File
@@ -14,34 +14,36 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"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/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
const testKeySize = 32 const testKeySize = 32
// testMiddleware creates a Middleware with minimal dependencies // testMiddleware creates a Middleware with minimal dependencies
// for testing. It uses a real session.Session. // for testing. It uses a real session.Session backed by an
// in-memory cookie store.
func testMiddleware( func testMiddleware(
t *testing.T, t *testing.T,
env string, env string,
) (*middleware.Middleware, *session.Session) { ) (*middleware.Middleware, *session.Session) {
t.Helper() t.Helper()
return testMiddlewareWithIdleTimeout(t, env, 0) m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil)
return m, s
} }
// testMiddlewareWithIdleTimeout is testMiddleware with a // testMiddlewareWithSessionClock is testMiddleware with a
// configurable session idle timeout, for the session-expiry tests. // configurable session idle timeout and a manually advanced clock,
func testMiddlewareWithIdleTimeout( // for the session-expiry tests. A nil clock uses the real one.
func testMiddlewareWithSessionClock(
t *testing.T, t *testing.T,
env string, env string,
idleTimeout time.Duration, idleTimeout time.Duration,
) (*middleware.Middleware, *session.Session) { clock *fakeClock,
) (*middleware.Middleware, *session.Session, *fakeClock) {
t.Helper() t.Helper()
log := slog.New(slog.NewTextHandler( log := slog.New(slog.NewTextHandler(
@@ -54,44 +56,59 @@ func testMiddlewareWithIdleTimeout(
SessionIdleTimeout: idleTimeout, SessionIdleTimeout: idleTimeout,
} }
sessManager := newTestSessionManager(t, cfg) sessManager := newTestSessionManager(cfg, log, clock)
m := middlewaretest.New(t, log, cfg, sessManager) m := middleware.NewForTest(log, cfg, sessManager)
return m, sessManager return m, sessManager, clock
} }
// newTestSessionManager builds the real session.Session the // newTestSessionManager builds the real session.Session the
// middleware tests run against, through session.New, with its key // middleware tests run against: an in-memory cookie store with a
// in a main database of its own. // known key, and optionally a manually advanced clock.
func newTestSessionManager( func newTestSessionManager(
t *testing.T,
cfg *config.Config, cfg *config.Config,
log *slog.Logger,
clock *fakeClock,
) *session.Session { ) *session.Session {
t.Helper() key := make([]byte, testKeySize)
discard := slog.New(slog.DiscardHandler) for i := range key {
key[i] = byte(i)
}
db, err := database.Open(t.TempDir(), discard) store := session.NewStore(key)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() }) var now func() time.Time
lc := fxtest.NewLifecycle(t) if clock != nil {
now = clock.Now
}
sessManager, err := session.New(lc, session.Params{ return session.NewForTest(store, cfg, log, key, now)
Config: cfg, }
Database: db,
Logger: discard,
})
require.NoError(t, err)
// The start hook reads the key from db and builds the cookie // fakeClock is a manually advanced clock, so session expiry can be
// store. // tested without sleeping.
lc.RequireStart() type fakeClock struct {
t.Cleanup(lc.RequireStop) t time.Time
}
return sessManager func (c *fakeClock) Now() time.Time {
return c.t
}
func (c *fakeClock) Advance(d time.Duration) {
c.t = c.t.Add(d)
}
// newFakeClock returns a clock started at a fixed instant.
func newFakeClock() *fakeClock {
return &fakeClock{
t: time.Date(
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
),
}
} }
// --- Logging Middleware Tests --- // --- Logging Middleware Tests ---
@@ -566,40 +583,6 @@ func sessionCookies(
return out return out
} }
// aged re-issues the session cookie in cookies with both of its
// timestamps moved back by d: the cookie as it stands once d has
// passed, so session expiry can be tested without sleeping.
func aged(
t *testing.T,
sessManager *session.Session,
cookies []*http.Cookie,
d time.Duration,
) []*http.Cookie {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
for _, c := range cookies {
req.AddCookie(c)
}
sess, err := sessManager.Get(req)
require.NoError(t, err)
for _, key := range []string{session.CreatedAtKey, session.LastSeenKey} {
at, ok := sess.Values[key].(int64)
require.True(t, ok, "the session has no %s", key)
sess.Values[key] = at - int64(d/time.Second)
}
w := httptest.NewRecorder()
require.NoError(t, sessManager.Save(req, w, sess))
return sessionCookies(w)
}
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin( func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
t *testing.T, t *testing.T,
) { ) {
@@ -607,11 +590,13 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
idle := time.Hour idle := time.Hour
m, sessManager := testMiddlewareWithIdleTimeout( m, sessManager, clock := testMiddlewareWithSessionClock(
t, config.EnvironmentDev, idle, t, config.EnvironmentDev, idle, newFakeClock(),
) )
cookies := aged(t, sessManager, loginCookies(t, sessManager), idle) cookies := loginCookies(t, sessManager)
clock.Advance(idle)
called, w := runAuthed(t, m, cookies) called, w := runAuthed(t, m, cookies)
@@ -636,12 +621,14 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
idle := time.Hour idle := time.Hour
m, sessManager := testMiddlewareWithIdleTimeout( m, sessManager, clock := testMiddlewareWithSessionClock(
t, config.EnvironmentDev, idle, t, config.EnvironmentDev, idle, newFakeClock(),
) )
cookies := loginCookies(t, sessManager)
// Activity halfway through the idle window. // Activity halfway through the idle window.
cookies := aged(t, sessManager, loginCookies(t, sessManager), idle/2) clock.Advance(idle / 2)
called, w := runAuthed(t, m, cookies) called, w := runAuthed(t, m, cookies)
require.True(t, called, "handler should run while valid") require.True(t, called, "handler should run while valid")
@@ -653,22 +640,16 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
) )
// Past the original deadline. The refreshed cookie is still // Past the original deadline. The refreshed cookie is still
// good; the original one is not. A minute short of the idle // good; the original one is not.
// window leaves room for the real clock, which the session clock.Advance(idle - time.Second)
// reads, to tick on while the test runs.
later := idle - time.Minute
calledRefreshed, _ := runAuthed( calledRefreshed, _ := runAuthed(t, m, refreshed)
t, m, aged(t, sessManager, refreshed, later),
)
assert.True( assert.True(
t, calledRefreshed, t, calledRefreshed,
"refreshed session should outlive the original deadline", "refreshed session should outlive the original deadline",
) )
calledStale, staleW := runAuthed( calledStale, staleW := runAuthed(t, m, cookies)
t, m, aged(t, sessManager, cookies, later),
)
assert.False( assert.False(
t, calledStale, t, calledStale,
"the pre-refresh cookie carries the old idle deadline", "the pre-refresh cookie carries the old idle deadline",
@@ -681,8 +662,8 @@ func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh(
) { ) {
t.Parallel() t.Parallel()
m, sessManager := testMiddlewareWithIdleTimeout( m, sessManager, _ := testMiddlewareWithSessionClock(
t, config.EnvironmentDev, time.Hour, t, config.EnvironmentDev, time.Hour, newFakeClock(),
) )
// A session cookie that exists but was never authenticated. // A session cookie that exists but was never authenticated.
@@ -943,9 +924,12 @@ func metricsAuthMiddleware(
MetricsPassword: "secret", MetricsPassword: "secret",
} }
return middlewaretest.New( key := make([]byte, testKeySize)
t, log, cfg, newTestSessionManager(t, cfg), store := session.NewStore(key)
)
sessManager := session.NewForTest(store, cfg, log, key, nil)
return middleware.NewForTest(log, cfg, sessManager)
} }
// runMetricsAuthRequest sends a GET /metrics request with the // runMetricsAuthRequest sends a GET /metrics request with the
@@ -1,42 +0,0 @@
// Package middlewaretest builds a Middleware for tests in other
// packages.
package middlewaretest
import (
"log/slog"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
)
// New builds a Middleware through middleware.New, on a
// lifecycle that is never started.
//
// Its metrics recorder writes to a fresh registry of its own, so
// Metrics() works on it and two of them never collide.
func New(
t *testing.T,
log *slog.Logger,
cfg *config.Config,
sess *session.Session,
) *middleware.Middleware {
t.Helper()
m, err := middleware.New(
fxtest.NewLifecycle(t),
middleware.MiddlewareParams{
Logger: log,
Config: cfg,
Session: sess,
Registry: prometheus.NewRegistry(),
},
)
require.NoError(t, err)
return m
}
+1 -2
View File
@@ -18,7 +18,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
func TestPostRateLimit_AllowsGET(t *testing.T) { func TestPostRateLimit_AllowsGET(t *testing.T) {
@@ -199,7 +198,7 @@ func rateLimitMiddleware(
&slog.HandlerOptions{Level: slog.LevelDebug}, &slog.HandlerOptions{Level: slog.LevelDebug},
)) ))
return middlewaretest.New(t, log, cfg, nil) return middleware.NewForTest(log, cfg, nil)
} }
// trustedProxies parses CIDR strings for a test Config. // trustedProxies parses CIDR strings for a test Config.
+32
View File
@@ -0,0 +1,32 @@
package middleware
import (
"log/slog"
"github.com/prometheus/client_golang/prometheus"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/session"
)
// NewForTest creates a Middleware with the minimum dependencies
// needed for testing. This bypasses the fx lifecycle.
//
// Its metrics recorder writes to a fresh registry of its own, so
// Metrics() works on it and two of them never collide.
func NewForTest(
log *slog.Logger,
cfg *config.Config,
sess *session.Session,
) *Middleware {
return &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sess,
metricsRecorder: prommetrics.NewRecorder(
prommetrics.Config{Registry: prometheus.NewRegistry()},
),
}
}
-1
View File
@@ -174,7 +174,6 @@ func newServerApp(
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func(l *logger.Logger) *slog.Logger { return l.Get() },
func() *config.Config { func() *config.Config {
return &config.Config{DataDir: dir} return &config.Config{DataDir: dir}
}, },
+12 -108
View File
@@ -98,25 +98,20 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
} }
// seedBrowserWebhook seeds the webhook the browser test loads, owned by // seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the // userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. The webhook's name and the newer // newer event failed once with a 502. It returns the webhook, the older
// event's content type are each too long for one line on a phone. It // and the newer event, and the target.
// returns the webhook, the older and the newer event, and the target.
func seedBrowserWebhook( func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string, t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Event, *database.Target) { ) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
t.Helper() t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Model(webhook).Update(
"name", "payment_provider_production_notifications",
).Error)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
WebhookID: webhook.ID, WebhookID: webhook.ID,
@@ -131,9 +126,6 @@ func seedBrowserWebhook(
webhookDB, err := env.dbMgr.GetDB(webhook.ID) webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, webhookDB.Model(event).Update(
"content_type", "application/vnd.paymentprovider.event+json",
).Error)
require.NoError(t, webhookDB.Omit(clause.Associations).Create( require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{ &database.DeliveryResult{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -280,23 +272,6 @@ func click(ctx context.Context, t *testing.T, xpath string) {
)) ))
} }
// clickAndLoad clicks the link or button matching an XPath expression
// and waits, as loadPage does, for the page the click opens to load and
// for Alpine.js to start on it. Reading earlier, a check can find an
// element of the page being left, gone by the time its value is read;
// and the wait in shown is too short for a page load on a busy host.
func clickAndLoad(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
_, err := chromedp.RunResponse(
ctx, chromedp.Click(xpath, chromedp.BySearch),
)
require.NoError(t, err)
require.NoError(t, chromedp.Run(
ctx, chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
))
}
// checkAddEntrypoint loads a webhook page and checks that the add // checkAddEntrypoint loads a webhook page and checks that the add
// entrypoint form stays hidden until the Add button beside its heading // entrypoint form stays hidden until the Add button beside its heading
// is clicked. The click looks for a button element there, so it also // is clicked. The click looks for a button element there, so it also
@@ -439,7 +414,7 @@ func checkAddTarget(
))) )))
} }
clickAndLoad(ctx, t, saveButton) click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+ assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+badge+`"]`), `"]/following-sibling::div/span[text()="`+badge+`"]`),
"%s: the added target is not listed as %s", targetType, badge) "%s: the added target is not listed as %s", targetType, badge)
@@ -502,9 +477,10 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
`/following-sibling::span[text()="daily"]`), `/following-sibling::span[text()="daily"]`),
"a database target added with daily is not listed as daily") "a database target added with daily is not listed as daily")
clickAndLoad(ctx, t, row+`//a[text()="Edit"]`) click(ctx, t, row+`//a[text()="Edit"]`)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
chromedp.WaitReady("#expiry", chromedp.ByQuery),
chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery), chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery),
chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery), chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery),
)) ))
@@ -525,7 +501,6 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
const ( const (
refusedURL = "http://127.0.0.1/hook" refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]` urlField = `form[action$="/targets"] input[name="url"]`
forwardQuery = `form[action$="/targets"] input[name="forward_query"]`
reason = `//div[@class="alert-error"]` reason = `//div[@class="alert-error"]`
) )
@@ -536,34 +511,25 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx, ctx,
chromedp.SetValue(targetName, "refused", chromedp.ByQuery), chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery), chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
chromedp.Click(forwardQuery, chromedp.ByQuery),
)) ))
clickAndLoad(ctx, t, saveButton) click(ctx, t, saveButton)
assert.True(t, shown(ctx, reason), assert.True(t, shown(ctx, reason),
"a refused target does not show the reason") "a refused target does not show the reason")
var ( var name, typed string
name, typed string
checked bool
)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
)) ))
assert.Equal(t, "refused", name, assert.Equal(t, "refused", name,
"a refused target does not keep the name entered") "a refused target does not keep the name entered")
assert.Equal(t, refusedURL, typed, assert.Equal(t, refusedURL, typed,
"a refused target does not keep the url entered") "a refused target does not keep the url entered")
assert.True(t, checked,
"a refused target does not keep the query string setting checked")
assert.True(t, shown(ctx, targetName), assert.True(t, shown(ctx, targetName),
"a refused target does not come back with the form open") "a refused target does not come back with the form open")
assert.True(t, hidden(ctx, typeSelect), assert.True(t, hidden(ctx, typeSelect),
@@ -579,15 +545,10 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx, ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
)) ))
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered") assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered") assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
assert.False(t, checked,
"after Cancel, the next Add keeps the query string setting checked")
} }
// checkTargetDeliveries loads a webhook page and checks that the row of // checkTargetDeliveries loads a webhook page and checks that the row of
@@ -652,7 +613,7 @@ func checkRefusedEdits(
)) ))
} }
clickAndLoad(ctx, t, `//button[text()="Save Changes"]`) click(ctx, t, `//button[text()="Save Changes"]`)
assert.Truef(t, shown(ctx, reason), assert.Truef(t, shown(ctx, reason),
"%s: a refused save does not show the reason", edit.url) "%s: a refused save does not show the reason", edit.url)
@@ -776,7 +737,7 @@ func checkEntrypointEdit(
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery), ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
)) ))
clickAndLoad(ctx, t, saveEdit) click(ctx, t, saveEdit)
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`), assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
"saving the edit form does not change the description") "saving the edit form does not change the description")
@@ -814,7 +775,7 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"clicking the newest event does not collapse it") "clicking the newest event does not collapse it")
require.NoError(t, chromedp.Run(ctx, loadPage(url))) require.NoError(t, chromedp.Run(ctx, loadPage(url)))
clickAndLoad(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`) click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`)
assert.True(t, shown(ctx, `//h2[text()="Body"]`), assert.True(t, shown(ctx, `//h2[text()="Body"]`),
"Open does not lead to the event's own page") "Open does not lead to the event's own page")
@@ -1215,7 +1176,7 @@ func checkNewWebhookTargets(
`","rotation":"none"}` `","rotation":"none"}`
} }
clickAndLoad(ctx, t, createButton) click(ctx, t, createButton)
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`), require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
"%s: the new webhook's page does not open", name) "%s: the new webhook's page does not open", name)
@@ -1276,7 +1237,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch), chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch),
chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery), chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery),
)) ))
clickAndLoad(ctx, t, createButton) click(ctx, t, createButton)
assert.True(t, shown(ctx, `//div[@class="alert-error"]`), assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
"a refused webhook does not show the reason") "a refused webhook does not show the reason")
@@ -1331,60 +1292,3 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
click(ctx, t, button) click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu") assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
} }
// scrollsSideways reports whether the page is wider than the window. A
// page's clientWidth is the window's width less its scroll bar.
const scrollsSideways = `document.documentElement.scrollWidth >
document.documentElement.clientWidth`
// cutOffElements lists each element, without elements inside it, that
// is shown but runs past the page's edge or its card's, by more than a
// pixel of rounding. A card hides what runs past its edge.
const cutOffElements = `[...document.querySelectorAll("body *")]
.filter((el) => {
const box = el.getBoundingClientRect();
const card = el.closest(".card")?.getBoundingClientRect();
const left = card ? card.left : 0;
const right = card ? card.right : document.documentElement.clientWidth;
return el.children.length === 0 && box.width > 0 &&
(box.left < left - 1 || box.right > right + 1);
})
.map((el) => el.outerHTML.slice(0, 120))`
// checkPhoneWidth loads the webhook page, url, and its event log,
// eventLog, in a phone-sized window, the event log with the attempts of
// the newest event's delivery to targetName shown. It checks that
// neither page scrolls sideways and that nothing shown on either, no
// status, time or control, is cut off at the page's or its card's edge.
func checkPhoneWidth(
ctx context.Context, t *testing.T, url, eventLog, targetName string,
) {
t.Helper()
var (
sideways bool
cutOff []string
)
measure := chromedp.Tasks{
chromedp.Evaluate(scrollsSideways, &sideways),
chromedp.Evaluate(cutOffElements, &cutOff),
}
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
measure,
))
assert.False(t, sideways, "the webhook page scrolls sideways on a phone")
assert.Empty(t, cutOff, "the webhook page cuts these off on a phone")
require.NoError(t, chromedp.Run(ctx, loadPage(eventLog)))
click(ctx, t, `//span[text()="`+targetName+`"]`)
require.True(t, shown(ctx, `//span[text()="Attempt 1"]`),
"clicking the delivery does not show its attempts")
require.NoError(t, chromedp.Run(ctx, measure))
assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
}
-2
View File
@@ -3,7 +3,6 @@ package server_test
import ( import (
"context" "context"
"html" "html"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -131,7 +130,6 @@ func newTestEnvWithConfig(
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func(l *logger.Logger) *slog.Logger { return l.Get() },
func() *config.Config { return cfg }, func() *config.Config { return cfg },
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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/logger"
"sneak.berlin/go/webhooker/internal/reqtls" "sneak.berlin/go/webhooker/internal/reqtls"
) )
@@ -79,7 +80,7 @@ type Params struct {
Config *config.Config Config *config.Config
Database *database.Database Database *database.Database
Logger *slog.Logger Logger *logger.Logger
} }
// Session manages encrypted session storage. // Session manages encrypted session storage.
@@ -179,7 +180,7 @@ func New(
params Params, params Params,
) (*Session, error) { ) (*Session, error) {
s := &Session{ s := &Session{
log: params.Logger, log: params.Logger.Get(),
idleTimeout: params.Config.SessionIdleTimeout, idleTimeout: params.Config.SessionIdleTimeout,
now: time.Now, now: time.Now,
} }
@@ -16,7 +16,8 @@ func NewStore(key []byte) *sessions.CookieStore {
} }
// NewForTest creates a Session with a pre-configured cookie store for use // NewForTest creates a Session with a pre-configured cookie store for use
// in tests. This bypasses the fx lifecycle and database dependency. The key // in tests. This bypasses the fx lifecycle and database dependency, allowing
// middleware and handler tests to use real session functionality. The key
// parameter is the raw 32-byte authentication key used for session encryption // parameter is the raw 32-byte authentication key used for session encryption
// and CSRF cookie signing. // and CSRF cookie signing.
// //
+1 -2
View File
@@ -3,6 +3,5 @@
"devDependencies": { "devDependencies": {
"eslint": "10.11.0", "eslint": "10.11.0",
"prettier": "3.9.9" "prettier": "3.9.9"
}, }
"packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f"
} }
+2 -2
View File
@@ -2,8 +2,8 @@
# script/assets: extract Alpine.js from its npm package tarball, committed # script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package # in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy # is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. make build, make dev # forbids eval. The extracted file is not committed. script/test, make
# and the Dockerfile's lint, test and build stages run this first. # build and make dev run this first.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+4 -12
View File
@@ -11,7 +11,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
APT_UPDATED=""
detect_pkgmgr() { detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0 [ -n "$PKGMGR" ] && return 0
@@ -40,14 +39,7 @@ pkg_install() {
detect_pkgmgr detect_pkgmgr
case "$PKGMGR" in case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;; nix) nix-env -iA "nixpkgs.$1" ;;
apt) apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
# Package lists may be empty (fresh images); refresh once per run.
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;; brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;; apk) apk add --no-cache "$4" ;;
esac esac
@@ -68,10 +60,10 @@ main() {
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# Not installed here: docker is platform-specific and out of scope for a # Not installed here: docker is platform-specific and out of scope for a
# package-manager bootstrap, but script/test, script/lint, script/fmt and # package-manager bootstrap, but script/lint, script/fmt and script/css
# script/css need it. # need it.
if missing docker; then if missing docker; then
echo "bootstrap: docker not found; script/test, script/lint, script/fmt and script/css require it" >&2 echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2
fi fi
go mod download go mod download
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check, css-check). Our own # script/check: run all checks (test, lint, fmt-check, css-check). Our own
# extension to scripts-to-rule-them-all. test, lint and css-check are # extension to scripts-to-rule-them-all.
# Docker builds; fmt-check runs gofmt on the host. Writes nothing. # Writes only the ignored static/js/alpine.min.js, through script/test.
# Generic, apart from css-check. # Generic, apart from css-check.
set -eu set -eu
+152
View File
@@ -0,0 +1,152 @@
#!/bin/sh
# script/ci-mark-superseded: record an honest status on commits whose CI
# run Gitea cancelled because a newer commit landed on the same branch.
# Gitea writes `failure` / "Has been cancelled" for such a run, which
# reads as a test result on a commit nothing ever tested. Cancellation is
# unconditional server-side for push events, so the superseding run
# rewrites those statuses to `failure` with a description that says the
# commit was never tested. `skipped` cannot be used: Gitea's combined
# status folds `skipped` into `success`, so a never-tested commit would
# report green. Genuine failures and successes are never touched.
#
# Called by the Gitea Actions workflow, which supplies GITHUB_API_URL,
# GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW, GITHUB_JOB,
# GITHUB_EVENT_NAME and GITEA_TOKEN. ANCESTOR_LIMIT (default 20) caps how
# far back the walk looks; a value that is set but not a positive integer
# aborts rather than silently disabling the walk.
set -eu
SUPERSEDED_DESC='Superseded by a newer commit; never tested'
# Gitea builds the commit-status context as
# "<workflow name> / <job name> (<event>)", so derive it rather than
# hardcoding the result.
#
# The derivation is deliberately not byte-exact with Gitea's own rule and
# must not be "fixed" into a silent fallback. Gitea uses the job's `name:`
# (falling back to the job id) and the workflow's `name:` (falling back to
# the workflow filename), while the runner exports GITHUB_JOB as the job
# *id* and GITHUB_WORKFLOW as the parsed workflow `name:`. So giving the
# job a display `name:`, or dropping the workflow's `name:`, makes the
# derived context stop matching --- and require_own_context below then
# turns every push red with a message. That loud failure is the point
# (https://git.eeqj.de/sneak/webhooker/issues/147 item 2); guessing at a
# fallback would restore the silent no-op it replaced.
context() {
printf '%s / %s (%s)' \
"$GITHUB_WORKFLOW" "$GITHUB_JOB" "$GITHUB_EVENT_NAME"
}
# ANCESTOR_LIMIT is a documented knob, so a value that is set but
# unusable must fail loudly instead of defaulting
# (https://git.eeqj.de/sneak/webhooker/issues/80). Passing it straight to
# git would print `fatal: not an integer` into a discarded exit status
# and mark nothing.
ancestor_limit() {
# `-` and not `:-`: an explicitly empty value is set-but-unusable
# config, so it aborts like any other bad value rather than silently
# running at the default.
_limit="${ANCESTOR_LIMIT-20}"
case "$_limit" in
'' | *[!0-9]* | 0*)
echo "ANCESTOR_LIMIT must be a positive integer," \
"got '${_limit}'" >&2
return 1
;;
esac
printf '%s' "$_limit"
}
# The status Gitea created for this very job proves which context string
# it uses. If the derived one is missing, the workflow or the job was
# renamed and the match below would silently stop firing, restoring the
# false-red bug with no signal. Fail loudly instead.
require_own_context() {
if ! _body="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
"${1}/commits/${GITHUB_SHA}/status")"; then
echo "cannot read commit statuses for ${GITHUB_SHA}" >&2
return 1
fi
_found="$(printf '%s' "$_body" | jq -r '(.statuses // [])[].context')"
if printf '%s\n' "$_found" | grep -qxF "$2"; then
return 0
fi
echo "no commit status with context '${2}' on ${GITHUB_SHA}:" >&2
echo "workflow or job renamed? contexts present:" >&2
printf '%s\n' "$_found" >&2
return 1
}
# Latest status for our context on a commit, as "state|description".
# The read is retried and bounded, and a read that still fails aborts the
# step: a laundered commit that cannot be read is not the same as one
# with nothing to do, and piping curl into jq would discard the
# difference.
status_of() {
if ! _sbody="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
"${1}/commits/${2}/status")"; then
echo "cannot read commit statuses for ${2}" >&2
return 1
fi
printf '%s' "$_sbody" | jq -r --arg c "$3" \
'[(.statuses // [])[] | select(.context == $c)][0] // empty
| "\(.status)|\(.description)"'
}
mark_superseded() {
curl -sf -X POST "${1}/statuses/${2}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$3" --arg d "$SUPERSEDED_DESC" \
'{context: $c, state: "failure", description: $d}')" \
>/dev/null
}
main() {
_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
_ctx="$(context)"
_limit="$(ancestor_limit)"
require_own_context "$_api" "$_ctx"
# A shallow clone cannot resolve the parent, so it looks exactly like
# a root commit to rev-parse below and would exit 0 having walked
# nothing (or, at depth > 1, only the ancestors that happen to be
# present). The workflow checks out with `fetch-depth: 0`; verify
# that here rather than depend on it silently.
if [ "$(git rev-parse --is-shallow-repository)" = 'true' ]; then
echo "shallow repository: the ancestor walk needs full history" >&2
return 1
fi
# A root commit legitimately has no ancestors and is not an error.
# A SHA this repository does not have lands here too, since its
# parent is equally unresolvable, but require_own_context above has
# already aborted on the 404 for it. The walk itself carries no
# `|| true`, so a rev-list failure aborts.
if ! git rev-parse -q --verify "${GITHUB_SHA}^" >/dev/null; then
echo "no ancestor of ${GITHUB_SHA} to check"
return 0
fi
_walk="$(git rev-list --max-count="$_limit" "${GITHUB_SHA}^")"
for _sha in $_walk; do
_latest="$(status_of "$_api" "$_sha" "$_ctx")"
# A run that was cancelled, or one an earlier revision of this
# script laundered into `skipped`. Anything else stands.
case "$_latest" in
'failure|Has been cancelled' | "skipped|${SUPERSEDED_DESC}") ;;
*) continue ;;
esac
mark_superseded "$_api" "$_sha" "$_ctx"
echo "marked superseded: ${_sha}"
done
}
main "$@"
+6 -19
View File
@@ -1,28 +1,15 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. The Dockerfile runs the checks (the
# checks out and runs this and nothing else, and script/fmt-check runs # gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown
# the formatter on the host, which a pristine checkout cannot do. # check, make test), so a successful build implies a green repo. Generic:
# --no-cache for the same reason as script/docker: the gate phases the # needs no adaptation. The Gitea workflow runs this on push.
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" docker build .
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+2 -4
View File
@@ -2,16 +2,14 @@
# script/css: regenerate static/css/tailwind.css (writes). tailwindcss is # script/css: regenerate static/css/tailwind.css (writes). tailwindcss is
# never installed locally: it runs in docker, at the version and sha256 # never installed locally: it runs in docker, at the version and sha256
# pinned in the Dockerfile's stylesheet stages, which also say what the # pinned in the Dockerfile's stylesheet stages, which also say what the
# stylesheet is generated from. --no-cache, as on every docker build in # stylesheet is generated from.
# script/, so the stylesheet is generated rather than taken from the cache.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build --target css-output --output type=local,dest=static/css .
--target css-output --output type=local,dest=static/css .
} }
main "$@" main "$@"
+2 -3
View File
@@ -1,15 +1,14 @@
#!/bin/sh #!/bin/sh
# script/css-check: fail when static/css/tailwind.css differs from what # script/css-check: fail when static/css/tailwind.css differs from what
# script/css would generate (read-only). The comparison is the Dockerfile's # script/css would generate (read-only). The comparison is the Dockerfile's
# css-check stage, which the image build runs too. --no-cache because a # css-check stage, which the image build runs too.
# cached check is a check that did not run.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache --target css-check --output type=cacheonly . docker build --target css-check --output type=cacheonly .
} }
main "$@" main "$@"
+7 -11
View File
@@ -1,8 +1,10 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # The tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN #
# steps, and a cached one is a check that did not run. # The version script/version resolves here goes in as the VERSION build
# arg, which takes precedence over what the build would derive from the
# .git in its context.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,14 +12,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does docker build \
# not trip `set -e`, so the inline form degrades silently to an --build-arg VERSION="$("$SCRIPT_DIR/version")" \
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+2 -4
View File
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes): the Go code with gofmt and # script/fmt: format all files (writes): the Go code with gofmt and
# goimports, the Markdown with prettier. prettier is never installed # goimports, the Markdown with prettier. prettier is never installed
# locally: it runs in docker, in the Dockerfile's Markdown stages, built # locally: it runs in docker, in the Dockerfile's Markdown stages.
# with --no-cache like every docker build in script/.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -13,8 +12,7 @@ main() {
if command -v goimports >/dev/null 2>&1; then if command -v goimports >/dev/null 2>&1; then
goimports -w . goimports -w .
fi fi
docker build --no-cache \ docker build --target markdown-output --output type=local,dest=. .
--target markdown-output --output type=local,dest=. .
} }
main "$@" main "$@"
+2 -3
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as # script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing. --no-cache because a cached # script/fmt, but fails instead of writing.
# check is a check that did not run.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -13,7 +12,7 @@ main() {
gofmt -s -l . gofmt -s -l .
exit 1 exit 1
fi fi
docker build --no-cache --target markdown-check --output type=cacheonly . docker build --target markdown-check --output type=cacheonly .
} }
main "$@" main "$@"
+61 -13
View File
@@ -1,23 +1,71 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and # script/lint: run the linters, golangci-lint over the Go code and then
# this builds that phase alone; the linter is never installed or run on # ESLint over static/js/. Neither is ever installed locally.
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
# #
# The phase is not the last stage in the file, so it is built only when # golangci-lint runs via docker only, one way, everywhere — script/lint builds
# --target names it. --no-cache because a cached lint layer is a lint # Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
# that did not run. The tag makes each build replace the previous image # and lints as a build step. This works even when the docker daemon is remote
# instead of leaving a dangling one behind. # and bind mounts are impossible, and it removes the host linter's shared
# cache, which has attributed other checkouts' findings to this one.
#
# --no-cache-filter=lint forces the lint stage to re-execute on every run; a
# cached lint stage exits 0 in under a second having linted nothing. The deps
# stage keeps its cache, so module downloads are not repeated.
# --progress=plain keeps the linter's own output visible on success, so a
# passing run shows the issue count rather than nothing.
# --output=type=cacheonly leaves no image behind to clean up.
#
# docker silently ignores --no-cache-filter for a stage name that does not
# match, so a rename or a typo would restore the cached false green with no
# warning and a fast exit 0. The flag is therefore not trusted: the build
# output is teed to a log and a run is only a pass if golangci-lint's own
# summary line ("N issues." / "N issues:") is in it. No summary, no lint,
# whatever the exit code says.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \
--target lint \ log="$(mktemp -t webhooker-lint.XXXXXXXX)"
-t "$("$SCRIPT_DIR/projectname")-lint" . rcfile="$(mktemp -t webhooker-lint-rc.XXXXXXXX)"
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
# The pipeline's status is tee's, and POSIX sh has no pipefail, so the
# build's status travels via a file. Output still streams live.
{
docker build \
-f Dockerfile.lint \
--no-cache-filter=lint \
--progress=plain \
--output=type=cacheonly \
. 2>&1 && echo 0 >"$rcfile" || echo $? >"$rcfile"
} | tee "$log" >&2
rc="$(cat "$rcfile")"
[ "$rc" -eq 0 ] || exit "$rc"
if ! grep -qE '[0-9]+ issues[.:]' "$log"; then
echo "script/lint: golangci-lint printed no summary line; the linter" >&2
echo " did not run. Check that the stage named in --no-cache-filter" >&2
echo " still matches a stage in Dockerfile.lint." >&2
exit 1
fi
# ESLint runs in the Dockerfile's js-lint stage, which the image build
# runs too. It prints nothing on a pass, so there is no summary to look
# for. Instead the stage is named once, for both flags: --target fails
# on a name that matches no stage, so a rename cannot leave
# --no-cache-filter silently ignored. The js-deps stage, which installs
# ESLint, keeps its cache, so ESLint is not downloaded again.
js_stage=js-lint
docker build \
--target "$js_stage" \
--no-cache-filter="$js_stage" \
--progress=plain \
--output=type=cacheonly \
.
} }
main "$@" main "$@"
+75 -10
View File
@@ -1,19 +1,84 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite.
# and this builds that phase alone, on the same terms as script/lint: #
# --target because a phase that is not the last stage is built only when # -timeout is applied by `go test` per package, not to the run as a whole, so
# named, --no-cache because a cached test layer is a test that did not # it only has to clear the slowest single package. When this budget was set
# run, and a tag so each build replaces the previous image. # that was internal/handlers, measured in a cache-defeated builder stage on the
# 48-core shared build host (2026-08-18); load- and host-dependent, not
# invariants:
#
# 16.9s host load 5-20, GOMAXPROCS 48
# 45.9s / 47.3s / 49.0s three runs at deliberate host load 31-73
# 30.6s / 39.7s host load 5-20, GOMAXPROCS 6 / 4
# 67.3s / 97.5s host load 5-20, GOMAXPROCS 2 / 1
# 67.3s GOMAXPROCS 4 at deliberate host load 52-68
#
# The old 30s budget was breached by every loaded run and by every GOMAXPROCS
# at or below 6; at GOMAXPROCS 4 it failed outright ("panic: test timed out
# after 30s"), reproduced on 33e4fa4 with no other change.
#
# 90s matches the org-wide backstop in REPO_POLICIES.md and is sized here
# against the figures above: the worst case under native parallelism is 49.0s,
# and the compound GOMAXPROCS-4-under-load case at 67.3s sits at 75% of it.
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
# 67s, that is the datum to revisit the org figure with.
#
# Those figures predate tests hashing the admin password at 1 MB instead of
# 64 MB (https://git.eeqj.de/sneak/webhooker/pulls/404). After that change, in
# a cache-defeated build at host load 44-109 (2026-10-02), internal/handlers
# took 8.5s and the slowest package was internal/database at 15.8s. Once its
# retention tests seeded 50 rows per insert instead of 500
# (https://git.eeqj.de/sneak/webhooker/issues/198), internal/database took
# 7.3s and the slowest package was internal/handlers at 8.1s to 10.0s, at host
# load 25-48 (2026-10-02).
#
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
# binaries build or run at once, each with at most eight parallel tests. Under
# -race every test binary and every link costs a few hundred MB, so the
# defaults (one per core) add up to several GB on a many-core host.
#
# The first run has no -v: go test then prints one result line per package,
# with its coverage, and for a package that fails, everything its tests wrote,
# application log lines included. Verbose output from the whole suite passes
# the 2 MiB at which the Docker build cuts off each step's log, so on a failure
# only the tests that failed run again, with -v. The script exits 1 after that
# rerun whatever its result: the first run already showed the suite is broken.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ "$ROOT/script/assets"
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" . log="$(mktemp -t webhooker-test.XXXXXXXX)"
rcfile="$(mktemp -t webhooker-test-rc.XXXXXXXX)"
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
# The pipeline's status is tee's, and POSIX sh has no pipefail, so go
# test's status travels via a file. Output still streams live.
{
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 \
&& echo 0 >"$rcfile" || echo $? >"$rcfile"
} | tee "$log"
if [ "$(cat "$rcfile")" -eq 0 ]; then
return
fi
# go test reports a failed test as a line starting "--- FAIL: TestName"
# (a failed subtest's line is indented, and reruns with its parent), and
# a failed package as "FAIL<tab>package/path<tab>...". A failure that
# names no test, such as a build error or a timeout, is already shown in
# full above, so there is nothing to rerun.
tests="$(awk '/^--- FAIL: / { print $3 }' "$log" | paste -s -d '|' -)"
packages="$(awk '/^FAIL\t/ { print $2 }' "$log")"
if [ -n "$tests" ]; then
echo "--- Rerunning the failed tests with -v for details ---"
go test -race -v -p 4 -parallel 8 -timeout 90s \
-run "^($tests)\$" $packages || true
fi
exit 1
} }
main "$@" main "$@"
+3 -2
View File
@@ -3,7 +3,8 @@
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned # Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
# headless browser image, so the host needs no browser. # headless browser image, so the host needs no browser.
# #
# --no-cache because a cached test layer is a test that did not run. # --no-cache-filter=browser runs the test again even when nothing changed;
# it must name the stage in Dockerfile.browser that runs it.
# --output=type=cacheonly leaves no image behind to clean up. # --output=type=cacheonly leaves no image behind to clean up.
set -eu set -eu
@@ -13,7 +14,7 @@ main() {
cd "$ROOT" cd "$ROOT"
docker build \ docker build \
-f Dockerfile.browser \ -f Dockerfile.browser \
--no-cache \ --no-cache-filter=browser \
--progress=plain \ --progress=plain \
--output=type=cacheonly \ --output=type=cacheonly \
. .
+3 -5
View File
@@ -1,11 +1,9 @@
#!/bin/sh #!/bin/sh
# script/version: output the version string the binary is stamped with. # script/version: output the version string the binary is stamped with.
# Our own extension to scripts-to-rule-them-all. The Makefile's build # Our own extension to scripts-to-rule-them-all. The Makefile's build
# and version targets take the value from here, and the Dockerfile's # target and script/docker both take the value from here, so a `make
# build stage calls them. script/docker and script/cibuild run the same # build` binary and a `make docker` image built from the same checkout
# `git describe` on the host and pass the result in as $VERSION, so a # report the same thing.
# `make build` binary and a `make docker` image built from the same
# checkout report the same thing.
# #
# Order of precedence: # Order of precedence:
# #
File diff suppressed because one or more lines are too long

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